This commit is contained in:
spaced4ndy
2026-05-15 18:15:31 +04:00
parent da8fc43e11
commit 61bb6d4430
24 changed files with 448 additions and 245 deletions
+14 -3
View File
@@ -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"
+72 -10
View File
@@ -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 {
+18 -3
View File
@@ -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<ChatResponse1>
@@ -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)
}
@@ -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 {
@@ -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)
}
}
@@ -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 {
@@ -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
}
}
+28 -28
View File
@@ -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<Int>?, 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: ()
@@ -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: {
@@ -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: {
@@ -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: {
@@ -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()
),
@@ -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
}
@@ -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)
@@ -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:
@@ -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")
@@ -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
@@ -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))
+94 -31
View File
@@ -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 }
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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):"
+17 -15
View File
@@ -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 #<groupId> <parentItemId>`; 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 #<groupId> <parentItemId> 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
+8 -6
View File
@@ -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 |
+168 -120
View File
@@ -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<String>` | 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<String>` | 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<URL>` | 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<URL>` | 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) |
---