From 61bb6d44305cd1b84c6b011e13ba6533048475ba Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 15 May 2026 18:15:31 +0400 Subject: [PATCH] wip --- apps/ios/Shared/Model/AppAPITypes.swift | 17 +- apps/ios/Shared/Model/ChatModel.swift | 82 ++++- apps/ios/Shared/Model/SimpleXAPI.swift | 21 +- .../Shared/Views/Chat/ChatInfoToolbar.swift | 2 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 10 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItem/FramedItemView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 56 ++-- .../Views/Chat/Group/GroupChatInfoView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 4 +- .../Views/Chat/Group/MemberSupportView.swift | 2 +- .../Views/Chat/Group/SecondaryChatView.swift | 2 +- .../Chat/SelectableChatItemToolbars.swift | 4 +- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Shared/Views/ChatList/ChatListView.swift | 6 +- .../Views/ChatList/ChatPreviewView.swift | 6 +- .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../Shared/Views/NewChat/NewChatView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 125 ++++++-- apps/ios/SimpleXChat/ChatUtils.swift | 4 +- apps/ios/SimpleXChat/Notifications.swift | 2 +- apps/ios/spec/api.md | 32 +- apps/ios/spec/impact.md | 14 +- apps/ios/spec/state.md | 288 ++++++++++-------- 24 files changed, 448 insertions(+), 245 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 547c2b7000..0c082458b4 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -42,10 +42,12 @@ enum ChatCommand: ChatCmdProtocol { case apiGetSettings(settings: AppSettings) case apiGetChatTags(userId: Int64) case apiGetChats(userId: Int64) - case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) + case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String, parentItemId: Int64? = nil) case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendComment(groupId: Int64, parentItemId: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSetCommentsDisabled(groupId: Int64, parentItemId: Int64, disabled: Bool) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -231,9 +233,10 @@ 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, scope, contentTag, pagination, search): + case let .apiGetChat(chatId, scope, contentTag, pagination, search, parentItemId): + let parent = parentItemId != nil ? " parent=\(parentItemId!)" : "" let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : "" - return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + return "/_get chat \(chatId)\(scopeRef(scope))\(parent)\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))" case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): @@ -241,6 +244,12 @@ enum ChatCommand: ChatCmdProtocol { let ttlStr = ttl != nil ? "\(ttl!)" : "default" let asGroup = sendAsGroup ? "(as_group=on)" : "" return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + case let .apiSendComment(groupId, parentItemId, live, ttl, composedMessages): + let msgs = encodeJSON(composedMessages) + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_comment #\(groupId) \(parentItemId) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + case let .apiSetCommentsDisabled(groupId, parentItemId, disabled): + return "/_comments_disabled #\(groupId) \(parentItemId) \(onOff(disabled))" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" 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)" @@ -442,6 +451,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetChatContentTypes: return "apiGetChatContentTypes" case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" + case .apiSendComment: return "apiSendComment" + case .apiSetCommentsDisabled: return "apiSetCommentsDisabled" case .apiCreateChatTag: return "apiCreateChatTag" case .apiSetChatTags: return "apiSetChatTags" case .apiDeleteChatTag: return "apiDeleteChatTag" diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1d28b8e22..71de43a212 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -58,6 +58,10 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { enum SecondaryItemsModelFilter { case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) case msgContentTagContext(contentTag: MsgContentTag) + // Comments thread under a channel post. The parent ChatItem carries the + // toolbar preview text (slice 4) and the parent.id used to route inbound + // items (ci.parentChatItemId == parent.id). + case groupChannelMsgContext(parent: ChatItem) func descr() -> String { switch self { @@ -65,6 +69,8 @@ enum SecondaryItemsModelFilter { return "groupChatScopeContext \(groupScopeInfo.toChatScope())" case let .msgContentTagContext(contentTag): return "msgContentTagContext \(contentTag.rawValue)" + case let .groupChannelMsgContext(parent): + return "groupChannelMsgContext parent=\(parent.id)" } } } @@ -108,11 +114,58 @@ class ItemsModel: ObservableObject { // Spec: spec/state.md#loadSecondaryChat static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { + // Comments-thread context: fetch with parentItemId, then inject the local-only + // ChannelMsgInfo carrier into the returned ChatInfo (the wire shape has no + // channelMsgInfo field; the carrier is set here so views can read it from cInfo). + // The owner-side parent may briefly lack itemSharedMsgId during send — guard against that. + if case let .groupChannelMsgContext(parent) = chatFilter { + guard let sharedId = parent.meta.itemSharedMsgId else { return } + let im = ItemsModel(secondaryIMFilter: chatFilter) + ChatModel.shared.secondaryIM = im + Task { + do { + let (chat, _) = try await apiGetChat( + chatId: chatId, + scope: nil, + pagination: .last(count: loadItemsPerPage), + parentItemId: parent.id + ) + let rewritten = injectChannelMsgInfo(chat.chatInfo, parent: parent, sharedId: sharedId) + await MainActor.run { + im.reversedChatItems = chat.chatItems.reversed() + ChatModel.shared.chatId = chatId + // Replace the chat in the model with the rewritten info so views + // querying secondaryIM.cInfo see the injected ChannelMsgInfo. + if let i = ChatModel.shared.getChatIndex(chatId) { + ChatModel.shared.chats[i].chatInfo = rewritten + } + willNavigate() + } + } catch { + logger.error("loadSecondaryChat (comments thread) failed: \(responseError(error))") + } + } + return + } let im = ItemsModel(secondaryIMFilter: chatFilter) ChatModel.shared.secondaryIM = im im.loadOpenChat(chatId, willNavigate: willNavigate) } + // Rewrites a ChatInfo.group's third associated value to embed the comments-thread + // carrier. Other ChatInfo cases (direct, local, contactRequest, contactConnection) + // are not comment contexts and pass through unchanged. + private static func injectChannelMsgInfo(_ cInfo: ChatInfo, parent: ChatItem, sharedId: String) -> ChatInfo { + if case let .group(groupInfo, groupChatScope, _) = cInfo { + return .group( + groupInfo: groupInfo, + groupChatScope: groupChatScope, + channelMsgInfo: ChannelMsgInfo(channelMsgItem: parent, channelMsgSharedId: sharedId) + ) + } + return cInfo + } + // Spec: spec/state.md#loadOpenChat func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { navigationTimeoutTask?.cancel() @@ -511,7 +564,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 @@ -566,8 +619,8 @@ final class ChatModel: ObservableObject { // Spec: spec/state.md#updateChatInfo func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { - if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { - chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil) + if case let .group(groupInfo, groupChatScope, _) = cInfo, groupChatScope != nil { + chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil, channelMsgInfo: nil) } else { chats[i].chatInfo = cInfo } @@ -592,7 +645,7 @@ final class ChatModel: ObservableObject { } func updateGroup(_ groupInfo: GroupInfo) { - updateChat(.group(groupInfo: groupInfo, groupChatScope: nil)) + updateChat(.group(groupInfo: groupInfo, groupChatScope: nil, channelMsgInfo: nil)) } private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { @@ -657,7 +710,7 @@ final class ChatModel: ObservableObject { // update chat list if let i = getChatIndex(cInfo.id) { // update preview - if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false { + if (cInfo.groupChatScope() == nil && cInfo.channelMsgInfo() == nil) || cInfo.groupInfo?.membership.memberPending ?? false { chats[i].chatItems = switch cInfo { case .group: if let currentPreviewItem = chats[i].chatItems.first { @@ -679,7 +732,7 @@ final class ChatModel: ObservableObject { // pop chat popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) } else { - if cInfo.groupChatScope() == nil { + if cInfo.groupChatScope() == nil && cInfo.channelMsgInfo() == nil { addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) } else { addChat(Chat(chatInfo: cInfo, chatItems: [])) @@ -709,6 +762,15 @@ final class ChatModel: ObservableObject { default: nil } + } else if cInfo.channelMsgInfo() != nil { + // Comments thread open. Inbound items lack channelMsgInfo on the wire (the + // carrier is local-only), so route by ci.parentChatItemId == parent.id. + switch secondaryIM?.secondaryIMFilter { + case let .some(.groupChannelMsgContext(parent)): + (cInfo.id == chatId && ci.parentChatItemId == parent.id) ? secondaryIM : nil + default: + nil + } } else { cInfo.id == chatId ? im : nil } @@ -717,7 +779,7 @@ final class ChatModel: ObservableObject { func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { // update chat list var itemAdded: Bool = false - if cInfo.groupChatScope() == nil { + if cInfo.groupChatScope() == nil && cInfo.channelMsgInfo() == 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) { @@ -788,7 +850,7 @@ final class ChatModel: ObservableObject { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { // update chat list - if cInfo.groupChatScope() == nil { + if cInfo.groupChatScope() == nil && cInfo.channelMsgInfo() == nil { if cItem.isRcvNew { unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) } @@ -1290,7 +1352,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) } } @@ -1356,7 +1418,7 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var supportUnreadCount: Int { switch chatInfo { - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): if groupInfo.canModerate { return groupInfo.membersRequireAttention } else { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ea2de31569..586b2d8370 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -450,8 +450,8 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] { let loadItemsPerPage = 50 -func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { - let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search)) +func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "", parentItemId: Int64? = nil) async throws -> (Chat, NavigationInfo) { + let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search, parentItemId: parentItemId)) if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) } throw r.unexpected } @@ -547,6 +547,21 @@ func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGr return await processSendMessageCmd(toChatType: type, cmd: cmd) } +// Send a comment on a channel post. The target chat is always a channel (group with useRelays); +// the recipient is the relay, which forwards the comment to other subscribers. +// Returns the locally-created ChatItem(s) for the sender's view; typically a singleton. +func apiSendComment(groupId: Int64, parentItemId: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendComment(groupId: groupId, parentItemId: parentItemId, live: live, ttl: ttl, composedMessages: composedMessages) + return await processSendMessageCmd(toChatType: .group, cmd: cmd) +} + +// Toggle commentsDisabled on a channel post via XMsgUpdate.prefs. The Haskell handler +// returns CRCmdOk; the local parent ChatItem update arrives via the owner's own echo +// of XMsgUpdate.prefs through the standard receive pipeline. No item returned here. +func apiSetCommentsDisabled(groupId: Int64, parentItemId: Int64, disabled: Bool) async throws { + try await sendCommandOkResp(.apiSetCommentsDisabled(groupId: groupId, parentItemId: parentItemId, disabled: disabled)) +} + private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? { let chatModel = ChatModel.shared let r: APIResult @@ -2919,7 +2934,7 @@ func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemID return } let im = ItemsModel.shared - let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil) + let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil, channelMsgInfo: nil) await MainActor.run { m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index e158b9374f..daad8107f9 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -82,7 +82,7 @@ struct ChatInfoToolbar: View { } private var channelSubscriberCount: Int64? { - if case let .group(groupInfo, _) = chat.chatInfo, + if case let .group(groupInfo, _, _) = chat.chatInfo, groupInfo.useRelays, let count = groupInfo.groupSummary.publicMemberCount, count > 0 { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c17d8e23a8..9fa9b3dd7f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -956,7 +956,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() @@ -992,7 +992,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() @@ -1070,12 +1070,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, groupChatScope: nil)) - chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) + ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil, channelMsgInfo: nil)) + chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil, channelMsgInfo: nil)) themes = themesFromChat(chat.wrappedValue) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index ec23dc15a4..ad58331fd3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -47,7 +47,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) @@ -85,7 +85,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/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d09289c1d5..46f264a853 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -313,7 +313,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 } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index a141a53b4c..1bc7cb4a87 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -85,7 +85,7 @@ struct ChatView: View { private var viewBody: some View { let cInfo = chat.chatInfo let memberSupportChat: (groupInfo: GroupInfo, member: GroupMember?)? = - if case let .group(groupInfo, .memberSupport(member)) = cInfo { + if case let .group(groupInfo, .memberSupport(member), _) = cInfo { (groupInfo, member) } else { nil @@ -156,7 +156,7 @@ struct ChatView: View { showArchiveSelectedReports = true }, moderateItems: { - if case let .group(groupInfo, _) = chat.chatInfo { + if case let .group(groupInfo, _, _) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) } }, @@ -167,7 +167,7 @@ struct ChatView: View { if im.showLoadingProgress == chat.id { ProgressView().scaleEffect(2) } - if case let .group(groupInfo, _) = chat.chatInfo, + if case let .group(groupInfo, _, _) = chat.chatInfo, case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, case let .memberSupport(groupMember_) = groupScopeInfo, let groupMember = groupMember_ { @@ -225,7 +225,7 @@ struct ChatView: View { archiveReports(chat, 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, selected.sorted(), true, deletedSelectedMessages) @@ -236,7 +236,7 @@ struct ChatView: View { .appSheet(item: $selectedMember, onDismiss: { chatModel.secondaryIM = nil }) { member in - if case let .group(groupInfo, _) = chat.chatInfo { + if case let .group(groupInfo, _, _) = chat.chatInfo { GroupMemberInfoView( groupInfo: groupInfo, chat: chat, @@ -248,12 +248,12 @@ 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) { - if case let .group(groupInfo, _) = cInfo { + if case let .group(groupInfo, _, _) = cInfo { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } .appSheet(isPresented: $showGroupLinkSheet) { - if case let .group(groupInfo, _) = cInfo { + if case let .group(groupInfo, _, _) = cInfo { GroupLinkView( groupId: groupInfo.groupId, groupLink: $groupLink, @@ -292,7 +292,7 @@ struct ChatView: View { ) { if let groupInfo = cInfo.groupInfo { SecondaryChatView( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()), + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo, channelMsgInfo: nil), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } @@ -314,7 +314,7 @@ struct ChatView: View { } } // if this is the main chat of the group with the pending member (knocking) - if case let .group(groupInfo, nil) = chat.chatInfo, + if case let .group(groupInfo, nil, _) = chat.chatInfo, groupInfo.membership.memberPending { ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) { showUserSupportChatSheet = true @@ -492,7 +492,7 @@ struct ChatView: View { onSearch: { focusSearch() } ) } - } else if case let .group(groupInfo, _) = cInfo { + } else if case let .group(groupInfo, _, _) = cInfo { Button { Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } } label: { @@ -508,7 +508,7 @@ struct ChatView: View { groupInfo: Binding( get: { groupInfo }, set: { gInfo in - chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil) + chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil, channelMsgInfo: nil) chat.created = Date.now } ), @@ -588,7 +588,7 @@ struct ChatView: View { Image(systemName: "ellipsis") } } - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): HStack { contentFilterMenu(withLabel: false) Menu { @@ -736,7 +736,7 @@ struct ChatView: View { } } } - if case let .group(groupInfo, _) = cInfo, groupInfo.useRelays { + if case let .group(groupInfo, _, _) = cInfo, groupInfo.useRelays { Task { await chatModel.loadGroupMembers(groupInfo) } if groupInfo.membership.memberRole == .owner { Task { @@ -1047,7 +1047,7 @@ struct ChatView: View { return ("SimpleX address", connLink) } } - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): if !groupInfo.nextConnectPrepared { if let pg = groupInfo.preparedGroup { let connLink = pg.connLinkToConnect.simplexChatUri() @@ -1080,7 +1080,7 @@ struct ChatView: View { } else { "Your contact" } - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): switch groupInfo.businessChat?.chatType { case .none: if groupInfo.nextConnectPrepared { @@ -1116,7 +1116,7 @@ struct ChatView: View { } else { nil } - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): if groupInfo.useRelays { nil } else { @@ -1419,7 +1419,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: { @@ -1429,7 +1429,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 gLink = try apiGetGroupLink(gInfo.groupId) { @@ -1443,7 +1443,7 @@ struct ChatView: View { } } } label: { - if case let .group(gInfo, _) = chat.chatInfo, gInfo.useRelays { + if case let .group(gInfo, _, _) = chat.chatInfo, gInfo.useRelays { Label("Channel link", systemImage: "link") } else { Label("Group link", systemImage: "link.badge.plus") @@ -1916,7 +1916,7 @@ struct ChatView: View { ) -> some View { let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 if case .channelRcv = ci.chatDir, - case let .group(groupInfo, _) = chat.chatInfo { + case let .group(groupInfo, _, _) = chat.chatInfo { if showAvatar { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { @@ -1983,7 +1983,7 @@ struct ChatView: View { .padding(.bottom, bottomPadding) } } else if case let .groupRcv(member) = ci.chatDir, - case let .group(groupInfo, _) = chat.chatInfo { + case let .group(groupInfo, _, _) = chat.chatInfo { if showAvatar { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { @@ -2196,7 +2196,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, reports.sorted(), true) @@ -2246,7 +2246,7 @@ struct ChatView: View { }) } switch chat.chatInfo { - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): v.contextMenu { ReactionContextMenu( groupInfo: groupInfo, @@ -2269,7 +2269,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) } @@ -2328,7 +2328,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 { @@ -2557,7 +2557,7 @@ struct ChatView: View { 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 { @@ -3054,7 +3054,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? @@ -3208,7 +3208,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: () diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index eee9500b3b..37d94a575f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -699,7 +699,7 @@ struct GroupChatInfoView: View { let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) NavigationLink(isActive: $navLinkActive) { SecondaryChatView( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo, channelMsgInfo: nil), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } label: { @@ -750,7 +750,7 @@ struct GroupChatInfoView: View { var body: some View { NavigationLink(isActive: $navLinkActive) { SecondaryChatView( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()), + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports, channelMsgInfo: nil), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 883a768d97..be43db63be 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -352,7 +352,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 } } @@ -566,7 +566,7 @@ struct GroupMemberInfoView: View { let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped) NavigationLink(isActive: $navLinkActive) { SecondaryChatView( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo, channelMsgInfo: nil), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 880933985c..1ce7c0506e 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -83,7 +83,7 @@ struct MemberSupportView: View { NavigationLink(isActive: $memberSupportChatNavLinkActive) { SecondaryChatView( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo, channelMsgInfo: nil), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index e2092f7a24..6ae1eded53 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -35,7 +35,7 @@ struct SecondaryChatView: View { #Preview { SecondaryChatView( chat: Chat( - chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), + chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData), channelMsgInfo: nil), chatItems: [], chatStats: ChatStats() ), diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 4855c3ca8d..98a76b87ce 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -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 >= .moderator default: false } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index b4590fc124..bddac5cc95 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -67,7 +67,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 dc4971aafa..578755646e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -952,17 +952,17 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C case let .direct(contact): !contact.isContactCard && !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 && !groupInfo.isChannel + case let .group(groupInfo, _, _): groupInfo.businessChat == nil && !groupInfo.isChannel default: false } case .channels: switch chatInfo { - case let .group(groupInfo, _): groupInfo.isChannel + case let .group(groupInfo, _, _): groupInfo.isChannel default: false } case .business: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 243d804685..dd80f86f90 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -142,7 +142,7 @@ struct ChatPreviewView: View { } else { EmptyView() } - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): switch (groupInfo.membership.memberStatus) { case .memRejected: inactiveIcon() case .memLeft: inactiveIcon() @@ -174,7 +174,7 @@ struct ChatPreviewView: View { ? theme.colors.secondary : nil previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color) - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): let color = if deleting { theme.colors.secondary } else { @@ -377,7 +377,7 @@ struct ChatPreviewView: View { } else { nil } - case let .group(groupInfo, _): + case let .group(groupInfo, _, _): if groupInfo.nextConnectPrepared { if groupInfo.businessChat?.chatType == .business { Text("Open to connect") diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 47afee5f06..f8e89c1925 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -204,7 +204,7 @@ struct AddGroupView: View { Task { await m.loadGroupMembers(gInfo) } - let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: []) + let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil, channelMsgInfo: nil), chatItems: []) m.addChat(c) withAnimation { groupInfo = gInfo diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 9bcc326a66..880db65ef1 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1202,7 +1202,7 @@ private func showPrepareGroupAlert( let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData) await MainActor.run { if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty, - case let .group(gInfo, _) = chat.chatInfo { + case let .group(gInfo, _, _) = chat.chatInfo { ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays } ChatModel.shared.addChat(Chat(chat)) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1dfa477c91..d5a7fef2d8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1434,7 +1434,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { // Spec: spec/state.md#ChatInfo public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) - case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?, channelMsgInfo: ChannelMsgInfo?) case local(noteFolder: NoteFolder) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) @@ -1448,7 +1448,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 @@ -1461,7 +1461,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 @@ -1474,7 +1474,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 @@ -1486,7 +1486,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var shortDescr: String? { switch self { case let .direct(contact): contact.profile.shortDescr - case let .group(groupInfo, _): groupInfo.groupProfile.shortDescr + case let .group(groupInfo, _, _): groupInfo.groupProfile.shortDescr case .local: nil case let .contactRequest(contactRequest): contactRequest.profile.shortDescr case let .contactConnection(contactConnection): nil @@ -1498,7 +1498,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 @@ -1511,7 +1511,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 @@ -1524,7 +1524,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 @@ -1550,7 +1550,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 @@ -1563,7 +1563,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 @@ -1575,7 +1575,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var sndReady: Bool { switch self { case let .direct(contact): contact.sndReady - case let .group(groupInfo, groupScope): + case let .group(groupInfo, groupScope, _): groupInfo.membership.memberActive && (groupScope != nil || (!groupInfo.membership.memberPending && groupInfo.membership.memberRole != .observer)) case .local: true @@ -1598,7 +1598,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.sendMsgToConnect - case let .group(groupInfo, _): return groupInfo.nextConnectPrepared + case let .group(groupInfo, _, _): return groupInfo.nextConnectPrepared default: return false } } @@ -1608,7 +1608,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.nextConnectPrepared - case let .group(groupInfo, _): return groupInfo.nextConnectPrepared + case let .group(groupInfo, _, _): return groupInfo.nextConnectPrepared default: return false } } @@ -1618,7 +1618,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.profileChangeProhibited - case let .group(groupInfo, _): return groupInfo.profileChangeProhibited + case let .group(groupInfo, _, _): return groupInfo.profileChangeProhibited default: return false } } @@ -1634,7 +1634,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { if contact.activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false { return ("not synchronized", nil) } if contact.activeConn?.connDisabled ?? true { return ("contact disabled", nil) } return nil - case let .group(groupInfo, groupChatScope): + case let .group(groupInfo, groupChatScope, _): if groupInfo.membership.memberActive { switch(groupChatScope) { case .none: @@ -1682,7 +1682,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 @@ -1707,7 +1707,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 } } @@ -1729,7 +1729,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .files: return cups.files.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 @@ -1757,7 +1757,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: @@ -1782,7 +1782,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 { @@ -1816,7 +1816,16 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public func groupChatScope() -> GroupChatScope? { switch self { - case let .group(_, groupChatScope): groupChatScope?.toChatScope() + case let .group(_, groupChatScope, _): groupChatScope?.toChatScope() + default: nil + } + } + + // Local-only carrier for the comments-thread view, embedded by + // ItemsModel.loadSecondaryChat. Nil for main-chat / member-support / direct / etc. + public func channelMsgInfo() -> ChannelMsgInfo? { + switch self { + case let .group(_, _, channelMsgInfo): channelMsgInfo default: nil } } @@ -1848,7 +1857,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 } } @@ -1864,7 +1873,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var useCommands: Bool { switch self { case let .direct(c): c.isBot - case let .group(g, _): (g.groupProfile.groupPreferences?.commands?.count ?? 0) > 0 + case let .group(g, _, _): (g.groupProfile.groupPreferences?.commands?.count ?? 0) > 0 default: false } } @@ -1872,7 +1881,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var menuCommands: [ChatBotCommand] { switch self { case let .direct(c): c.isBot ? c.profile.preferences?.commands ?? [] : [] - case let .group(g, _): g.groupProfile.groupPreferences?.commands ?? [] + case let .group(g, _, _): g.groupProfile.groupPreferences?.commands ?? [] default: [] } } @@ -1880,7 +1889,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 } } @@ -1888,7 +1897,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 @@ -1904,7 +1913,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 { @@ -1924,7 +1933,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, groupChatScope: nil), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData, groupChatScope: nil, channelMsgInfo: nil), local: ChatInfo.local(noteFolder: NoteFolder.sampleData), contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData), contactConnection: ChatInfo.contactConnection(contactConnection: PendingContactConnection.getSampleData()) @@ -2013,6 +2022,19 @@ public enum GroupChatScopeInfo: Decodable, Hashable { } } +// Local-only carrier for the comments-thread view; not present on the wire. +// Embedded in `ChatInfo.group`'s third associated value by `ItemsModel.loadSecondaryChat` +// after fetching the chat by parent= pagination. See spec/state.md#ChannelMsgInfo. +public struct ChannelMsgInfo: Decodable, Hashable { + public var channelMsgItem: ChatItem + public var channelMsgSharedId: String + + public init(channelMsgItem: ChatItem, channelMsgSharedId: String) { + self.channelMsgItem = channelMsgItem + self.channelMsgSharedId = channelMsgSharedId + } +} + public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var contactId: Int64 var localDisplayName: ContactName @@ -3239,7 +3261,7 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil, parentChatItemId: Int64? = nil, commentsTotal: Int = 0, commentsDisabled: Bool = false) { self.chatDir = chatDir self.meta = meta self.content = content @@ -3248,6 +3270,9 @@ public struct ChatItem: Identifiable, Decodable, Hashable { self.quotedItem = quotedItem self.reactions = reactions self.file = file + self.parentChatItemId = parentChatItemId + self.commentsTotal = commentsTotal + self.commentsDisabled = commentsDisabled } public var chatDir: CIDirection @@ -3258,6 +3283,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var quotedItem: CIQuote? public var reactions: [CIReactionCount] public var file: CIFile? + // Slice-1 channel-comments fields. On the wire these live inside the `meta` + // object (Haskell `CIMeta`); for iOS caller ergonomics they are hoisted to + // ChatItem level. See spec/state.md#ChatItem and plan §5.1 item 4. + public var parentChatItemId: Int64? = nil + public var commentsTotal: Int = 0 + public var commentsDisabled: Bool = false public var viewTimestamp = Date.now public var isLiveDummy: Bool = false @@ -3266,8 +3297,37 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file } + private enum MetaForwardKeys: String, CodingKey { + case parentChatItemId, commentsTotal, commentsDisabled + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.chatDir = try container.decode(CIDirection.self, forKey: .chatDir) + self.meta = try container.decode(CIMeta.self, forKey: .meta) + self.content = try container.decode(CIContent.self, forKey: .content) + self.formattedText = try container.decodeIfPresent([FormattedText].self, forKey: .formattedText) + self.mentions = try container.decodeIfPresent([String: CIMention].self, forKey: .mentions) + self.quotedItem = try container.decodeIfPresent(CIQuote.self, forKey: .quotedItem) + self.reactions = try container.decodeIfPresent([CIReactionCount].self, forKey: .reactions) ?? [] + self.file = try container.decodeIfPresent(CIFile.self, forKey: .file) + // Hoist the three channel-comments fields from the nested `meta` object. + // Defaults mirror Haskell `omittedField`: nil / 0 / false. + let metaContainer = try container.nestedContainer(keyedBy: MetaForwardKeys.self, forKey: .meta) + self.parentChatItemId = try metaContainer.decodeIfPresent(Int64.self, forKey: .parentChatItemId) + self.commentsTotal = try metaContainer.decodeIfPresent(Int.self, forKey: .commentsTotal) ?? 0 + self.commentsDisabled = try metaContainer.decodeIfPresent(Bool.self, forKey: .commentsDisabled) ?? false + } + public var id: Int64 { meta.itemId } + // Subscriber-view helper: true iff this item came in on the channel-cast direction. + // Owner-side composers determine the equivalent locally (groupInfo.useRelays && chatDir == .groupSnd). + public var isChannelPost: Bool { + if case .channelRcv = chatDir { return true } + return false + } + public var viewId: String { "\(meta.itemId) \(viewTimestamp.timeIntervalSince1970)" } public var timestampText: Text { meta.timestampText } @@ -3444,12 +3504,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 >= .moderator && 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 >= .moderator ? (groupInfo, nil) : nil case (.group, .channelRcv): @@ -3718,6 +3778,9 @@ public struct CIMeta: Decodable, Hashable { public var deletable: Bool public var editable: Bool public var showGroupAsSender: Bool + // Wire field present once an item has been propagated to the relay/peer. + // Optional because items in "sending" state may not yet have one. + public var itemSharedMsgId: String? = nil public var timestampText: Text { Text(formatTimestampMeta(itemTs)) } public var recent: Bool { updatedAt + 10 > .now } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 788ac12bae..ee1134358d 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -14,7 +14,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 @@ -93,7 +93,7 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { public func chatIconName(_ cInfo: ChatInfo) -> String { switch cInfo { case let .direct(contact): contact.chatIconName - case let .group(groupInfo, _): groupInfo.chatIconName + case let .group(groupInfo, _, _): groupInfo.chatIconName case .local: "folder.circle.fill" case .contactRequest: "person.crop.circle.fill" default: "circle.fill" diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index a40e8eda99..a609727900 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -66,7 +66,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/ios/spec/api.md b/apps/ios/spec/api.md index 45a06c371f..bd823a5584 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -81,19 +81,21 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| | `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L44](../Shared/Model/AppAPITypes.swift#L44) | -| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L45](../Shared/Model/AppAPITypes.swift#L45) | +| `apiGetChat` | `chatId, scope, contentTag, pagination, search, parentItemId` | Get messages for a chat. When `parentItemId` is non-nil, returns the comments thread under that channel post (mutually exclusive with `scope`). | [L45](../Shared/Model/AppAPITypes.swift#L45) | | `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L46](../Shared/Model/AppAPITypes.swift#L46) | | `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L47](../Shared/Model/AppAPITypes.swift#L47) | | `apiSendMessages` | `type, id, scope, sendAsGroup, live, ttl, composedMessages` | Send one or more messages; `sendAsGroup` sends as channel owner | [L48](../Shared/Model/AppAPITypes.swift#L48) | -| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L54](../Shared/Model/AppAPITypes.swift#L54) | -| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L56](../Shared/Model/AppAPITypes.swift#L56) | -| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L57](../Shared/Model/AppAPITypes.swift#L57) | -| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L58](../Shared/Model/AppAPITypes.swift#L58) | -| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L61](../Shared/Model/AppAPITypes.swift#L61) | -| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L62](../Shared/Model/AppAPITypes.swift#L62) | -| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L63](../Shared/Model/AppAPITypes.swift#L63) | -| `apiForwardChatItems` | `toChatType, toChatId, toScope, sendAsGroup, from..., itemIds, ttl` | Forward messages; `sendAsGroup` forwards as channel owner | [L64](../Shared/Model/AppAPITypes.swift#L64) | -| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L55](../Shared/Model/AppAPITypes.swift#L55) | +| `apiSendComment` | `groupId, parentItemId, live, ttl, composedMessages` | Send one or more comments under a channel post. Maps to Haskell `/_comment # `; requires `useRelays' gInfo`. Outbound: only structural and content validation runs locally (quoted items must be in the same comment section). Authorization (role gate, `commentsDisabled` gate) is enforced at the receiver via the same XMsgNew/XMsgUpdate inbound gates that protect channel posts. | [L49](../Shared/Model/AppAPITypes.swift#L49) | +| `apiSetCommentsDisabled` | `groupId, parentItemId, disabled` | Per-post comments override. Maps to Haskell `/_comments_disabled # on|off`; requires `useRelays' gInfo` and caller role at or above `GRModerator`. Returns `CRCmdOk` (the Swift wrapper is `async throws -> Void`). | [L50](../Shared/Model/AppAPITypes.swift#L50) | +| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L56](../Shared/Model/AppAPITypes.swift#L56) | +| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L58](../Shared/Model/AppAPITypes.swift#L58) | +| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L59](../Shared/Model/AppAPITypes.swift#L59) | +| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L60](../Shared/Model/AppAPITypes.swift#L60) | +| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction (also valid on a comment item: reactions on comments are sent with `scope = nil` and reach all thread participants via the comment's `parent` chain) | [L63](../Shared/Model/AppAPITypes.swift#L63) | +| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L64](../Shared/Model/AppAPITypes.swift#L64) | +| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L65](../Shared/Model/AppAPITypes.swift#L65) | +| `apiForwardChatItems` | `toChatType, toChatId, toScope, sendAsGroup, from..., itemIds, ttl` | Forward messages; `sendAsGroup` forwards as channel owner | [L66](../Shared/Model/AppAPITypes.swift#L66) | +| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L57](../Shared/Model/AppAPITypes.swift#L57) | | `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L166](../Shared/Model/AppAPITypes.swift#L166) | | `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L167](../Shared/Model/AppAPITypes.swift#L167) | | `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L168](../Shared/Model/AppAPITypes.swift#L168) | @@ -154,11 +156,11 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| | `apiGetChatTags` | `userId` | Get all user tags | [L43](../Shared/Model/AppAPITypes.swift#L43) | -| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L49](../Shared/Model/AppAPITypes.swift#L49) | -| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L50](../Shared/Model/AppAPITypes.swift#L50) | -| `apiDeleteChatTag` | `tagId` | Delete a tag | [L51](../Shared/Model/AppAPITypes.swift#L51) | -| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L52](../Shared/Model/AppAPITypes.swift#L52) | -| `apiReorderChatTags` | `tagIds` | Reorder tags | [L53](../Shared/Model/AppAPITypes.swift#L53) | +| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L51](../Shared/Model/AppAPITypes.swift#L51) | +| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L52](../Shared/Model/AppAPITypes.swift#L52) | +| `apiDeleteChatTag` | `tagId` | Delete a tag | [L53](../Shared/Model/AppAPITypes.swift#L53) | +| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L54](../Shared/Model/AppAPITypes.swift#L54) | +| `apiReorderChatTags` | `tagIds` | Reorder tags | [L55](../Shared/Model/AppAPITypes.swift#L55) | ### 2.7 File Operations diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index eaf646e7f4..faac1391c0 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -46,14 +46,16 @@ ## 1. Swift Source Impact +[GAP] `product/views/comments.md` is not yet defined. Channel-comments slice 3 (data model + API surface) landed in `ChatTypes.swift`, `ChatModel.swift`, `AppAPITypes.swift`, `SimpleXAPI.swift` and ~16 view files (pattern-match cascade for the third `ChatInfo.group` associated value). The view-layer product concept will be created in slice 5 — see channel-comments plan §5.4. Until then, treat the channel-comments rows below as affecting PC31 (Channels) only. + | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access | | Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | | Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | | Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | -| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | -| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | +| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering. Slice 3: pattern-match cascade for `ChatInfo.group` third arg. [GAP] product/views/comments.md (slice 5). | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup. Slice 3: pattern-match cascade for `ChatInfo.group` third arg (no compose logic changes yet — compose-as-comment lands in slice 4). [GAP] product/views/comments.md (slice 5). | | Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | | Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification | | Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30, PC31 | High | Group management hub, channel info adaptations | @@ -75,13 +77,13 @@ | Shared/Views/LocalAuth/ | PC22 | Medium | App lock functionality | | Shared/Views/Database/ | PC23, PC26 | High | Database encryption and export | | Shared/Views/Migration/ | PC26 | High | Device migration — data portability | -| Shared/Model/ChatModel.swift | PC1 through PC31 | High | Central state — all features depend on it | -| Shared/Model/SimpleXAPI.swift | PC1 through PC31 | High | FFI bridge — all commands flow through here | -| Shared/Model/AppAPITypes.swift | PC1 through PC31 | High | Command/response types — all API communication | +| Shared/Model/ChatModel.swift | PC1 through PC31 | High | Central state — all features depend on it. Slice 3: added `SecondaryItemsModelFilter.groupChannelMsgContext`, `ItemsModel.loadSecondaryChat` comments branch with `injectChannelMsgInfo`, `getCIItemsModel` comments-thread branch, four `cInfo.channelMsgInfo() == nil` gating sites. [GAP] product/views/comments.md (slice 5). | +| Shared/Model/SimpleXAPI.swift | PC1 through PC31 | High | FFI bridge — all commands flow through here. Slice 3: added `apiSendComment`, `apiSetCommentsDisabled`, `apiGetChat(parentItemId:)` extension. [GAP] product/views/comments.md (slice 5). | +| Shared/Model/AppAPITypes.swift | PC1 through PC31 | High | Command/response types — all API communication. Slice 3: added `apiSendComment` and `apiSetCommentsDisabled` enum cases, `parentItemId` parameter on `apiGetChat`. [GAP] product/views/comments.md (slice 5). | | Shared/Model/NtfManager.swift | PC18 | High | Notification delivery | | Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling | | Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine | -| SimpleXChat/ChatTypes.swift | PC1 through PC31 | High | Core data types — all features use them | +| SimpleXChat/ChatTypes.swift | PC1 through PC31 | High | Core data types — all features use them. Slice 3: extended `ChatInfo.group` to three associated values (added `ChannelMsgInfo?` carrier), added `ChannelMsgInfo` struct, added `ChatItem.parentChatItemId`/`commentsTotal`/`commentsDisabled` (hoisted from `meta` on decode), added `CIMeta.itemSharedMsgId`. [GAP] product/views/comments.md (slice 5). | | SimpleXChat/APITypes.swift | PC1 through PC31 | High | API result types and error handling | | SimpleXChat/CallTypes.swift | PC17 | Medium | Call-specific data types | | SimpleXChat/FileUtils.swift | PC10, PC23, PC26 | Medium | File paths and encryption utilities | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index 6dda4ba275..31b275e336 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -1,6 +1,6 @@ # SimpleX Chat iOS -- State Management -**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1404) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5377) +**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1483) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5377) > Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage. > @@ -63,151 +63,152 @@ ChatTagsModel (singleton -- filter state) --- -## 2. [ChatModel](../Shared/Model/ChatModel.swift#L353-L1289) +## 2. [ChatModel](../Shared/Model/ChatModel.swift#L416-L1368) **Class**: `final class ChatModel: ObservableObject` **Singleton**: `ChatModel.shared` -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L353) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L416) ### Key Published Properties #### App Lifecycle | Property | Type | Description | Line | |----------|------|-------------|------| -| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L354](../Shared/Model/ChatModel.swift#L354) | -| `chatInitialized` | `Bool` | Whether chat has been initialized | [L363](../Shared/Model/ChatModel.swift#L363) | -| `chatRunning` | `Bool?` | Whether chat engine is running | [L364](../Shared/Model/ChatModel.swift#L364) | -| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L365](../Shared/Model/ChatModel.swift#L365) | -| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L366](../Shared/Model/ChatModel.swift#L366) | -| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L367](../Shared/Model/ChatModel.swift#L367) | -| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L368](../Shared/Model/ChatModel.swift#L368) | -| `migrationState` | `MigrationToState?` | Device migration state | [L417](../Shared/Model/ChatModel.swift#L417) | +| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L417](../Shared/Model/ChatModel.swift#L417) | +| `chatInitialized` | `Bool` | Whether chat has been initialized | [L426](../Shared/Model/ChatModel.swift#L426) | +| `chatRunning` | `Bool?` | Whether chat engine is running | [L427](../Shared/Model/ChatModel.swift#L427) | +| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L428](../Shared/Model/ChatModel.swift#L428) | +| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L429](../Shared/Model/ChatModel.swift#L429) | +| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L430](../Shared/Model/ChatModel.swift#L430) | +| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L431](../Shared/Model/ChatModel.swift#L431) | +| `migrationState` | `MigrationToState?` | Device migration state | [L481](../Shared/Model/ChatModel.swift#L481) | #### User State | Property | Type | Description | Line | |----------|------|-------------|------| -| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L357](../Shared/Model/ChatModel.swift#L357) | -| `users` | `[UserInfo]` | All user profiles | [L362](../Shared/Model/ChatModel.swift#L362) | -| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L356](../Shared/Model/ChatModel.swift#L356) | +| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L420](../Shared/Model/ChatModel.swift#L420) | +| `users` | `[UserInfo]` | All user profiles | [L425](../Shared/Model/ChatModel.swift#L425) | +| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L419](../Shared/Model/ChatModel.swift#L419) | #### Chat List | Property | Type | Description | Line | |----------|------|-------------|------| -| `chats` | `[Chat]` (private set) | All conversations for current user | [L374](../Shared/Model/ChatModel.swift#L374) | -| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L375](../Shared/Model/ChatModel.swift#L375) | +| `chats` | `[Chat]` (private set) | All conversations for current user | [L437](../Shared/Model/ChatModel.swift#L437) | +| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L438](../Shared/Model/ChatModel.swift#L438) | #### Active Chat | Property | Type | Description | Line | |----------|------|-------------|------| -| `chatId` | `String?` | Currently open chat ID | [L377](../Shared/Model/ChatModel.swift#L377) | -| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L378](../Shared/Model/ChatModel.swift#L378) | -| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L379](../Shared/Model/ChatModel.swift#L379) | -| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L380](../Shared/Model/ChatModel.swift#L380) | -| `chatToTop` | `String?` | Chat to scroll to top | [L381](../Shared/Model/ChatModel.swift#L381) | -| `groupMembers` | `[GMember]` | Members of active group | [L382](../Shared/Model/ChatModel.swift#L382) | -| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L383](../Shared/Model/ChatModel.swift#L383) | -| `membersLoaded` | `Bool` | Whether members have been loaded | [L384](../Shared/Model/ChatModel.swift#L384) | -| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L435](../Shared/Model/ChatModel.swift#L435) | +| `chatId` | `String?` | Currently open chat ID | [L440](../Shared/Model/ChatModel.swift#L440) | +| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L441](../Shared/Model/ChatModel.swift#L441) | +| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L442](../Shared/Model/ChatModel.swift#L442) | +| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L443](../Shared/Model/ChatModel.swift#L443) | +| `chatToTop` | `String?` | Chat to scroll to top | [L444](../Shared/Model/ChatModel.swift#L444) | +| `groupMembers` | `[GMember]` | Members of active group | [L446](../Shared/Model/ChatModel.swift#L446) | +| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L447](../Shared/Model/ChatModel.swift#L447) | +| `membersLoaded` | `Bool` | Whether members have been loaded | [L448](../Shared/Model/ChatModel.swift#L448) | +| `secondaryIM` | `ItemsModel?` | Secondary items model (member support scope, channel comments thread, or content-type filter) | [L499](../Shared/Model/ChatModel.swift#L499) | #### Authentication | Property | Type | Description | Line | |----------|------|-------------|------| -| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L371](../Shared/Model/ChatModel.swift#L371) | -| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L372](../Shared/Model/ChatModel.swift#L372) | +| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L434](../Shared/Model/ChatModel.swift#L434) | +| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L435](../Shared/Model/ChatModel.swift#L435) | #### Notifications | Property | Type | Description | Line | |----------|------|-------------|------| -| `deviceToken` | `DeviceToken?` | Current APNs device token | [L395](../Shared/Model/ChatModel.swift#L395) | -| `savedToken` | `DeviceToken?` | Previously saved token | [L396](../Shared/Model/ChatModel.swift#L396) | -| `tokenRegistered` | `Bool` | Whether token is registered with server | [L397](../Shared/Model/ChatModel.swift#L397) | -| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L399](../Shared/Model/ChatModel.swift#L399) | -| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L400](../Shared/Model/ChatModel.swift#L400) | -| `notificationServer` | `String?` | Notification server URL | [L401](../Shared/Model/ChatModel.swift#L401) | -| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L402](../Shared/Model/ChatModel.swift#L402) | -| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L369](../Shared/Model/ChatModel.swift#L369) | -| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L404](../Shared/Model/ChatModel.swift#L404) | -| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L405](../Shared/Model/ChatModel.swift#L405) | +| `deviceToken` | `DeviceToken?` | Current APNs device token | [L459](../Shared/Model/ChatModel.swift#L459) | +| `savedToken` | `DeviceToken?` | Previously saved token | [L460](../Shared/Model/ChatModel.swift#L460) | +| `tokenRegistered` | `Bool` | Whether token is registered with server | [L461](../Shared/Model/ChatModel.swift#L461) | +| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L463](../Shared/Model/ChatModel.swift#L463) | +| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L464](../Shared/Model/ChatModel.swift#L464) | +| `notificationServer` | `String?` | Notification server URL | [L465](../Shared/Model/ChatModel.swift#L465) | +| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L466](../Shared/Model/ChatModel.swift#L466) | +| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L432](../Shared/Model/ChatModel.swift#L432) | +| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L468](../Shared/Model/ChatModel.swift#L468) | +| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L469](../Shared/Model/ChatModel.swift#L469) | #### Calls | Property | Type | Description | Line | |----------|------|-------------|------| -| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L407](../Shared/Model/ChatModel.swift#L407) | -| `activeCall` | `Call?` | Currently active call | [L408](../Shared/Model/ChatModel.swift#L408) | -| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L409](../Shared/Model/ChatModel.swift#L409) | -| `showCallView` | `Bool` | Whether to show full-screen call UI | [L410](../Shared/Model/ChatModel.swift#L410) | -| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L411](../Shared/Model/ChatModel.swift#L411) | +| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L471](../Shared/Model/ChatModel.swift#L471) | +| `activeCall` | `Call?` | Currently active call | [L472](../Shared/Model/ChatModel.swift#L472) | +| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L473](../Shared/Model/ChatModel.swift#L473) | +| `showCallView` | `Bool` | Whether to show full-screen call UI | [L474](../Shared/Model/ChatModel.swift#L474) | +| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L475](../Shared/Model/ChatModel.swift#L475) | #### Remote Desktop | Property | Type | Description | Line | |----------|------|-------------|------| -| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L414](../Shared/Model/ChatModel.swift#L414) | +| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L478](../Shared/Model/ChatModel.swift#L478) | #### Misc | Property | Type | Description | Line | |----------|------|-------------|------| -| `userAddress` | `UserContactLink?` | User's SimpleX address | [L391](../Shared/Model/ChatModel.swift#L391) | -| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L392](../Shared/Model/ChatModel.swift#L392) | -| `appOpenUrl` | `URL?` | URL opened while app active | [L393](../Shared/Model/ChatModel.swift#L393) | -| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L394](../Shared/Model/ChatModel.swift#L394) | -| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L416](../Shared/Model/ChatModel.swift#L416) | -| `draft` | `ComposeState?` | Saved compose draft | [L420](../Shared/Model/ChatModel.swift#L420) | -| `draftChatId` | `String?` | Chat ID for saved draft | [L421](../Shared/Model/ChatModel.swift#L421) | -| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L422](../Shared/Model/ChatModel.swift#L422) | -| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L424](../Shared/Model/ChatModel.swift#L424) | -| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L419](../Shared/Model/ChatModel.swift#L419) | +| `userAddress` | `UserContactLink?` | User's SimpleX address | [L455](../Shared/Model/ChatModel.swift#L455) | +| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L456](../Shared/Model/ChatModel.swift#L456) | +| `appOpenUrl` | `URL?` | URL opened while app active | [L457](../Shared/Model/ChatModel.swift#L457) | +| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L458](../Shared/Model/ChatModel.swift#L458) | +| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L480](../Shared/Model/ChatModel.swift#L480) | +| `draft` | `ComposeState?` | Saved compose draft | [L484](../Shared/Model/ChatModel.swift#L484) | +| `draftChatId` | `String?` | Chat ID for saved draft | [L485](../Shared/Model/ChatModel.swift#L485) | +| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L486](../Shared/Model/ChatModel.swift#L486) | +| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L488](../Shared/Model/ChatModel.swift#L488) | +| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L483](../Shared/Model/ChatModel.swift#L483) | ### Non-Published Properties | Property | Type | Description | Line | |----------|------|-------------|------| -| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) | -| `filesToDelete` | `Set` | Files queued for deletion | [L428](../Shared/Model/ChatModel.swift#L428) | -| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L432](../Shared/Model/ChatModel.swift#L432) | +| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L490](../Shared/Model/ChatModel.swift#L490) | +| `filesToDelete` | `Set` | Files queued for deletion | [L492](../Shared/Model/ChatModel.swift#L492) | +| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L496](../Shared/Model/ChatModel.swift#L496) | ### Key Methods | Method | Description | Line | |--------|-------------|------| -| `getUser(_ userId:)` | Find user by ID | [L455](../Shared/Model/ChatModel.swift#L455) | -| `updateUser(_ user:)` | Update user in list and current | [L466](../Shared/Model/ChatModel.swift#L466) | -| `removeUser(_ user:)` | Remove user from list | [L476](../Shared/Model/ChatModel.swift#L476) | -| `getChat(_ id:)` | Find chat by ID | [L487](../Shared/Model/ChatModel.swift#L487) | -| `addChat(_ chat:)` | Add chat to list | [L542](../Shared/Model/ChatModel.swift#L542) | -| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L556](../Shared/Model/ChatModel.swift#L556) | -| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L608](../Shared/Model/ChatModel.swift#L608) | -| `removeChat(_ id:)` | Remove chat from list | [L1217](../Shared/Model/ChatModel.swift#L1217) | -| `popChat(_ id:)` | Move chat to top of list | [L1193](../Shared/Model/ChatModel.swift#L1193) | -| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1093](../Shared/Model/ChatModel.swift#L1093) | +| `getUser(_ userId:)` | Find user by ID | [L519](../Shared/Model/ChatModel.swift#L519) | +| `updateUser(_ user:)` | Update user in list and current | [L530](../Shared/Model/ChatModel.swift#L530) | +| `removeUser(_ user:)` | Remove user from list | [L540](../Shared/Model/ChatModel.swift#L540) | +| `getChat(_ id:)` | Find chat by ID | [L551](../Shared/Model/ChatModel.swift#L551) | +| `addChat(_ chat:)` | Add chat to list | [L606](../Shared/Model/ChatModel.swift#L606) | +| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L620](../Shared/Model/ChatModel.swift#L620) | +| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L672](../Shared/Model/ChatModel.swift#L672) | +| `getCIItemsModel(_ cInfo:_ ci:)` | Resolve which `ItemsModel` an incoming item belongs to (primary `im`, secondary `secondaryIM`, or `nil`). For the channel-comments thread it routes via `ci.parentChatItemId == parent.id`. | [L747](../Shared/Model/ChatModel.swift#L747) | +| `removeChat(_ id:)` | Remove chat from list | [L1290](../Shared/Model/ChatModel.swift#L1290) | +| `popChat(_ id:)` | Move chat to top of list | [L1266](../Shared/Model/ChatModel.swift#L1266) | +| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1166](../Shared/Model/ChatModel.swift#L1166) | --- -## 3. [ItemsModel](../Shared/Model/ChatModel.swift#L74-L174) +## 3. [ItemsModel](../Shared/Model/ChatModel.swift#L80-L227) **Class**: `class ItemsModel: ObservableObject` **Primary singleton**: `ItemsModel.shared` -**Secondary instances**: Created via `ItemsModel.loadSecondaryChat()` for scope-based views (e.g., group member support chat) -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L74) +**Secondary instances**: Created via `ItemsModel.loadSecondaryChat()` for scope-based views (member support chat, channel comments thread, reports filter). +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L80) ### Properties | Property | Type | Description | Line | |----------|------|-------------|------| -| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L80](../Shared/Model/ChatModel.swift#L80) | -| `itemAdded` | `Bool` | Flag indicating a new item was added | [L83](../Shared/Model/ChatModel.swift#L83) | -| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L87](../Shared/Model/ChatModel.swift#L87) | -| `isLoading` | `Bool` | Whether messages are currently loading | [L91](../Shared/Model/ChatModel.swift#L91) | -| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L92](../Shared/Model/ChatModel.swift#L92) | -| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L77](../Shared/Model/ChatModel.swift#L77) | -| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L76](../Shared/Model/ChatModel.swift#L76) | +| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L86](../Shared/Model/ChatModel.swift#L86) | +| `itemAdded` | `Bool` | Flag indicating a new item was added | [L89](../Shared/Model/ChatModel.swift#L89) | +| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L93](../Shared/Model/ChatModel.swift#L93) | +| `isLoading` | `Bool` | Whether messages are currently loading | [L97](../Shared/Model/ChatModel.swift#L97) | +| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L98](../Shared/Model/ChatModel.swift#L98) | +| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L83](../Shared/Model/ChatModel.swift#L83) | +| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L82](../Shared/Model/ChatModel.swift#L82) | ### Computed Properties | Property | Type | Description | Line | |----------|------|-------------|------| -| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L97](../Shared/Model/ChatModel.swift#L97) | -| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L159](../Shared/Model/ChatModel.swift#L159) | -| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L167](../Shared/Model/ChatModel.swift#L167) | +| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L103](../Shared/Model/ChatModel.swift#L103) | +| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L212](../Shared/Model/ChatModel.swift#L212) | +| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L220](../Shared/Model/ChatModel.swift#L220) | ### Throttling @@ -226,37 +227,52 @@ Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throt | Method | Description | Line | |--------|-------------|------| -| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L117](../Shared/Model/ChatModel.swift#L117) | -| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L143](../Shared/Model/ChatModel.swift#L143) | -| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L110](../Shared/Model/ChatModel.swift#L110) | +| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L170](../Shared/Model/ChatModel.swift#L170) | +| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L196](../Shared/Model/ChatModel.swift#L196) | +| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary `ItemsModel` instance. For `.groupChannelMsgContext` it fetches via `apiGetChat(parentItemId:)` and injects a local-only `ChannelMsgInfo` into the returned `ChatInfo.group`. | [L116](../Shared/Model/ChatModel.swift#L116) | -### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70) +### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L76) -Used for secondary chat views (e.g., group member support scope, content type filter): +Used for secondary chat views (e.g., group member support scope, content type filter, channel comments thread): ```swift enum SecondaryItemsModelFilter { case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) case msgContentTagContext(contentTag: MsgContentTag) + case groupChannelMsgContext(parent: ChatItem) } ``` +`.groupChannelMsgContext` is the channel-comments-thread scope. The associated `parent` carries the channel post being commented on; the comments-thread view routes inbound items via `ci.parentChatItemId == parent.id`. The parent's `meta.itemSharedMsgId` is required before opening the thread (the owner-side parent may briefly lack a shared id during send). + +### Channel comments thread + +Opening a comments thread bypasses the standard `loadOpenChat` flow because the comments scope is not represented on the wire as a `GroupChatScope`. Instead: + +1. The caller invokes [`ItemsModel.loadSecondaryChat`](../Shared/Model/ChatModel.swift#L116-L153) with `chatFilter: .groupChannelMsgContext(parent:)`. +2. Items are fetched via `apiGetChat(..., parentItemId: parent.id)` (see `apiGetChat` in [spec/api.md §2.3](api.md#23-chat--message-operations)). +3. The returned `ChatInfo.group` has its third associated value rewritten by [`injectChannelMsgInfo`](../Shared/Model/ChatModel.swift#L158-L167) to embed a local-only [`ChannelMsgInfo`](../SimpleXChat/ChatTypes.swift#L2028-L2036) carrier so toolbar and routing logic can read the parent without a second lookup. +4. [`getCIItemsModel`](../Shared/Model/ChatModel.swift#L747-L777) gains a new branch (`cInfo.channelMsgInfo() != nil`) that routes events into the secondary `ItemsModel` when `ci.parentChatItemId == parent.id`. +5. Gating sites that previously checked `cInfo.groupChatScope() == nil` to decide whether to update the main chat preview now also check `cInfo.channelMsgInfo() == nil`, so comment items do not bubble into the main chat list. + +The carrier is **not** serialized: `ChannelMsgInfo` is `Decodable` only so it nests cleanly into `ChatInfo.group`, but the server never sends this field — it is injected exclusively by `loadSecondaryChat`. Inbound `XMsgNew` items with a `parent` come back over the regular event stream; they are routed by `ci.parentChatItemId` alone. + --- -## 4. [ChatTagsModel](../Shared/Model/ChatModel.swift#L189-L291) +## 4. [ChatTagsModel](../Shared/Model/ChatModel.swift#L242-L344) **Class**: `class ChatTagsModel: ObservableObject` **Singleton**: `ChatTagsModel.shared` -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L189) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L242) ### Properties | Property | Type | Description | Line | |----------|------|-------------|------| -| `userTags` | `[ChatTag]` | User-defined tags | [L192](../Shared/Model/ChatModel.swift#L192) | -| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L193](../Shared/Model/ChatModel.swift#L193) | -| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L194](../Shared/Model/ChatModel.swift#L194) | -| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L195](../Shared/Model/ChatModel.swift#L195) | +| `userTags` | `[ChatTag]` | User-defined tags | [L245](../Shared/Model/ChatModel.swift#L245) | +| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L246](../Shared/Model/ChatModel.swift#L246) | +| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L247](../Shared/Model/ChatModel.swift#L247) | +| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L248](../Shared/Model/ChatModel.swift#L248) | ### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52) @@ -270,11 +286,11 @@ enum ActiveFilter { --- -## 5. [ChannelRelaysModel](../Shared/Model/ChatModel.swift#L336-L350) +## 5. [ChannelRelaysModel](../Shared/Model/ChatModel.swift#L389-L413) **Class**: `class ChannelRelaysModel: ObservableObject` **Singleton**: `ChannelRelaysModel.shared` -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L336) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L389) Holds runtime relay state for the currently viewed channel. Used by `ChannelRelaysView` to display and manage relays. Reset when the view is dismissed. @@ -282,22 +298,22 @@ Holds runtime relay state for the currently viewed channel. Used by `ChannelRela | Property | Type | Description | Line | |----------|------|-------------|------| -| `groupId` | `Int64?` | Group ID of the channel whose relays are loaded | [L338](../Shared/Model/ChatModel.swift#L338) | -| `groupRelays` | `[GroupRelay]` | Current relay instances for the channel | [L339](../Shared/Model/ChatModel.swift#L339) | +| `groupId` | `Int64?` | Group ID of the channel whose relays are loaded | [L391](../Shared/Model/ChatModel.swift#L391) | +| `groupRelays` | `[GroupRelay]` | Current relay instances for the channel | [L392](../Shared/Model/ChatModel.swift#L392) | ### Methods | Method | Description | Line | |--------|-------------|------| -| `set(groupId:groupRelays:)` | Populate all properties at once | [L341](../Shared/Model/ChatModel.swift#L341) | -| `reset()` | Clear all properties to nil/empty | [L346](../Shared/Model/ChatModel.swift#L346) | +| `set(groupId:groupRelays:)` | Populate all properties at once | [L394](../Shared/Model/ChatModel.swift#L394) | +| `reset()` | Clear all properties to nil/empty | [L409](../Shared/Model/ChatModel.swift#L409) | --- -## 6. [Chat](../Shared/Model/ChatModel.swift#L1301-L1353) +## 6. [Chat](../Shared/Model/ChatModel.swift#L1380-L1432) **Class**: `final class Chat: ObservableObject, Identifiable, ChatLike` -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1301) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1380) Represents a single conversation in the chat list. Each `Chat` is an independent observable object. @@ -305,12 +321,12 @@ Represents a single conversation in the chat list. Each `Chat` is an independent | Property | Type | Description | Line | |----------|------|-------------|------| -| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1302](../Shared/Model/ChatModel.swift#L1302) | -| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1303](../Shared/Model/ChatModel.swift#L1303) | -| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1304](../Shared/Model/ChatModel.swift#L1304) | -| `created` | `Date` | Creation timestamp | [L1305](../Shared/Model/ChatModel.swift#L1305) | +| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1381](../Shared/Model/ChatModel.swift#L1381) | +| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1382](../Shared/Model/ChatModel.swift#L1382) | +| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1383](../Shared/Model/ChatModel.swift#L1383) | +| `created` | `Date` | Creation timestamp | [L1384](../Shared/Model/ChatModel.swift#L1384) | -### [ChatStats](../SimpleXChat/ChatTypes.swift#L1881-L1903) +### [ChatStats](../SimpleXChat/ChatTypes.swift#L1966-L1988) ```swift struct ChatStats: Decodable, Hashable { @@ -326,24 +342,24 @@ struct ChatStats: Decodable, Hashable { | Property | Description | Line | |----------|-------------|------| -| `id` | Chat ID from `chatInfo.id` | [L1336](../Shared/Model/ChatModel.swift#L1336) | -| `viewId` | Unique view identity including creation time | [L1338](../Shared/Model/ChatModel.swift#L1338) | -| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1328](../Shared/Model/ChatModel.swift#L1328) | -| `supportUnreadCount` | Unread count for group support scope | [L1340](../Shared/Model/ChatModel.swift#L1340) | +| `id` | Chat ID from `chatInfo.id` | [L1415](../Shared/Model/ChatModel.swift#L1415) | +| `viewId` | Unique view identity including creation time | [L1417](../Shared/Model/ChatModel.swift#L1417) | +| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1407](../Shared/Model/ChatModel.swift#L1407) | +| `supportUnreadCount` | Unread count for group support scope | [L1419](../Shared/Model/ChatModel.swift#L1419) | --- -## 7. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1374-L1856) +## 7. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1435-L1941) **Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable` -**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1374) +**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1435) Represents the type and metadata of a conversation: ```swift public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) - case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?, channelMsgInfo: ChannelMsgInfo?) case local(noteFolder: NoteFolder) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) @@ -356,12 +372,23 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { | Case | Associated Value | Description | |------|-----------------|-------------| | `.direct` | `Contact` | One-to-one conversation | -| `.group` | `GroupInfo, GroupChatScopeInfo?` | Group conversation (optional scope for member support threads) | +| `.group` | `GroupInfo, GroupChatScopeInfo?, ChannelMsgInfo?` | Group conversation. Optional `GroupChatScopeInfo` selects a member support thread; optional `ChannelMsgInfo` (set client-side only) selects a channel-comments thread under a specific post. The two are mutually exclusive in practice. | | `.local` | `NoteFolder` | Local notes (self-chat) | | `.contactRequest` | `UserContactRequest` | Incoming contact request | | `.contactConnection` | `PendingContactConnection` | Pending connection | | `.invalidJSON` | `Data?` | Undecodable chat data | +### [ChannelMsgInfo](../SimpleXChat/ChatTypes.swift#L2028-L2036) + +```swift +public struct ChannelMsgInfo: Decodable, Hashable { + public var channelMsgItem: ChatItem + public var channelMsgSharedId: String +} +``` + +Local-only carrier for the comments-thread view. Holds the parent channel post (`channelMsgItem`) and its shared message id (`channelMsgSharedId`, hoisted from `parent.meta.itemSharedMsgId`). The struct is `Decodable` so it can sit inside the `ChatInfo.group` enum case, but the Haskell core never emits this field — `ItemsModel.loadSecondaryChat` injects it after fetching the thread by `parentItemId=`. See [§3 Channel comments thread](#channel-comments-thread). + ### Key Computed Properties on ChatInfo | Property | Type | Description | @@ -382,25 +409,46 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support | Type | Field | Type | Description | Line | |------|-------|------|-------------|------| | `User` | `userChatRelay` | `Bool` | Whether user acts as a chat relay | [L46](../SimpleXChat/ChatTypes.swift#L46) | -| `GroupInfo` | `useRelays` | `Bool` | Whether group uses relay infrastructure (channel mode) | [L2343](../SimpleXChat/ChatTypes.swift#L2343) | -| `GroupInfo` | `relayOwnStatus` | `RelayStatus?` | Current user's relay status in this group | [L2344](../SimpleXChat/ChatTypes.swift#L2344) | -| `GroupProfile` | `publicGroup` | `PublicGroupProfile?` | Channel-specific profile data (type, link, ID) | [L2472](../SimpleXChat/ChatTypes.swift#L2472) | +| `GroupInfo` | `useRelays` | `Bool` | Whether group uses relay infrastructure (channel mode) | [L2448](../SimpleXChat/ChatTypes.swift#L2448) | +| `GroupInfo` | `relayOwnStatus` | `RelayStatus?` | Current user's relay status in this group | [L2449](../SimpleXChat/ChatTypes.swift#L2449) | +| `GroupProfile` | `publicGroup` | `PublicGroupProfile?` | Channel-specific profile data (type, link, ID) | [L2588](../SimpleXChat/ChatTypes.swift#L2588) | #### New Types | Type | Kind | Description | Line | |------|------|-------------|------| -| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | -| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | -| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) | -| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) | +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2659](../SimpleXChat/ChatTypes.swift#L2659) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2730](../SimpleXChat/ChatTypes.swift#L2730) | +| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2721](../SimpleXChat/ChatTypes.swift#L2721) | +| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2674](../SimpleXChat/ChatTypes.swift#L2674) | #### New Enum Cases | Enum | Case | Description | Line | |------|------|-------------|------| -| `GroupMemberRole` | `.relay` | Role for relay members (below `.observer`) | [L2807](../SimpleXChat/ChatTypes.swift#L2807) | -| `CIDirection` | `.channelRcv` | Message direction for channel-received messages (via relay) | [L3529](../SimpleXChat/ChatTypes.swift#L3529) | +| `GroupMemberRole` | `.relay` | Role for relay members (below `.observer`) | [L2974](../SimpleXChat/ChatTypes.swift#L2974) | +| `CIDirection` | `.channelRcv` | Message direction for channel-received messages (via relay) | [L3737](../SimpleXChat/ChatTypes.swift#L3737) | + +### Comments-Related Data Model (Channel Comments) + +A **comment** is a `ChatItem` whose `parentChatItemId` references a channel post. The parent post is the only `ChatItem` that carries `commentsTotal`/`commentsDisabled`. Comments live in a secondary `ItemsModel` keyed by [`.groupChannelMsgContext(parent:)`](#secondaryitemsmodelfilter); the channel-post-list view (slice 4) uses `commentsTotal` to render the comments-button counter. + +#### New Fields on Existing Types + +| Type | Field | Type | Description | Line | +|------|-------|------|-------------|------| +| `ChatItem` | `parentChatItemId` | `Int64?` | Parent post id if this item is a comment. Hoisted from `CIMeta.parentChatItemId` on decode for caller ergonomics; `nil` means the item is not a comment. | [L3289](../SimpleXChat/ChatTypes.swift#L3289) | +| `ChatItem` | `commentsTotal` | `Int` | Total comments under this item (only meaningful when this item is a channel post). Defaults to `0` via `decodeIfPresent ?? 0`, mirroring Haskell `omittedField`. | [L3290](../SimpleXChat/ChatTypes.swift#L3290) | +| `ChatItem` | `commentsDisabled` | `Bool` | Whether the channel owner has disabled comments under this post. Defaults to `false` via `decodeIfPresent ?? false`. | [L3291](../SimpleXChat/ChatTypes.swift#L3291) | +| `CIMeta` | `itemSharedMsgId` | `String?` | Shared message id (`SharedMsgId`) used to address this item across members. Needed to open a comments thread under an owner-side parent that has just been sent. | [L3783](../SimpleXChat/ChatTypes.swift#L3783) | + +The three `ChatItem` fields are wire-encoded inside the nested `meta` object (Haskell `CIMeta`). The iOS [`ChatItem.init(from decoder:)`](../SimpleXChat/ChatTypes.swift#L3304-L3320) hoists them to `ChatItem` level so that view code can write `ci.parentChatItemId` and `ci.commentsTotal` directly without descending into `ci.meta`. Encoding round-trips remain wire-compatible because the iOS `ChatItem` is `Decodable`-only. + +#### New Types + +| Type | Kind | Description | Line | +|------|------|-------------|------| +| `ChannelMsgInfo` | `struct` | Local-only carrier for the comments-thread view. See [§7 ChannelMsgInfo](#channelmsginfo). | [L2028](../SimpleXChat/ChatTypes.swift#L2028) | ---