ios: channels and chat relays ui (#6634)

This commit is contained in:
spaced4ndy
2026-03-05 09:13:24 +00:00
committed by GitHub
parent 567a89d49d
commit b97868d79f
80 changed files with 4170 additions and 971 deletions
+4
View File
@@ -174,6 +174,8 @@ After completing all changes (code + documentation), you MUST run an adversarial
| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md |
| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md |
| Shared/Views/Chat/Group/ChannelMembersView.swift | spec/client/chat-view.md | product/views/group-info.md |
| Shared/Views/Chat/Group/ChannelRelaysView.swift | spec/client/chat-view.md | product/views/group-info.md |
| Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md |
| Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md |
| Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md |
@@ -199,6 +201,8 @@ After completing all changes (code + documentation), you MUST run an adversarial
| SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md |
| SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md |
| SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md |
| Shared/Views/Chat/ChatItemsMerger.swift | spec/client/chat-view.md | product/views/chat.md |
| SimpleX SE/ShareAPI.swift | spec/api.md | product/flows/messaging.md |
### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`)
+6 -1
View File
@@ -451,7 +451,12 @@ struct ContentView: View {
func connectViaUrl_(_ url: URL) {
dismissAllSheets() {
var path = url.path
if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
if path == "/r" {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
)
} else if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
path.removeFirst()
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
planAndConnect(
+59 -17
View File
@@ -45,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String)
case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?)
case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatTag(tag: ChatTagData)
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
case apiDeleteChatTag(tagId: Int64)
@@ -61,7 +61,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64])
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?)
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -70,6 +70,8 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetNtfConns(nonce: String, encNtfInfo: String)
case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq])
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile)
case apiGetGroupRelays(groupId: Int64)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
@@ -126,7 +128,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiChangeConnectionUser(connId: Int64, userId: Int64)
case apiConnectPlan(userId: Int64, connLink: String)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData)
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64)
case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?)
@@ -230,10 +232,11 @@ enum ChatCommand: ChatCmdProtocol {
return "/_get chat \(chatId)\(scopeRef(scope))\(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, live, ttl, composedMessages):
case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
let asGroup = sendAsGroup ? "(as_group=on)" : ""
return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
@@ -252,9 +255,10 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))"
case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl):
case let .apiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
let asGroup = sendAsGroup ? " as_group=on" : ""
return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
case .apiGetNtfToken: return "/_ntf get "
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
@@ -263,6 +267,8 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))"
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))"
case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
@@ -329,7 +335,7 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)"
case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)"
case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))"
case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))"
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))"
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)"
case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))"
@@ -449,6 +455,8 @@ enum ChatCommand: ChatCmdProtocol {
case .apiGetNtfConns: return "apiGetNtfConns"
case .apiGetConnNtfMessages: return "apiGetConnNtfMessages"
case .apiNewGroup: return "apiNewGroup"
case .apiNewPublicGroup: return "apiNewPublicGroup"
case .apiGetGroupRelays: return "apiGetGroupRelays"
case .apiAddMember: return "apiAddMember"
case .apiJoinGroup: return "apiJoinGroup"
case .apiAcceptMember: return "apiAcceptMember"
@@ -660,7 +668,7 @@ enum ChatResponse0: Decodable, ChatAPIResult {
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
case serverOperatorConditions(conditions: ServerOperatorConditions)
case userServers(user: UserRef, userServers: [UserOperatorServers])
case userServersValidation(user: UserRef, serverErrors: [UserServersError])
case userServersValidation(user: UserRef, serverErrors: [UserServersError], serverWarnings: [UserServersWarning])
case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg)
@@ -728,7 +736,7 @@ enum ChatResponse0: Decodable, ChatAPIResult {
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
case let .userServersValidation(u, serverErrors, serverWarnings): return withUser(u, "serverErrors: \(String(describing: serverErrors))\nserverWarnings: \(String(describing: serverWarnings))")
case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
case let .networkConfig(networkConfig): return String(describing: networkConfig)
@@ -779,7 +787,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case sentConfirmation(user: UserRef, connection: PendingContactConnection)
case sentInvitation(user: UserRef, connection: PendingContactConnection)
case startedConnectionToContact(user: UserRef, contact: Contact)
case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo)
case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo, relayResults: [RelayConnectionResult])
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
case contactAlreadyExists(user: UserRef, contact: Contact)
case contactDeleted(user: UserRef, contact: Contact)
@@ -900,7 +908,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact))
case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .startedConnectionToGroup(u, groupInfo, relayResults): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nrelayResults: \(String(describing: relayResults))")
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
}
@@ -911,6 +919,8 @@ enum ChatResponse1: Decodable, ChatAPIResult {
enum ChatResponse2: Decodable, ChatAPIResult {
// group responses
case groupCreated(user: UserRef, groupInfo: GroupInfo)
case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay])
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
@@ -961,6 +971,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
var responseType: String {
switch self {
case .groupCreated: "groupCreated"
case .publicGroupCreated: "publicGroupCreated"
case .groupRelays: "groupRelays"
case .sentGroupInvitation: "sentGroupInvitation"
case .userAcceptedGroupSent: "userAcceptedGroupSent"
case .userDeletedMembers: "userDeletedMembers"
@@ -1007,6 +1019,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
var details: String {
switch self {
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)")
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
@@ -1086,10 +1100,11 @@ enum ChatEvent: Decodable, ChatAPIResult {
case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool)
case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case userJoinedGroup(user: UserRef, groupInfo: GroupInfo)
case userJoinedGroup(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?)
case groupUpdated(user: UserRef, toGroup: GroupInfo)
case groupLinkRelaysUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
// receiving file events
case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
@@ -1166,6 +1181,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .joinedGroupMember: "joinedGroupMember"
case .connectedToGroupMember: "connectedToGroupMember"
case .groupUpdated: "groupUpdated"
case .groupLinkRelaysUpdated: "groupLinkRelaysUpdated"
case .newMemberContactReceivedInv: "newMemberContactReceivedInv"
case .rcvFileAccepted: "rcvFileAccepted"
case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled"
@@ -1242,10 +1258,11 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)")
case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .userJoinedGroup(u, groupInfo, _): return withUser(u, String(describing: groupInfo))
case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))")
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
case let .groupLinkRelaysUpdated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
case .rcvFileAcceptedSndCancelled: return noDetails
@@ -1284,6 +1301,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
struct NewUser: Encodable {
var profile: Profile?
var pastTimestamp: Bool
var userChatRelay: Bool = false
}
enum ChatPagination {
@@ -1331,8 +1349,14 @@ enum ContactAddressPlan: Decodable, Hashable {
case contactViaAddress(contact: Contact)
}
public struct GroupShortLinkInfo: Decodable, Hashable {
public var direct: Bool
public var groupRelays: [String]
public var sharedGroupId: String?
}
enum GroupLinkPlan: Decodable, Hashable {
case ok(groupSLinkData_: GroupShortLinkData?)
case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?)
case ownLink(groupInfo: GroupInfo)
case connectingConfirmReconnect
case connectingProhibit(groupInfo_: GroupInfo?)
@@ -1712,6 +1736,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
var `operator`: ServerOperator?
var smpServers: [UserServer]
var xftpServers: [UserServer]
var chatRelays: [UserChatRelay]
var id: String {
if let op = self.operator {
@@ -1741,21 +1766,29 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
static var sampleData1 = UserOperatorServers(
operator: ServerOperator.sampleData1,
smpServers: [UserServer.sampleData.preset],
xftpServers: [UserServer.sampleData.xftpPreset]
xftpServers: [UserServer.sampleData.xftpPreset],
chatRelays: []
)
static var sampleDataNilOperator = UserOperatorServers(
operator: nil,
smpServers: [UserServer.sampleData.preset],
xftpServers: [UserServer.sampleData.xftpPreset]
xftpServers: [UserServer.sampleData.xftpPreset],
chatRelays: []
)
}
public enum UserServersWarning: Decodable {
case noChatRelays(user: UserRef?)
}
enum UserServersError: Decodable {
case noServers(protocol: ServerProtocol, user: UserRef?)
case storageMissing(protocol: ServerProtocol, user: UserRef?)
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
case duplicateChatRelayName(duplicateChatRelay: String)
case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String)
var globalError: String? {
switch self {
@@ -1774,6 +1807,10 @@ enum UserServersError: Decodable {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
case let .duplicateChatRelayName(duplicateChatRelay):
return String.localizedStringWithFormat(NSLocalizedString("Duplicate chat relay name: %@", comment: "servers error"), duplicateChatRelay)
case let .duplicateChatRelayAddress(_, duplicateAddress):
return String.localizedStringWithFormat(NSLocalizedString("Duplicate chat relay address: %@", comment: "servers error"), duplicateAddress)
default: return nil
}
}
@@ -1913,6 +1950,11 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable {
}
}
struct RelayConnectionResult: Decodable {
var relayMember: GroupMember
var relayError: ChatError?
}
enum ProtocolTestStep: String, Decodable, Equatable {
case connect
case disconnect
+30
View File
@@ -333,6 +333,22 @@ class ConnectProgressManager: ObservableObject {
}
}
class ChannelRelaysModel: ObservableObject {
static let shared = ChannelRelaysModel()
@Published var groupId: Int64? = nil
@Published var groupRelays: [GroupRelay] = []
func set(groupId: Int64, groupRelays: [GroupRelay]) {
self.groupId = groupId
self.groupRelays = groupRelays
}
func reset() {
groupId = nil
groupRelays = []
}
}
// Spec: spec/state.md#ChatModel
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@@ -366,6 +382,9 @@ final class ChatModel: ObservableObject {
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
// Runtime-only relay hostnames for pre-join channel display, not persisted lost on app restart.
// APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join.
@Published var channelRelayHostnames: [Int64: [String]] = [:]
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@@ -1196,13 +1215,24 @@ final class ChatModel: ObservableObject {
// Spec: spec/state.md#removeChat
func removeChat(_ id: String) {
var groupId: Int64?
withAnimation {
if let i = getChatIndex(id) {
let removed = chats.remove(at: i)
groupId = removed.chatInfo.groupInfo?.groupId
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
removeWallpaperFilesFromChat(removed)
}
}
if chatId == id {
groupMembers = []
groupMembersIndexes.removeAll()
// Remove channelRelayHostnames for this channel only, preserving other prepared channels
if let gId = groupId {
channelRelayHostnames.removeValue(forKey: gId)
}
membersLoaded = false
}
}
func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool {
+39 -12
View File
@@ -503,8 +503,8 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?,
throw r.unexpected
}
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
@@ -536,8 +536,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages)
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
@@ -795,10 +795,10 @@ func setUserServers(userServers: [UserOperatorServers]) async throws {
throw r.unexpected
}
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
func validateServers(userServers: [UserOperatorServers]) async throws -> ([UserServersError], [UserServersWarning]) {
let userId = try currentUserId("validateServers")
let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
if case let .userServersValidation(_, serverErrors, serverWarnings) = r { return (serverErrors, serverWarnings) }
logger.error("validateServers error: \(String(describing: r))")
throw r.unexpected
}
@@ -1121,9 +1121,9 @@ func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactS
throw r.unexpected
}
func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
let userId = try currentUserId("apiPrepareGroup")
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData))
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData))
if case let .newPreparedChat(_, chat) = r { return chat }
throw r.unexpected
}
@@ -1147,9 +1147,9 @@ func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgConten
return nil
}
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? {
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo }
if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
return nil
}
@@ -1826,6 +1826,22 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf
throw r.unexpected
}
func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])? {
let userId = try currentUserId("apiNewPublicGroup")
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile))
switch r {
case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)):
return (groupInfo, groupLink, groupRelays)
default: if let r { throw r.unexpected } else { return nil }
}
}
func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiGetGroupRelays(groupId: groupId))
if case let .result(.groupRelays(_, _, relays)) = r { return relays }
return []
}
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
@@ -2461,9 +2477,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
}
case let .groupLinkConnecting(user, groupInfo, hostMember):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, hostMember)
if let hostConn = hostMember.activeConn {
m.dismissConnReqView(hostConn.id)
m.removeChat(hostConn.id)
@@ -2526,10 +2542,11 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.updateGroup(groupInfo)
}
}
case let .userJoinedGroup(user, groupInfo):
case let .userJoinedGroup(user, groupInfo, hostMember):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, hostMember)
}
if m.chatId == groupInfo.id {
if groupInfo.membership.memberPending {
@@ -2561,6 +2578,16 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.updateGroup(toGroup)
}
}
case let .groupLinkRelaysUpdated(user, groupInfo, _, groupRelays):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
let relaysModel = ChannelRelaysModel.shared
if relaysModel.groupId == groupInfo.groupId {
relaysModel.set(groupId: groupInfo.groupId, groupRelays: groupRelays)
}
}
}
case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _):
if active(user) {
await MainActor.run {
@@ -267,6 +267,7 @@ struct ListItem: Hashable {
case .directRcv: 1
case .groupSnd: 2
case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash
case .channelRcv: 3
case .localSnd: 4
case .localRcv: 5
}
+101 -26
View File
@@ -65,7 +65,6 @@ struct ChatView: View {
@State private var showUserSupportChatSheet = false
@State private var showCommandsMenu = false
@State private var supportChatMemberInfoLinkActive = false
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -135,12 +134,6 @@ struct ChatView: View {
.padding(.top)
}
if selectedChatItems == nil {
let reason = chat.chatInfo.userCantSendReason
let composeEnabled = (
chat.chatInfo.sendMsgEnabled ||
(chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || // allow to join prepared group without message
(chat.chatInfo.contact?.nextAcceptContactRequest ?? false) // allow to accept or reject contact request
)
ComposeView(
chat: chat,
im: im,
@@ -149,17 +142,8 @@ struct ChatView: View {
keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
selectedRange: $selectedRange,
disabledText: reason?.composeLabel
disabledText: chat.chatInfo.userCantSendReason?.composeLabel
)
.disabled(!composeEnabled)
.if(!composeEnabled) { v in
v.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: reason?.alertMessage
)
}
}
} else {
SelectedItemsBottomToolbar(
im: im,
@@ -405,6 +389,7 @@ struct ChatView: View {
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
ChannelRelaysModel.shared.reset()
}
}
}
@@ -701,6 +686,17 @@ struct ChatView: View {
}
}
}
if case let .group(groupInfo, _) = cInfo, groupInfo.useRelays {
Task { await chatModel.loadGroupMembers(groupInfo) }
if groupInfo.membership.memberRole == .owner {
Task {
let relays = await apiGetGroupRelays(groupInfo.groupId)
await MainActor.run {
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
}
}
}
}
updateAvailableContent()
}
if chatModel.draftChatId == cInfo.id && !composeState.forwarding,
@@ -1029,12 +1025,12 @@ struct ChatView: View {
switch groupInfo.businessChat?.chatType {
case .none:
if groupInfo.nextConnectPrepared {
"Tap Join group"
groupInfo.useRelays ? "Tap Join channel" : "Tap Join group"
} else {
switch (groupInfo.membership.memberStatus) {
case .memInvited: "Join group"
case .memCreator: "Your group"
default: "Group"
case .memInvited: groupInfo.useRelays ? "Join channel" : "Join group"
case .memCreator: groupInfo.useRelays ? "Your channel" : "Your group"
default: groupInfo.useRelays ? "Channel" : "Group"
}
}
case .business:
@@ -1062,10 +1058,14 @@ struct ChatView: View {
nil
}
case let .group(groupInfo, _):
switch (groupInfo.membership.memberStatus) {
case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil
case .memAccepted: "connecting…"
default: nil
if groupInfo.useRelays {
nil
} else {
switch (groupInfo.membership.memberStatus) {
case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil
case .memAccepted: "connecting…"
default: nil
}
}
default: nil
}
@@ -1653,6 +1653,8 @@ struct ChatView: View {
let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir {
groupMember.groupMemberId == prevGroupMember.groupMemberId
} else if case .channelRcv = chatItem.chatDir, case .channelRcv = prevItem.chatDir {
true
} else {
chatItem.chatDir.sent == prevItem.chatDir.sent
}
@@ -1668,16 +1670,21 @@ struct ChatView: View {
func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
let oldIsGroupRcv = switch older?.chatDir {
case .groupRcv: true
case .channelRcv: true
default: false
}
let sameMember = switch (older?.chatDir, current.chatDir) {
case (.groupRcv(let oldMember), .groupRcv(let member)):
oldMember.memberId == member.memberId
case (.channelRcv, .channelRcv):
true
default:
false
}
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else {
return false
}
@@ -1843,7 +1850,74 @@ struct ChatView: View {
_ itemSeparation: ItemSeparation
) -> some View {
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
if case let .groupRcv(member) = ci.chatDir,
if case .channelRcv = ci.chatDir,
case let .group(groupInfo, _) = chat.chatInfo {
if showAvatar {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Group {
Group {
if #available(iOS 16.0, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
Text(groupInfo.chatViewName)
.lineLimit(1)
Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages"))
.fontWeight(.semibold)
.lineLimit(1)
.padding(.trailing, 8)
}
} else {
HStack(spacing: 16) {
Text(groupInfo.chatViewName)
.lineLimit(1)
Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages"))
.fontWeight(.semibold)
.lineLimit(1)
.layoutPriority(1)
}
}
}
.frame(
maxWidth: maxWidth,
alignment: chatItem.chatDir.sent ? .trailing : .leading
)
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
.padding(.top, 3)
}
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 10) {
ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background)
.simultaneousGesture(TapGesture().onEnded {
showChatInfoSheet = true
})
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
}
}
}
.padding(.bottom, bottomPadding)
.padding(.trailing)
.padding(.leading, 12)
} else {
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
.padding(.leading, 10 + memberImageSize + 12)
}
.padding(.bottom, bottomPadding)
}
} else if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo, _) = chat.chatInfo {
if showAvatar {
VStack(alignment: .leading, spacing: 4) {
@@ -2043,6 +2117,7 @@ struct ChatView: View {
switch (prevItem?.chatDir) {
case .groupSnd: return true
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
case .channelRcv: return true
default: return false
}
}
@@ -364,6 +364,8 @@ struct ComposeView: View {
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@State private var updatingCompose = false
@State private var relayListExpanded = false
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
// Spec: spec/client/compose.md#body
var body: some View {
@@ -371,6 +373,7 @@ struct ComposeView: View {
Divider()
if chat.chatInfo.nextConnectPrepared,
!composeState.inProgress,
let user = chatModel.currentUser {
ContextProfilePickerView(
chat: chat,
@@ -379,85 +382,148 @@ struct ComposeView: View {
Divider()
}
if let groupInfo = chat.chatInfo.groupInfo,
case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
case let .memberSupport(member) = groupScopeInfo,
let member = member,
member.memberPending,
composeState.contextItem == .noContextItem,
composeState.noPreview {
ContextPendingMemberActionsView(
groupInfo: groupInfo,
member: member
)
Divider()
}
if case let .reportedItem(_, reason) = composeState.contextItem {
reportReasonView(reason)
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
}
contextItemView()
switch (composeState.editing, composeState.preview) {
case (true, .filePreview): EmptyView()
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
let contact = chat.chatInfo.contact
if chat.chatInfo.groupInfo?.nextConnectPrepared == true {
if chat.chatInfo.groupInfo?.businessChat == nil {
connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup)
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays {
if gInfo.membership.memberRole == .owner {
let relays = channelRelaysModel.groupId == gInfo.groupId
? channelRelaysModel.groupRelays : []
let activeCount = relays.filter { $0.relayStatus == .rsActive }.count
if !relays.isEmpty && activeCount < relays.count {
ownerChannelRelayBar(relays: relays, activeCount: activeCount)
}
} else {
sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup)
}
} else if contact?.nextSendGrpInv == true {
contextSendMessageToConnect("Send direct message to connect")
Divider()
HStack (alignment: .center) {
attachmentAndCommandsButtons().disabled(true)
sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation)
}
.padding(.horizontal, 12)
} else if let contact,
contact.nextConnectPrepared == true,
let linkType = contact.preparedContact?.uiConnLinkType {
switch linkType {
case .inv:
connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact)
case .con:
if contact.isBot {
connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact)
} else {
sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest)
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
let relayMembers = chatModel.groupMembers
.filter { $0.wrapped.memberRole == .relay }
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count
let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count
let resolvedCount = connectedCount + deletedCount
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
if total > 0, !showProgress || resolvedCount < total {
subscriberChannelRelayBar(
hostnames: hostnames,
relayMembers: relayMembers,
connectedCount: connectedCount,
deletedCount: deletedCount,
total: total,
showProgress: showProgress
)
}
}
} else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId {
ContextContactRequestActionsView(contactRequestId: crId)
} else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv {
ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv)
} else {
HStack (alignment: .center) {
attachmentAndCommandsButtons()
sendMessageView(disableSendButton)
}
let composeEnabled = (
chat.chatInfo.sendMsgEnabled ||
(chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) ||
(chat.chatInfo.contact?.nextAcceptContactRequest ?? false)
)
Group {
if let groupInfo = chat.chatInfo.groupInfo,
case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
case let .memberSupport(member) = groupScopeInfo,
let member = member,
member.memberPending,
composeState.contextItem == .noContextItem,
composeState.noPreview {
ContextPendingMemberActionsView(
groupInfo: groupInfo,
member: member
)
Divider()
}
if case let .reportedItem(_, reason) = composeState.contextItem {
reportReasonView(reason)
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
}
contextItemView()
switch (composeState.editing, composeState.preview) {
case (true, .filePreview): EmptyView()
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
let contact = chat.chatInfo.contact
if chat.chatInfo.groupInfo?.nextConnectPrepared == true {
if chat.chatInfo.groupInfo?.businessChat == nil {
let isChannel = chat.chatInfo.groupInfo?.useRelays == true
connectButtonView(
isChannel ? "Join channel" : "Join group",
icon: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.fill",
connect: connectPreparedGroup
)
} else {
sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup)
}
} else if contact?.nextSendGrpInv == true {
contextSendMessageToConnect("Send direct message to connect")
Divider()
HStack (alignment: .center) {
attachmentAndCommandsButtons().disabled(true)
sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation)
}
.padding(.horizontal, 12)
} else if let contact,
contact.nextConnectPrepared == true,
let linkType = contact.preparedContact?.uiConnLinkType {
switch linkType {
case .inv:
connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact)
case .con:
if contact.isBot {
connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact)
} else {
sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest)
}
}
} else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId {
ContextContactRequestActionsView(contactRequestId: crId)
} else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv {
ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv)
} else {
HStack (alignment: .center) {
attachmentAndCommandsButtons()
sendMessageView(
disableSendButton,
placeholder: chat.chatInfo.groupInfo.map { gi in
gi.useRelays && gi.membership.memberRole >= .owner
? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner")
: nil
} ?? nil
)
}
.padding(.horizontal, 12)
}
} // Group
.disabled(!composeEnabled)
.if(!composeEnabled) { v in
v.onTapGesture {
if let reason = chat.chatInfo.userCantSendReason {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: reason.alertMessage
)
}
}
.padding(.horizontal, 12)
}
}
.background {
@@ -653,18 +719,129 @@ struct ComposeView: View {
}
}
private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int) -> some View {
let total = relays.count
let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
return VStack(spacing: 0) {
relayBarHeader {
if activeCount < total {
RelayProgressIndicator(active: activeCount, total: total)
}
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total))
}
if relayListExpanded {
ForEach(sorted) { relay in
relayBarDetailRow {
Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary)
Spacer()
relayStatusIndicator(relay.relayStatus)
}
}
}
}
.padding(.bottom, relayListExpanded ? 4 : 0)
.animation(nil, value: relayListExpanded)
}
private func subscriberChannelRelayBar(
hostnames: [String],
relayMembers: [GMember],
connectedCount: Int,
deletedCount: Int,
total: Int,
showProgress: Bool
) -> some View {
VStack(spacing: 0) {
relayBarHeader {
let activeTotal = total - deletedCount
if showProgress && connectedCount < activeTotal {
RelayProgressIndicator(active: connectedCount, total: activeTotal)
}
if showProgress {
if deletedCount > 0 {
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d deleted", comment: "channel subscriber relay bar progress with deleted"), connectedCount, activeTotal, deletedCount))
} else {
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, activeTotal))
}
} else {
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total))
}
}
if relayListExpanded {
if relayMembers.isEmpty {
ForEach(hostnames, id: \.self) { relay in
relayBarDetailRow {
Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(relay)))
.foregroundColor(theme.colors.secondary)
Spacer()
}
}
} else {
ForEach(relayMembers) { member in
let m = member.wrapped
let host = m.relayLink.map { hostFromRelayLink($0) }
relayBarDetailRow {
Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName))
.foregroundColor(theme.colors.secondary)
Spacer()
let status = relayConnStatus(m)
Circle()
.fill(status.color)
.frame(width: 8, height: 8)
Text(status.text)
.foregroundColor(theme.colors.secondary)
}
}
}
}
}
.padding(.bottom, relayListExpanded ? 4 : 0)
.animation(nil, value: relayListExpanded)
}
private func relayBarHeader<Content: View>(@ViewBuilder content: () -> Content) -> some View {
Button {
withAnimation(nil) { relayListExpanded.toggle() }
} label: {
HStack(spacing: 8) {
content()
Spacer()
Image(systemName: relayListExpanded ? "chevron.down" : "chevron.up")
.font(.system(size: 12, weight: .bold))
.foregroundColor(theme.colors.secondary)
.opacity(0.7)
}
.font(.callout)
.foregroundColor(theme.colors.secondary)
.padding(.top, 8)
.padding(.bottom, relayListExpanded ? 4 : 8)
.padding(.leading, 12)
.padding(.trailing)
}
}
private func relayBarDetailRow<Content: View>(@ViewBuilder content: () -> Content) -> some View {
HStack {
content()
}
.font(.caption)
.padding(.leading, 12)
.padding(.trailing)
.padding(.vertical, 2)
}
private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View {
Button(action: connect) {
ZStack(alignment: .trailing) {
Label(label, systemImage: icon)
.frame(maxWidth: .infinity)
if composeState.progressByTimeout {
if composeState.progressByTimeout && chat.chatInfo.groupInfo?.useRelays != true {
ProgressView()
.padding()
}
}
}
.frame(height: 60)
.frame(height: 57)
.disabled(composeState.inProgress)
}
@@ -851,9 +1028,12 @@ struct ComposeView: View {
await sending()
let mc = connectCheckLinkPreview()
let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
if let groupInfo = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
await MainActor.run {
self.chatModel.updateGroup(groupInfo)
self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId)
self.chatModel.groupMembers = relayResults.map { GMember($0.relayMember) }
self.chatModel.populateGroupMembersIndexes()
clearState()
}
} else {
@@ -1322,6 +1502,7 @@ struct ComposeView: View {
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
live: live,
ttl: ttl,
composedMessages: msgs
@@ -1347,6 +1528,7 @@ struct ComposeView: View {
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
toScope: chat.chatInfo.groupChatScope(),
sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
fromScope: fromChatInfo.groupChatScope(),
@@ -0,0 +1,90 @@
//
// ChannelMembersView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 20.02.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChannelMembersView: View {
@ObservedObject var chat: Chat
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var body: some View {
let allMembers = chatModel.groupMembers
.filter { m in
let s = m.wrapped.memberStatus
return s != .memLeft && s != .memRemoved && m.wrapped.groupMemberId != groupInfo.membership.groupMemberId
}
let owners = allMembers.filter { $0.wrapped.memberRole >= .owner }
// TODO [relays] subscriber/owner counts require backend support for accurate totals
let subscribers = allMembers.filter { $0.wrapped.memberRole < .owner && $0.wrapped.memberRole != .relay }
List {
Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) {
if groupInfo.membership.memberRole >= .owner {
memberRow(GMember(groupInfo.membership), user: true)
}
ForEach(owners) { member in
memberRow(member, user: false)
}
}
if groupInfo.isOwner {
Section(header: Text("\(subscribers.count) subscribers").foregroundColor(theme.colors.secondary)) {
if subscribers.isEmpty {
Text("No subscribers")
.foregroundColor(theme.colors.secondary)
} else {
ForEach(subscribers) { member in
memberRow(member, user: false)
}
}
}
}
}
}
@ViewBuilder private func memberRow(_ gMember: GMember, user: Bool) -> some View {
let member = gMember.wrapped
let nameText = Text(member.chatViewName)
.foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
let displayName = member.verified
? (Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption).baselineOffset(2).kerning(-2)
.foregroundColor(theme.colors.secondary) + nameText
: nameText
let row = HStack {
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
displayName
.lineLimit(1)
Spacer()
}
if user {
row
} else {
NavigationLink {
GroupMemberInfoView(
groupInfo: groupInfo,
chat: chat,
groupMember: gMember,
scrollToItemId: Binding.constant(nil)
)
.navigationBarHidden(false)
} label: {
row
}
}
}
}
#Preview {
ChannelMembersView(
chat: Chat.sampleData,
groupInfo: GroupInfo.sampleData
)
}
@@ -0,0 +1,118 @@
//
// ChannelRelaysView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 20.02.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChannelRelaysView: View {
@ObservedObject var chat: Chat
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var groupRelays: [GroupRelay] = []
var body: some View {
let isOwner = groupInfo.isOwner
List {
relaysList(showRelayStatus: isOwner)
}
.onAppear {
Task {
await chatModel.loadGroupMembers(groupInfo)
if isOwner {
groupRelays = await apiGetGroupRelays(groupInfo.groupId)
}
}
}
}
@ViewBuilder private func relaysList(showRelayStatus: Bool) -> some View {
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay }
if relayMembers.isEmpty {
Section {
Text("No chat relays")
.foregroundColor(theme.colors.secondary)
}
} else {
Section {
ForEach(relayMembers) { member in
NavigationLink {
GroupMemberInfoView(
groupInfo: groupInfo,
chat: chat,
groupMember: member,
scrollToItemId: Binding.constant(nil),
groupRelay: groupRelays.first(where: { $0.groupMemberId == member.wrapped.groupMemberId })
)
.navigationBarHidden(false)
} label: {
relayMemberRow(member.wrapped, relayStatus: showRelayStatus ? relayStatusForMember(member.wrapped) : nil)
}
}
} footer: {
Text("Chat relays forward messages to channel subscribers.")
}
}
}
private func relayStatusForMember(_ member: GroupMember) -> RelayStatus? {
groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus
}
private func relayMemberRow(_ member: GroupMember, relayStatus: RelayStatus?) -> some View {
HStack {
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
VStack(alignment: .leading) {
Text(member.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Text(relayStatus?.text ?? relayConnStatusText(member))
.lineLimit(1)
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
Spacer()
}
}
private func relayConnStatusText(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
"disabled"
} else if member.activeConn?.connInactive ?? false {
"inactive"
} else {
relayConnStatus(member).text
}
}
}
func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) {
switch member.activeConn?.connStatus {
case .ready: ("connected", .green)
case .deleted: ("deleted", .red)
default: ("connecting", .yellow)
}
}
func hostFromRelayLink(_ link: String) -> String {
if let ft = parseSimpleXMarkdown(link) {
for f in ft {
if case let .simplexLink(_, _, _, smpHosts) = f.format,
let host = smpHosts.first {
return host
}
}
}
return link
}
#Preview {
ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData)
}
@@ -90,22 +90,46 @@ struct GroupChatInfoView: View {
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section {
if groupInfo.canAddMembers && groupInfo.businessChat == nil {
groupLinkButton()
if groupInfo.useRelays {
Section {
// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)
if groupInfo.isOwner && groupLink != nil {
channelLinkButton()
} else if let link = groupInfo.groupProfile.groupLink {
SimpleXLinkQRCode(uri: link)
Button {
showShareSheet(items: [simplexChatLink(link)])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
}
if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) {
channelMembersButton()
}
} footer: {
if !groupInfo.isOwner && groupInfo.groupProfile.groupLink != nil {
Text("You can share a link or a QR code - anybody will be able to join the channel.")
.foregroundColor(theme.colors.secondary)
}
}
if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
memberSupportButton()
} else {
Section {
if groupInfo.canAddMembers && groupInfo.businessChat == nil {
groupLinkButton()
}
if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
memberSupportButton()
}
if groupInfo.canModerate {
GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
if groupInfo.membership.memberActive
&& (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
} header: {
Text("")
}
if groupInfo.canModerate {
GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
if groupInfo.membership.memberActive
&& (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
} header: {
Text("")
}
Section {
@@ -115,22 +139,28 @@ struct GroupChatInfoView: View {
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
addOrEditWelcomeMessage()
}
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
if !groupInfo.useRelays {
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
}
} footer: {
let label: LocalizedStringKey = (
groupInfo.businessChat == nil
? "Only group owners can change group preferences."
: "Only chat owners can change preferences."
)
Text(label)
.foregroundColor(theme.colors.secondary)
if !groupInfo.useRelays {
let label: LocalizedStringKey = (
groupInfo.businessChat == nil
? "Only group owners can change group preferences."
: "Only chat owners can change preferences."
)
Text(label)
.foregroundColor(theme.colors.secondary)
}
}
Section {
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
if !groupInfo.useRelays {
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
}
}
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
@@ -142,7 +172,7 @@ struct GroupChatInfoView: View {
Text("Delete chat messages from your device.")
}
if !groupInfo.nextConnectPrepared {
if !groupInfo.nextConnectPrepared && !groupInfo.useRelays {
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
@@ -174,12 +204,18 @@ struct GroupChatInfoView: View {
}
Section {
if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) {
channelRelaysButton()
}
clearChatButton()
if groupInfo.canDelete {
deleteGroupButton()
}
if groupInfo.membership.memberCurrentOrPending {
leaveGroupButton()
if !groupInfo.useRelays || !groupInfo.isOwner
|| members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) {
leaveGroupButton()
}
}
}
@@ -220,13 +256,15 @@ struct GroupChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
}
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
do {
if let gLink = try apiGetGroupLink(groupInfo.groupId) {
groupLink = gLink
groupLinkMemberRole = gLink.acceptMemberRole
if !groupInfo.useRelays || groupInfo.isOwner {
do {
if let gLink = try apiGetGroupLink(groupInfo.groupId) {
groupLink = gLink
groupLinkMemberRole = gLink.acceptMemberRole
}
} catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
} catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
}
@@ -299,7 +337,9 @@ struct GroupChatInfoView: View {
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
searchButton(width: buttonWidth)
if groupInfo.canAddMembers {
if groupInfo.useRelays && groupInfo.isOwner {
channelLinkActionButton(width: buttonWidth)
} else if !groupInfo.useRelays && groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
if let nextNtfMode = chat.chatInfo.nextNtfMode {
@@ -360,6 +400,23 @@ struct GroupChatInfoView: View {
.disabled(!groupInfo.ready)
}
private func channelLinkActionButton(width: CGFloat) -> some View {
ZStack {
InfoViewButton(image: "link", title: "link", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View {
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
case .customer: "Add team members"
@@ -545,19 +602,51 @@ struct GroupChatInfoView: View {
}
}
private func channelLinkButton() -> some View {
NavigationLink {
groupLinkDestinationView()
} label: {
Label("Channel link", systemImage: "link")
}
}
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
creatingGroup: false,
isChannel: groupInfo.useRelays
)
.navigationBarTitle("Group link")
.navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
}
private func channelMembersButton() -> some View {
let label: LocalizedStringKey = groupInfo.isOwner ? "Owners & subscribers" : "Owners"
return NavigationLink {
ChannelMembersView(chat: chat, groupInfo: groupInfo)
.navigationTitle(label)
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label(label, systemImage: "person.2")
}
}
private func channelRelaysButton() -> some View {
NavigationLink {
ChannelRelaysView(chat: chat, groupInfo: groupInfo)
.navigationTitle("Chat relays")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Chat relays", systemImage: "externaldrive.connected.to.line.below")
}
}
struct UserSupportChatNavLink: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@@ -652,7 +741,7 @@ struct GroupChatInfoView: View {
groupProfile: groupInfo.groupProfile
)
} label: {
Label("Edit group profile", systemImage: "pencil")
Label(groupInfo.useRelays ? "Edit channel profile" : "Edit group profile", systemImage: "pencil")
}
}
@@ -674,7 +763,7 @@ struct GroupChatInfoView: View {
}
@ViewBuilder private func deleteGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
Button(role: .destructive) {
alert = .deleteGroupAlert
} label: {
@@ -693,7 +782,7 @@ struct GroupChatInfoView: View {
}
private func leaveGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
let label: LocalizedStringKey = groupInfo.useRelays ? "Leave channel" : groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
return Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {
@@ -704,7 +793,7 @@ struct GroupChatInfoView: View {
// TODO reuse this and clearChatAlert with ChatInfoView
private func deleteGroupAlert() -> Alert {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
title: Text(label),
message: deleteGroupAlertMessage(groupInfo),
@@ -741,9 +830,11 @@ struct GroupChatInfoView: View {
}
private func leaveGroupAlert() -> Alert {
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
let messageLabel: LocalizedStringKey = (
groupInfo.businessChat == nil
groupInfo.useRelays
? "You will stop receiving messages from this channel. Chat history will be preserved."
: groupInfo.businessChat == nil
? "You will stop receiving messages from this group. Chat history will be preserved."
: "You will stop receiving messages from this chat. Chat history will be preserved."
)
@@ -794,9 +885,13 @@ struct GroupChatInfoView: View {
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
showAlert(
NSLocalizedString("Remove member?", comment: "alert title"),
groupInfo.useRelays
? NSLocalizedString("Remove subscriber?", comment: "alert title")
: NSLocalizedString("Remove member?", comment: "alert title"),
message:
groupInfo.businessChat == nil
groupInfo.useRelays
? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
: groupInfo.businessChat == nil
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
actions: {[
@@ -838,10 +933,18 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool
}
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
groupInfo.businessChat == nil ? (
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
groupInfo.useRelays ? (
groupInfo.membership.memberCurrent
? Text("Channel will be deleted for all subscribers - this cannot be undone!")
: Text("Channel will be deleted for you - this cannot be undone!")
) : groupInfo.businessChat == nil ? (
groupInfo.membership.memberCurrent
? Text("Group will be deleted for all members - this cannot be undone!")
: Text("Group will be deleted for you - this cannot be undone!")
) : (
groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!")
groupInfo.membership.memberCurrent
? Text("Chat will be deleted for all members - this cannot be undone!")
: Text("Chat will be deleted for you - this cannot be undone!")
)
}
@@ -17,6 +17,7 @@ struct GroupLinkView: View {
@Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false
var creatingGroup: Bool = false
var isChannel: Bool = false
var linkCreatedCb: (() -> Void)? = nil
@State private var showShortLink = true
@State private var creatingLink = false
@@ -60,12 +61,16 @@ struct GroupLinkView: View {
List {
Group {
if showTitle {
Text("Group link")
Text(isChannel ? "Channel link" : "Group link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
}
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
if isChannel {
Text("You can share a link or a QR code - anybody will be able to join the channel.")
} else {
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -73,15 +78,17 @@ struct GroupLinkView: View {
Section {
if let groupLink = groupLink {
Picker("Initial role", selection: $groupLinkMemberRole) {
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
Text(role.text)
if !isChannel {
Picker("Initial role", selection: $groupLinkMemberRole) {
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
Text(role.text)
}
}
.frame(height: 36)
}
.frame(height: 36)
SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink)
.id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))")
if groupLink.shouldBeUpgraded {
if !isChannel && groupLink.shouldBeUpgraded {
Button {
upgradeAndShareLinkAlert()
} label: {
@@ -89,7 +96,7 @@ struct GroupLinkView: View {
}
}
Button {
if groupLink.shouldBeUpgraded {
if !isChannel && groupLink.shouldBeUpgraded {
upgradeAndShareLinkAlert(groupLink: groupLink)
} else {
groupLink.shareAddress(short: showShortLink)
@@ -98,7 +105,8 @@ struct GroupLinkView: View {
Label("Share link", systemImage: "square.and.arrow.up")
}
if !creatingGroup {
// TODO [relays] review: channel link deletion is only possible together with deleting the channel
if !creatingGroup && !isChannel {
Button(role: .destructive) { alert = .deleteLink } label: {
Label("Delete link", systemImage: "trash")
}
@@ -110,7 +118,7 @@ struct GroupLinkView: View {
.disabled(creatingLink)
}
} header: {
if let groupLink, groupLink.connLinkContact.connShortLink != nil {
if !isChannel, let groupLink, groupLink.connLinkContact.connShortLink != nil {
ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink)
}
}
@@ -20,6 +20,7 @@ struct GroupMemberInfoView: View {
@Binding var scrollToItemId: ChatItem.ID?
var navigation: Bool = false
var openedFromSupportChat: Bool = false
var groupRelay: GroupRelay? = nil
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false
@@ -32,6 +33,26 @@ struct GroupMemberInfoView: View {
@State private var justOpened = true
@State private var progressIndicator = false
private var channelMemberSectionHeader: LocalizedStringKey {
if groupInfo.useRelays {
switch groupMember.wrapped.memberRole {
case .relay: "Relay"
case .owner: "Owner"
default: "Subscriber"
}
} else {
"Member"
}
}
private var relaySectionFooter: LocalizedStringKey {
if groupInfo.isOwner {
"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel."
} else {
"You connected to the channel via this relay link."
}
}
enum GroupMemberInfoViewAlert: Identifiable {
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
@@ -89,13 +110,15 @@ struct GroupMemberInfoView: View {
.listRowSeparator(.hidden)
.padding(.bottom, 18)
infoActionButtons(member)
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if !groupInfo.useRelays {
infoActionButtons(member)
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
if connectionLoaded {
@@ -103,10 +126,14 @@ struct GroupMemberInfoView: View {
Section {
if !openedFromSupportChat
&& groupInfo.membership.memberRole >= .moderator
&& member.memberRole != .relay
&& (member.memberRole < .moderator || member.supportChat != nil) {
MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
}
if let code = connectionCode { verifyCodeButton(code) }
if let code = connectionCode,
!(groupInfo.useRelays && member.memberRole == .relay) {
verifyCodeButton(code)
}
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
@@ -141,11 +168,12 @@ struct GroupMemberInfoView: View {
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
Section {
let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat"
infoRow(label, groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
// TODO [relays] review: role changing is not supported for channels currently
if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
@@ -155,6 +183,23 @@ struct GroupMemberInfoView: View {
} else {
infoRow("Role", member.memberRole.text)
}
if let link = member.relayLink {
infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link)))
}
if let address = groupRelay?.userChatRelay.address {
infoRow("Relay address", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(address)))
Button {
showShareSheet(items: [simplexChatLink(address)])
} label: {
Label("Share relay address", systemImage: "square.and.arrow.up")
}
}
} header: {
Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary)
} footer: {
if groupInfo.useRelays && member.memberRole == .relay {
Text(relaySectionFooter).foregroundColor(theme.colors.secondary)
}
}
if let connStats = connectionStats {
@@ -191,7 +236,7 @@ struct GroupMemberInfoView: View {
if groupInfo.membership.memberRole >= .moderator {
adminDestructiveSection(member)
} else {
} else if !groupInfo.useRelays {
nonAdminBlockSection(member)
}
@@ -203,16 +248,18 @@ struct GroupMemberInfoView: View {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
Button ("Debug delivery") {
Task {
do {
if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) {
await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
if !groupInfo.useRelays || member.memberRole == .relay {
Button ("Debug delivery") {
Task {
do {
if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) {
await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
}
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
}
}
@@ -576,7 +623,9 @@ struct GroupMemberInfoView: View {
blockForAllButton(mem)
}
}
if canRemove {
// TODO [relays] removing relay should also remove its link from group link data;
// removing last relay should be prohibited or show warning
if canRemove && mem.memberRole != .relay {
if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
deleteMemberMessagesButton(mem)
} else {
@@ -638,7 +687,7 @@ struct GroupMemberInfoView: View {
Button(role: .destructive) {
showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
} label: {
Label("Remove member", systemImage: "trash")
Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash")
.foregroundColor(.red)
}
}
@@ -818,7 +867,7 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet
func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Block member for all?"),
title: Text(gInfo.useRelays ? "Block subscriber for all?" : "Block member for all?"),
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
primaryButton: .destructive(Text("Block for all")) {
blockMemberForAll(gInfo, mem, true)
@@ -829,7 +878,7 @@ func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Unblock member for all?"),
title: Text(gInfo.useRelays ? "Unblock subscriber for all?" : "Unblock member for all?"),
message: Text("Messages from \(mem.chatViewName) will be shown!"),
primaryButton: .default(Text("Unblock for all")) {
blockMemberForAll(gInfo, mem, false)
@@ -244,7 +244,7 @@ struct ChatListNavLink: View {
}
.swipeActions(edge: .trailing) {
tagChatButton(chat)
if (groupInfo.membership.memberCurrentOrPending) {
if groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) {
leaveGroupChatButton(groupInfo)
}
if groupInfo.canDelete {
@@ -269,7 +269,7 @@ struct ChatListNavLink: View {
let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator
let showClearButton = !chat.chatItems.isEmpty
let showDeleteGroup = groupInfo.canDelete
let showLeaveGroup = groupInfo.membership.memberCurrentOrPending
let showLeaveGroup = groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)
let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
if showClearButton && totalNumberOfButtons <= 3 {
@@ -565,7 +565,7 @@ struct ChatListNavLink: View {
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
title: Text(label),
message: deleteGroupAlertMessage(groupInfo),
@@ -620,9 +620,11 @@ struct ChatListNavLink: View {
}
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
let messageLabel: LocalizedStringKey = (
groupInfo.businessChat == nil
groupInfo.useRelays
? "You will stop receiving messages from this channel. Chat history will be preserved."
: groupInfo.businessChat == nil
? "You will stop receiving messages from this group. Chat history will be preserved."
: "You will stop receiving messages from this chat. Chat history will be preserved."
)
@@ -64,13 +64,14 @@ enum ActiveFilter: Identifiable, Equatable {
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: [])
}
struct ServerSettings {
public var currUserServers: [UserOperatorServers]
public var userServers: [UserOperatorServers]
public var serverErrors: [UserServersError]
public var serverWarnings: [UserServersWarning]
}
struct UserPickerSheetView: View {
@@ -17,6 +17,15 @@ extension View {
self
}
}
@inline(__always)
@ViewBuilder func compactSectionSpacing() -> some View {
if #available(iOS 17, *) {
self.listSectionSpacing(.compact)
} else {
self
}
}
}
extension Notification.Name {
@@ -0,0 +1,396 @@
//
// AddChannelView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 23.02.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct AddChannelView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
@StateObject private var ss = SaveableSettings()
@State private var profile = GroupProfile(displayName: "", fullName: "")
@FocusState private var focusDisplayName: Bool
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil
@State private var hasRelays = true
@State private var groupInfo: GroupInfo? = nil
@State private var groupLink: GroupLink? = nil
@State private var groupRelays: [GroupRelay] = []
@State private var creationInProgress = false
@State private var showLinkStep = false
@State private var relayListExpanded = false
var body: some View {
Group {
if showLinkStep, let gInfo = groupInfo {
linkStepView(gInfo)
} else if let gInfo = groupInfo {
progressStepView(gInfo)
} else {
profileStepView()
}
}
}
// MARK: - Step 1: Profile
private func profileStepView() -> some View {
List {
Group {
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profile.image, size: 128)
if profile.image != nil {
Button {
profile.image = nil
} label: {
Image(systemName: "multiply")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12)
}
}
}
editImageButton { showChooseSource = true }
.buttonStyle(BorderlessButtonStyle())
}
.frame(maxWidth: .infinity, alignment: .center)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
Section {
channelNameTextField()
NavigationLink {
NetworkAndServers()
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
.environmentObject(ss)
} label: {
let color: Color = hasRelays ? .accentColor : .orange
settingsRow("externaldrive.connected.to.line.below", color: color) {
Text("Configure relays").foregroundColor(color)
}
}
let canCreate = canCreateProfile() && hasRelays && !creationInProgress
Button(action: createChannel) {
settingsRow("checkmark", color: canCreate ? theme.colors.primary : theme.colors.secondary) { Text("Create channel") }
}
.disabled(!canCreate)
} footer: {
if !hasRelays {
ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning"))
} else {
Text("Your profile will be shared with chat relays and subscribers.")
.foregroundColor(theme.colors.secondary)
}
}
.compactSectionSpacing()
}
.onAppear {
Task { hasRelays = await checkHasRelays() }
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true
}
}
.confirmationDialog("Channel image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") { showTakePhoto = true }
Button("Choose from library") { showImagePicker = true }
}
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { _ in
await MainActor.run { showImagePicker = false }
}
}
.onChange(of: chosenImage) { image in
Task {
let resized: String? = if let image {
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
} else {
nil
}
await MainActor.run { profile.image = resized }
}
}
.modifier(ThemedBackground(grouped: true))
}
private func channelNameTextField() -> some View {
ZStack(alignment: .leading) {
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
if name != mkValidName(name) {
Button {
showInvalidChannelNameAlert()
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
}
TextField("Enter channel name…", text: $profile.displayName)
.padding(.leading, 36)
.focused($focusDisplayName)
.submitLabel(.continue)
.onSubmit {
if canCreateProfile() && hasRelays { createChannel() }
}
}
}
private func canCreateProfile() -> Bool {
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
return name != "" && validDisplayName(name)
}
private func createChannel() {
focusDisplayName = false
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
creationInProgress = true
Task {
do {
let enabledRelays = try await getEnabledRelays()
let relayIds = enabledRelays.compactMap { $0.chatRelayId }
guard !relayIds.isEmpty else {
await MainActor.run {
creationInProgress = false
hasRelays = false
}
return
}
guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup(
incognito: false, relayIds: relayIds, groupProfile: profile
) else {
await MainActor.run { creationInProgress = false }
return
}
await MainActor.run {
m.updateGroup(gInfo)
groupInfo = gInfo
groupLink = gLink
groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) }
channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays)
creationInProgress = false
}
} catch {
await MainActor.run {
creationInProgress = false
showAlert(
NSLocalizedString("Error creating channel", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
// TODO [relays] move random relay selection to backend; prefer selecting relays from different operators
private func getEnabledRelays() async throws -> [UserChatRelay] {
let servers = try await getUserServers()
let all = servers.flatMap { op in
op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
}
return Array(all.shuffled().prefix(3))
}
private func checkHasRelays() async -> Bool {
guard let servers = try? await getUserServers() else { return false }
return servers.contains { op in
op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
}
}
// MARK: - Step 2: Progress
private func progressStepView(_ gInfo: GroupInfo) -> some View {
let activeCount = groupRelays.filter { $0.relayStatus == .rsActive }.count
let total = groupRelays.count
return List {
Group {
ProfileImage(imageStr: gInfo.groupProfile.image, size: 128)
.frame(maxWidth: .infinity, alignment: .center)
Text(gInfo.groupProfile.displayName)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .center)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
Section {
Button {
withAnimation { relayListExpanded.toggle() }
} label: {
HStack(spacing: 8) {
if activeCount < total {
RelayProgressIndicator(active: activeCount, total: total)
}
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total))
Spacer()
Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down")
.foregroundColor(theme.colors.secondary)
}
}
.foregroundColor(theme.colors.onBackground)
if relayListExpanded {
ForEach(groupRelays) { relay in
HStack {
Text(relayDisplayName(relay))
Spacer()
relayStatusIndicator(relay.relayStatus)
}
}
}
}
.compactSectionSpacing()
Section {
Button("Channel link") {
if activeCount >= total {
showLinkStep = true
} else if activeCount > 0 {
showAlert(
NSLocalizedString("Not all relays connected", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total),
actions: {[
UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in }
]}
)
}
}
.disabled(activeCount == 0)
}
}
.navigationTitle("Creating channel")
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { cancelChannelCreation(gInfo) }
}
}
.onChange(of: channelRelaysModel.groupRelays) { relays in
guard channelRelaysModel.groupId == gInfo.groupId else { return }
groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
if relays.allSatisfy({ $0.relayStatus == .rsActive }) {
showLinkStep = true
channelRelaysModel.reset()
}
}
}
// MARK: - Step 3: Link
private func linkStepView(_ gInfo: GroupInfo) -> some View {
GroupLinkView(
groupId: gInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays
showTitle: false,
creatingGroup: true,
isChannel: true
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(gInfo.id)
}
}
}
.navigationBarTitle("Channel link")
}
private func cancelChannelCreation(_ gInfo: GroupInfo) {
channelRelaysModel.reset()
dismissAllSheets(animated: true)
Task {
do {
try await apiDeleteChat(type: .group, id: gInfo.apiId)
await MainActor.run { m.removeChat(gInfo.id) }
} catch {
logger.error("cancelChannelCreation error: \(responseError(error))")
}
}
}
// MARK: - Helpers
private func showInvalidChannelNameAlert() {
let validName = mkValidName(profile.displayName)
if validName == "" {
showAlert(NSLocalizedString("Invalid name!", comment: "alert title"))
} else {
showAlert(
NSLocalizedString("Invalid name!", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName),
actions: {[
UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in
profile.displayName = validName
},
cancelAlertAction
]}
)
}
}
}
func relayDisplayName(_ relay: GroupRelay) -> String {
if !relay.userChatRelay.name.isEmpty { return relay.userChatRelay.name }
if let domain = relay.userChatRelay.domains.first { return domain }
if let link = relay.relayLink { return hostFromRelayLink(link) }
return "relay \(relay.groupRelayId)"
}
func relayStatusIndicator(_ status: RelayStatus) -> some View {
HStack(spacing: 4) {
Circle()
.fill(status == .rsActive ? .green : status == .rsNew ? .red : .orange)
.frame(width: 8, height: 8)
Text(status.text)
.font(.caption)
.foregroundStyle(.secondary)
}
}
struct RelayProgressIndicator: View {
var active: Int
var total: Int
var body: some View {
if active == 0 {
ProgressView()
.frame(width: 20, height: 20)
} else {
ZStack {
Circle()
.stroke(Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 2.5))
Circle()
.trim(from: 0, to: Double(active) / Double(max(total, 1)))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.frame(width: 20, height: 20)
}
}
}
#Preview {
AddChannelView()
}
@@ -88,7 +88,7 @@ struct AddGroupView: View {
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
Section {
groupNameTextField()
@@ -108,6 +108,7 @@ struct AddGroupView: View {
focusDisplayName = false
}
}
.compactSectionSpacing()
}
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
@@ -125,6 +125,14 @@ struct NewChatSheet: View {
} label: {
Label("Create group", systemImage: "person.2.circle.fill")
}
NavigationLink {
AddChannelView()
.navigationTitle("Create channel")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Create channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill")
}
}
if (showArchive) {
+94 -40
View File
@@ -990,42 +990,67 @@ private func showOwnGroupLinkConfirmConnectSheet(
dismiss: Bool,
cleanup: (() -> Void)?
) {
showSheet(
String.localizedStringWithFormat(
NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"),
groupInfo.displayName
),
actions: {[
UIAlertAction(
title: NSLocalizedString("Open group", comment: "new chat action"),
style: .default,
handler: { _ in
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
}
if groupInfo.useRelays {
showSheet(
String.localizedStringWithFormat(
NSLocalizedString("This is your link for channel %@!", comment: "new chat action"),
groupInfo.displayName
),
UIAlertAction(
title: NSLocalizedString("Use current profile", comment: "new chat action"),
style: .destructive,
handler: { _ in
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
}
actions: {[
UIAlertAction(
title: NSLocalizedString("Open channel", comment: "new chat action"),
style: .default,
handler: { _ in
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
}
),
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "new chat action"),
style: .default,
handler: { _ in
cleanup?()
}
)
]}
)
} else {
showSheet(
String.localizedStringWithFormat(
NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"),
groupInfo.displayName
),
UIAlertAction(
title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
style: .destructive,
handler: { _ in
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
}
),
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "new chat action"),
style: .default,
handler: { _ in
cleanup?()
}
)
]}
)
actions: {[
UIAlertAction(
title: NSLocalizedString("Open group", comment: "new chat action"),
style: .default,
handler: { _ in
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
}
),
UIAlertAction(
title: NSLocalizedString("Use current profile", comment: "new chat action"),
style: .destructive,
handler: { _ in
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
}
),
UIAlertAction(
title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
style: .destructive,
handler: { _ in
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
}
),
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "new chat action"),
style: .default,
handler: { _ in
cleanup?()
}
)
]}
)
}
}
private func showPrepareContactAlert(
@@ -1074,30 +1099,45 @@ private func showPrepareContactAlert(
private func showPrepareGroupAlert(
connectionLink: CreatedConnLink,
groupShortLinkInfo: GroupShortLinkInfo?,
groupShortLinkData: GroupShortLinkData,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
) {
let isChannel = !(groupShortLinkInfo?.direct ?? true)
showOpenChatAlert(
profileName: groupShortLinkData.groupProfile.displayName,
profileFullName: groupShortLinkData.groupProfile.fullName,
profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize),
profileImage:
ProfileImage(
imageStr: groupShortLinkData.groupProfile.image,
iconName: isChannel
? "antenna.radiowaves.left.and.right.circle.fill"
: "person.2.circle.fill",
size: alertProfileImageSize
),
theme: theme,
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"),
confirmTitle: isChannel
? NSLocalizedString("Open new channel", comment: "new chat action")
: NSLocalizedString("Open new group", comment: "new chat action"),
onCancel: { cleanup?() },
onConfirm: {
Task {
do {
let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData)
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 {
ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays
}
ChatModel.shared.addChat(Chat(chat))
openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
}
} catch let error {
logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)")
showAlert(NSLocalizedString("Error opening group", comment: ""), message: responseError(error))
showAlert(NSLocalizedString(isChannel ? "Error opening channel" : "Error opening group", comment: "alert title"), message: responseError(error))
await MainActor.run {
cleanup?()
}
@@ -1150,7 +1190,12 @@ private func showOpenKnownGroupAlert(
theme: theme,
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle:
groupInfo.businessChat == nil
groupInfo.useRelays
? ( groupInfo.nextConnectPrepared
? NSLocalizedString("Open new channel", comment: "new chat action")
: NSLocalizedString("Open channel", comment: "new chat action")
)
: groupInfo.businessChat == nil
? ( groupInfo.nextConnectPrepared
? NSLocalizedString("Open new group", comment: "new chat action")
: NSLocalizedString("Open group", comment: "new chat action")
@@ -1174,6 +1219,14 @@ func planAndConnect(
filterKnownContact: ((Contact) -> Void)? = nil,
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
)
cleanup?()
return
}
ConnectProgressManager.shared.cancelConnectProgress()
let inProgress = BoxedValue(true)
connectTask(inProgress)
@@ -1332,12 +1385,13 @@ func planAndConnect(
}
case let .groupLink(glp):
switch glp {
case let .ok(groupSLinkData_):
case let .ok(groupShortLinkInfo_, groupSLinkData_):
if let groupSLinkData = groupSLinkData_ {
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
await MainActor.run {
showPrepareGroupAlert(
connectionLink: connectionLink,
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
theme: theme,
dismiss: dismiss,
@@ -0,0 +1,323 @@
//
// ChatRelayView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 23.02.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
// Spec: spec/architecture.md
import SwiftUI
import SimpleXChat
@ViewBuilder func showRelayTestStatus(relay: UserChatRelay) -> some View {
switch relay.tested {
case .some(true): Image(systemName: "checkmark").foregroundColor(.green)
case .some(false): Image(systemName: "multiply").foregroundColor(.red)
case .none: Color.clear
}
}
func validRelayName(_ name: String) -> Bool {
name != "" && validDisplayName(name)
}
func showInvalidRelayNameAlert(_ name: Binding<String>) {
let validName = mkValidName(name.wrappedValue)
if validName == "" {
showAlert(NSLocalizedString("Invalid name!", comment: "alert title"))
} else {
showAlert(
NSLocalizedString("Invalid name!", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName),
actions: {[
UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in
name.wrappedValue = validName
},
cancelAlertAction
]}
)
}
}
func validRelayAddress(_ address: String) -> Bool {
if let parsedMd = parseSimpleXMarkdown(address),
parsedMd.count == 1,
case .simplexLink(_, .relay, _, _) = parsedMd.first?.format {
true
} else {
false
}
}
// TODO [relays] TBC matching relay to operator by domain (relay address can be hosted on operator server)
func addChatRelay(
_ relay: UserChatRelay,
_ userServers: Binding<[UserOperatorServers]>,
_ serverErrors: Binding<[UserServersError]>,
_ serverWarnings: Binding<[UserServersWarning]>? = nil,
_ dismiss: DismissAction
) {
let nameEmpty = relay.name.trimmingCharacters(in: .whitespaces).isEmpty
let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty
if nameEmpty && addressEmpty {
dismiss()
} else if !validRelayName(relay.name) {
dismiss()
showAlert(
NSLocalizedString("Invalid relay name!", comment: "alert title"),
message: NSLocalizedString("Check relay name and try again.", comment: "alert message")
)
} else if !validRelayAddress(relay.address) {
dismiss()
showAlert(
NSLocalizedString("Invalid relay address!", comment: "alert title"),
message: NSLocalizedString("Check relay address and try again.", comment: "alert message")
)
} else if let i = userServers.wrappedValue.firstIndex(where: { $0.operator == nil }) {
userServers[i].wrappedValue.chatRelays.append(relay)
validateServers_(userServers, serverErrors, serverWarnings)
dismiss()
} else { // Shouldn't happen
dismiss()
showAlert(NSLocalizedString("Error adding relay", comment: "alert title"))
}
}
struct ChatRelayView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@Binding var relay: UserChatRelay
@State var relayToEdit: UserChatRelay
var backLabel: LocalizedStringKey
var body: some View {
let validName = validRelayName(relayToEdit.name)
let validAddress = validRelayAddress(relayToEdit.address)
ZStack {
if relay.preset {
presetRelay()
} else {
customRelay(validName: validName, validAddress: validAddress)
}
}
.modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
if validName && validAddress {
relay = relayToEdit
validateServers_($userServers, $serverErrors, $serverWarnings)
dismiss()
} else if !validName {
dismiss()
showAlert(
NSLocalizedString("Invalid relay name!", comment: "alert title"),
message: NSLocalizedString("Check relay name and try again.", comment: "alert message")
)
} else {
dismiss()
showAlert(
NSLocalizedString("Invalid relay address!", comment: "alert title"),
message: NSLocalizedString("Check relay address and try again.", comment: "alert message")
)
}
})
}
private func relayNameHeader(validName: Bool) -> some View {
HStack {
Text("Your relay name").foregroundColor(theme.colors.secondary)
if !validName {
Spacer()
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
.onTapGesture { showInvalidRelayNameAlert($relayToEdit.name) }
}
}
}
private func presetRelay() -> some View {
List {
Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) {
Text(relayToEdit.name)
}
Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) {
Text(relayToEdit.address)
.textSelection(.enabled)
}
useRelaySection()
}
}
private func customRelay(validName: Bool, validAddress: Bool) -> some View {
List {
Section {
TextField("Enter relay name…", text: $relayToEdit.name)
.autocorrectionDisabled(true)
} header: {
relayNameHeader(validName: validName)
}
Section {
TextEditor(text: $relayToEdit.address)
.multilineTextAlignment(.leading)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.allowsTightening(true)
.lineLimit(10)
.frame(height: 144)
.padding(-6)
} header: {
HStack {
Text("Your relay address")
.foregroundColor(theme.colors.secondary)
if !validAddress {
Spacer()
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
}
}
useRelaySection(valid: validAddress)
Section {
Button(role: .destructive) {
relay.deleted = true
validateServers_($userServers, $serverErrors, $serverWarnings)
dismiss()
} label: {
Label("Delete relay", systemImage: "trash")
.foregroundColor(.red)
}
}
}
}
private func useRelaySection(valid: Bool = true) -> some View {
Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) {
HStack {
Button("Test relay") {
showAlert(
NSLocalizedString("Not implemented", comment: "alert title"),
message: NSLocalizedString("Relay testing is not yet available.", comment: "alert message")
)
}
.disabled(!valid)
Spacer()
showRelayTestStatus(relay: relayToEdit)
}
Toggle("Use for new channels", isOn: $relayToEdit.enabled)
}
}
}
struct ChatRelayViewLink: View {
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@Binding var relay: UserChatRelay
var backLabel: LocalizedStringKey
@Binding var selectedServer: String?
var body: some View {
NavigationLink(tag: relay.id, selection: $selectedServer) {
ChatRelayView(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
relay: $relay,
relayToEdit: relay,
backLabel: backLabel
)
.navigationBarTitle("Chat relay")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
HStack {
Group {
if !relay.enabled {
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
} else {
showRelayTestStatus(relay: relay)
}
}
.frame(width: 16, alignment: .center)
.padding(.trailing, 4)
let displayName = !relay.name.isEmpty ? relay.name : relay.domains.first ?? relay.address
let v = Text(displayName).lineLimit(1)
if relay.enabled {
v
} else {
v.foregroundColor(theme.colors.secondary)
}
}
}
}
}
struct NewChatRelayView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@State private var relayToEdit = UserChatRelay(
chatRelayId: nil, address: "", name: "", domains: [],
preset: false, tested: nil, enabled: true, deleted: false
)
var body: some View {
let validName = validRelayName(relayToEdit.name)
let validAddress = validRelayAddress(relayToEdit.address)
List {
Section {
TextField("Enter relay name…", text: $relayToEdit.name)
.autocorrectionDisabled(true)
} header: {
HStack {
Text("Your relay name").foregroundColor(theme.colors.secondary)
if !validName {
Spacer()
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
.onTapGesture { showInvalidRelayNameAlert($relayToEdit.name) }
}
}
}
Section {
TextEditor(text: $relayToEdit.address)
.multilineTextAlignment(.leading)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.allowsTightening(true)
.lineLimit(10)
.frame(height: 144)
.padding(-6)
} header: {
HStack {
Text("Your relay address")
.foregroundColor(theme.colors.secondary)
if !validAddress {
Spacer()
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
}
}
Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) {
HStack {
Button("Test relay") {
showAlert(
NSLocalizedString("Not implemented", comment: "alert title"),
message: NSLocalizedString("Relay testing is not yet available.", comment: "alert message")
)
}
.disabled(!validAddress)
Spacer()
showRelayTestStatus(relay: relayToEdit)
}
Toggle("Use for new channels", isOn: $relayToEdit.enabled)
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss)
})
}
}
@@ -78,6 +78,7 @@ struct NetworkAndServers: View {
YourServersView(
userServers: $ss.servers.userServers,
serverErrors: $ss.servers.serverErrors,
serverWarnings: $ss.servers.serverWarnings,
operatorIndex: idx
)
.navigationTitle("Your servers")
@@ -115,6 +116,9 @@ struct NetworkAndServers: View {
} else if !ss.servers.serverErrors.isEmpty {
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
}
if let warnStr = globalServersWarning(ss.servers.serverWarnings) {
ServersWarningView(warnStr: warnStr)
}
}
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
@@ -143,6 +147,8 @@ struct NetworkAndServers: View {
ss.servers.currUserServers = try await getUserServers()
ss.servers.userServers = ss.servers.currUserServers
ss.servers.serverErrors = []
ss.servers.serverWarnings = []
validateServers_($ss.servers.userServers, $ss.servers.serverErrors, $ss.servers.serverWarnings)
} catch let error {
await MainActor.run {
showAlert(
@@ -186,6 +192,7 @@ struct NetworkAndServers: View {
currUserServers: $ss.servers.currUserServers,
userServers: $ss.servers.userServers,
serverErrors: $ss.servers.serverErrors,
serverWarnings: $ss.servers.serverWarnings,
operatorIndex: operatorIndex,
useOperator: serverOperator.enabled
)
@@ -360,13 +367,18 @@ struct SimpleConditionsView: View {
}
}
func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
func validateServers_(
_ userServers: Binding<[UserOperatorServers]>,
_ serverErrors: Binding<[UserServersError]>,
_ serverWarnings: Binding<[UserServersWarning]>? = nil
) {
let userServersToValidate = userServers.wrappedValue
Task {
do {
let errs = try await validateServers(userServers: userServersToValidate)
let (errs, warns) = try await validateServers(userServers: userServersToValidate)
await MainActor.run {
serverErrors.wrappedValue = errs
serverWarnings?.wrappedValue = warns
}
} catch let error {
logger.error("validateServers error: \(responseError(error))")
@@ -396,6 +408,20 @@ struct ServersErrorView: View {
}
}
struct ServersWarningView: View {
@EnvironmentObject var theme: AppTheme
var warnStr: String
var body: some View {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
Text(warnStr)
.foregroundColor(theme.colors.secondary)
}
}
}
func globalServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalError {
@@ -405,6 +431,29 @@ func globalServersError(_ serverErrors: [UserServersError]) -> String? {
return nil
}
func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? {
for warn in serverWarnings {
switch warn {
case let .noChatRelays(user):
let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning")
if let user = user {
return String.localizedStringWithFormat(
NSLocalizedString("For chat profile %@:", comment: "servers warning"),
user.localDisplayName
) + " " + text
} else { return text }
}
}
return nil
}
func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> {
Binding(
get: { userServers[opIndex].wrappedValue.chatRelays },
set: { userServers[opIndex].wrappedValue.chatRelays = $0 }
)
}
func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalSMPError {
@@ -15,6 +15,7 @@ struct NewServerView: View {
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@State private var serverToEdit: UserServer = .empty
@State private var showTestFailure = false
@State private var testing = false
@@ -28,7 +29,7 @@ struct NewServerView: View {
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
addServer(serverToEdit, $userServers, $serverErrors, dismiss)
addServer(serverToEdit, $userServers, $serverErrors, $serverWarnings, dismiss)
})
.alert(isPresented: $showTestFailure) {
Alert(
@@ -118,6 +119,7 @@ func addServer(
_ server: UserServer,
_ userServers: Binding<[UserOperatorServers]>,
_ serverErrors: Binding<[UserServersError]>,
_ serverWarnings: Binding<[UserServersWarning]>? = nil,
_ dismiss: DismissAction
) {
if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) {
@@ -126,7 +128,7 @@ func addServer(
case .smp: userServers[i].wrappedValue.smpServers.append(server)
case .xftp: userServers[i].wrappedValue.xftpServers.append(server)
}
validateServers_(userServers, serverErrors)
validateServers_(userServers, serverErrors, serverWarnings)
dismiss()
if let op = matchingOperator {
showAlert(
@@ -152,6 +154,7 @@ func addServer(
#Preview {
NewServerView(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([])
serverErrors: Binding.constant([]),
serverWarnings: Binding.constant([])
)
}
@@ -19,6 +19,7 @@ struct OperatorView: View {
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
var operatorIndex: Int
@State var useOperator: Bool
@State private var useOperatorToggleReset: Bool = false
@@ -52,6 +53,8 @@ struct OperatorView: View {
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if let warnStr = globalServersWarning(serverWarnings) {
ServersWarningView(warnStr: warnStr)
} else {
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
case let .accepted(acceptedAt, _):
@@ -69,15 +72,36 @@ struct OperatorView: View {
}
if userServers[operatorIndex].operator_.enabled {
if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty {
Section {
ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in
if !relay.wrappedValue.deleted {
ChatRelayViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
relay: relay,
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
selectedServer: $selectedServer
)
} else { EmptyView() }
}
} header: {
Text("Chat relays").foregroundColor(theme.colors.secondary)
} footer: {
Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary)
}
}
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
Section {
Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage)
.onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy)
.onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Use for messages")
@@ -97,6 +121,7 @@ struct OperatorView: View {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .smp,
@@ -128,6 +153,7 @@ struct OperatorView: View {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .smp,
@@ -140,7 +166,7 @@ struct OperatorView: View {
}
.onDelete { indexSet in
deleteSMPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Added message servers")
@@ -152,7 +178,7 @@ struct OperatorView: View {
Section {
Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage)
.onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Use for files")
@@ -172,6 +198,7 @@ struct OperatorView: View {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .xftp,
@@ -203,6 +230,7 @@ struct OperatorView: View {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .xftp,
@@ -215,7 +243,7 @@ struct OperatorView: View {
}
.onDelete { indexSet in
deleteXFTPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Added media & file servers")
@@ -246,6 +274,7 @@ struct OperatorView: View {
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
operatorIndex: operatorIndex
)
.modifier(ThemedBackground(grouped: true))
@@ -276,18 +305,18 @@ struct OperatorView: View {
switch userServers[operatorIndex].operator_.conditionsAcceptance {
case .accepted:
userServers[operatorIndex].operator_.enabled = true
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
case let .required(deadline):
if deadline == nil {
showConditionsSheet = true
} else {
userServers[operatorIndex].operator_.enabled = true
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
}
} else {
userServers[operatorIndex].operator_.enabled = false
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
}
}
@@ -424,6 +453,7 @@ struct SingleOperatorUsageConditionsView: View {
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
var operatorIndex: Int
var body: some View {
@@ -526,7 +556,7 @@ struct SingleOperatorUsageConditionsView: View {
updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
userServers[operatorIndexToEnable].operator?.enabled = true
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
dismiss()
}
} catch let error {
@@ -581,6 +611,7 @@ func conditionsLinkButton() -> some View {
currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
serverWarnings: Binding.constant([]),
operatorIndex: 1,
useOperator: ServerOperator.sampleData1.enabled
)
@@ -15,6 +15,7 @@ struct ProtocolServerView: View {
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@Binding var server: UserServer
@State var serverToEdit: UserServer
var backLabel: LocalizedStringKey
@@ -50,7 +51,7 @@ struct ProtocolServerView: View {
)
} else {
server = serverToEdit
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
dismiss()
}
} else {
@@ -202,6 +203,7 @@ struct ProtocolServerView_Previews: PreviewProvider {
ProtocolServerView(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
serverWarnings: Binding.constant([]),
server: Binding.constant(UserServer.sampleData.custom),
serverToEdit: UserServer.sampleData.custom,
backLabel: "Your SMP servers"
@@ -19,10 +19,12 @@ struct YourServersView: View {
@Environment(\.editMode) private var editMode
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
var operatorIndex: Int
@State private var selectedServer: String? = nil
@State private var showAddServer = false
@State private var newServerNavLinkActive = false
@State private var newChatRelayNavLinkActive = false
@State private var showScanProtoServer = false
@State private var testing = false
@@ -42,6 +44,31 @@ struct YourServersView: View {
private func yourServersView() -> some View {
let duplicateHosts = findDuplicateHosts(serverErrors)
return List {
if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty {
Section {
ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in
if !relay.wrappedValue.deleted {
ChatRelayViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
relay: relay,
backLabel: "Your servers",
selectedServer: $selectedServer
)
} else { EmptyView() }
}
.onDelete { indexSet in
deleteChatRelay($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Chat relays").foregroundColor(theme.colors.secondary)
} footer: {
Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary)
}
}
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
Section {
ForEach($userServers[operatorIndex].smpServers) { srv in
@@ -49,6 +76,7 @@ struct YourServersView: View {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .smp,
@@ -61,7 +89,7 @@ struct YourServersView: View {
}
.onDelete { indexSet in
deleteSMPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Message servers")
@@ -84,6 +112,7 @@ struct YourServersView: View {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .xftp,
@@ -96,7 +125,7 @@ struct YourServersView: View {
}
.onDelete { indexSet in
deleteXFTPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Media & file servers")
@@ -125,10 +154,23 @@ struct YourServersView: View {
}
.frame(width: 1, height: 1)
.hidden()
NavigationLink(isActive: $newChatRelayNavLinkActive) {
NewChatRelayView(userServers: $userServers, serverErrors: $serverErrors, serverWarnings: $serverWarnings)
.navigationTitle("New chat relay")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if let warnStr = globalServersWarning(serverWarnings) {
ServersWarningView(warnStr: warnStr)
}
}
@@ -144,7 +186,8 @@ struct YourServersView: View {
.toolbar {
if (
!userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty ||
!userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty
!userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty ||
!userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty
) {
EditButton()
}
@@ -152,11 +195,13 @@ struct YourServersView: View {
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
Button("Enter server manually") { newServerNavLinkActive = true }
Button("Scan server QR code") { showScanProtoServer = true }
Button("Chat relay") { newChatRelayNavLinkActive = true }
}
.sheet(isPresented: $showScanProtoServer) {
ScanProtocolServer(
userServers: $userServers,
serverErrors: $serverErrors
serverErrors: $serverErrors,
serverWarnings: $serverWarnings
)
.modifier(ThemedBackground(grouped: true))
}
@@ -165,7 +210,8 @@ struct YourServersView: View {
private func newServerDestinationView() -> some View {
NewServerView(
userServers: $userServers,
serverErrors: $serverErrors
serverErrors: $serverErrors,
serverWarnings: $serverWarnings
)
.navigationTitle("New server")
.navigationBarTitleDisplayMode(.large)
@@ -190,6 +236,7 @@ struct ProtocolServerViewLink: View {
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
var duplicateHosts: Set<String>
@Binding var server: UserServer
var serverProtocol: ServerProtocol
@@ -203,6 +250,7 @@ struct ProtocolServerViewLink: View {
ProtocolServerView(
userServers: $userServers,
serverErrors: $serverErrors,
serverWarnings: $serverWarnings,
server: $server,
serverToEdit: server,
backLabel: backLabel
@@ -280,6 +328,23 @@ func deleteXFTPServer(
}
}
func deleteChatRelay(
_ userServers: Binding<[UserOperatorServers]>,
_ operatorServersIndex: Int,
_ serverIndexSet: IndexSet
) {
if let idx = serverIndexSet.first {
let relay = userServers[operatorServersIndex].wrappedValue.chatRelays[idx]
if relay.chatRelayId == nil {
userServers[operatorServersIndex].wrappedValue.chatRelays.remove(at: idx)
} else {
var updatedRelay = relay
updatedRelay.deleted = true
userServers[operatorServersIndex].wrappedValue.chatRelays[idx] = updatedRelay
}
}
}
struct TestServersButton: View {
@Binding var smpServers: [UserServer]
@Binding var xftpServers: [UserServer]
@@ -354,6 +419,7 @@ struct YourServersView_Previews: PreviewProvider {
YourServersView(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
serverWarnings: Binding.constant([]),
operatorIndex: 1
)
}
@@ -15,6 +15,7 @@ struct ScanProtocolServer: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
var body: some View {
VStack(alignment: .leading) {
@@ -36,7 +37,7 @@ struct ScanProtocolServer: View {
case let .success(r):
var server: UserServer = .empty
server.server = r.string
addServer(server, $userServers, $serverErrors, dismiss)
addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss)
case let .failure(e):
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
@@ -48,7 +49,8 @@ struct ScanProtocolServer_Previews: PreviewProvider {
static var previews: some View {
ScanProtocolServer(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([])
serverErrors: Binding.constant([]),
serverWarnings: Binding.constant([])
)
}
}
+5 -3
View File
@@ -68,6 +68,7 @@ func apiSendMessages(
type: chatInfo.chatType,
id: chatInfo.apiId,
scope: chatInfo.groupChatScope(),
sendAsGroup: chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
live: false,
ttl: nil,
composedMessages: composedMessages
@@ -124,7 +125,7 @@ enum SEChatCommand: ChatCmdProtocol {
case apiSetEncryptLocalFiles(enable: Bool)
case apiGetChats(userId: Int64)
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
var cmdString: String {
switch self {
@@ -140,10 +141,11 @@ enum SEChatCommand: ChatCmdProtocol {
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiSendMessages(type, id, scope, live, ttl, composedMessages):
case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
let asGroup = sendAsGroup ? "(as_group=on)" : ""
return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
}
}
@@ -162,9 +162,13 @@
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; };
647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; };
@@ -528,10 +532,14 @@
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = "<group>"; };
647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = "<group>"; };
@@ -959,6 +967,7 @@
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */,
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */,
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */,
647B15E72F4C8D2500EB431E /* AddChannelView.swift */,
);
path = NewChat;
sourceTree = "<group>";
@@ -1122,6 +1131,7 @@
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */,
647B15E92F4C8D5100EB431E /* ChatRelayView.swift */,
);
path = NetworkAndServers;
sourceTree = "<group>";
@@ -1141,6 +1151,8 @@
64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */,
64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */,
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
6495D7032F48CFC50060512B /* ChannelMembersView.swift */,
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */,
);
path = Group;
sourceTree = "<group>";
@@ -1470,6 +1482,7 @@
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */,
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
@@ -1572,6 +1585,8 @@
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */,
E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */,
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */,
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */,
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
@@ -1611,6 +1626,7 @@
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */,
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */,
1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */,
5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */,
1841538E296606C74533367C /* UserPicker.swift in Sources */,
+6
View File
@@ -727,6 +727,7 @@ public enum ChatErrorType: Decodable, Hashable {
case userUnknown
case activeUserExists
case userExists
case chatRelayExists
case invalidDisplayName
case differentActiveUser(commandUserId: Int64, activeUserId: Int64)
case cantDeleteActiveUser(userId: Int64)
@@ -801,6 +802,7 @@ public enum ChatErrorType: Decodable, Hashable {
public enum StoreError: Decodable, Hashable {
case duplicateName
case userNotFound(userId: Int64)
case relayUserNotFound
case userNotFoundByName(contactName: ContactName)
case userNotFoundByContactId(contactId: Int64)
case userNotFoundByGroupId(groupId: Int64)
@@ -825,6 +827,7 @@ public enum StoreError: Decodable, Hashable {
case memberContactGroupMemberNotFound(contactId: Int64)
case groupWithoutUser
case duplicateGroupMember
case duplicateMemberId
case groupAlreadyJoined
case groupInvitationNotFound
case sndFileNotFound(fileId: Int64)
@@ -859,6 +862,9 @@ public enum StoreError: Decodable, Hashable {
case hostMemberIdNotFound(groupId: Int64)
case contactNotFoundByFileId(fileId: Int64)
case noGroupSndStatus(itemId: Int64, groupMemberId: Int64)
case userChatRelayNotFound(chatRelayId: Int64)
case groupRelayNotFound(groupRelayId: Int64)
case groupRelayNotFoundByMemberId(groupMemberId: Int64)
case dBException(message: String)
}
+108 -15
View File
@@ -43,6 +43,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
public var autoAcceptMemberContacts: Bool
public var viewPwdHash: UserPwdHash?
public var uiThemes: ThemeModeOverrides?
public var userChatRelay: Bool
public var id: Int64 { userId }
@@ -68,7 +69,8 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
showNtfs: true,
sendRcptsContacts: true,
sendRcptsSmallGroups: false,
autoAcceptMemberContacts: false
autoAcceptMemberContacts: false,
userChatRelay: false
)
}
@@ -1577,7 +1579,9 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
switch(groupChatScope) {
case .none:
if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") }
if groupInfo.membership.memberRole == .observer { return ("you are observer", "Please contact group admin.") }
if groupInfo.membership.memberRole == .observer {
return groupInfo.useRelays ? ("you are subscriber", nil) : ("you are observer", "Please contact group admin.")
}
return nil
case let .some(.memberSupport(groupMember_: .some(supportMember))):
if supportMember.versionRange.maxVersion < GROUP_KNOCKING_VERSION && !supportMember.memberPending {
@@ -2336,6 +2340,8 @@ public struct Group: Decodable, Hashable {
public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var groupId: Int64
public var useRelays: Bool
public var relayOwnStatus: RelayStatus? = nil
var localDisplayName: GroupName
public var groupProfile: GroupProfile
public var businessChat: BusinessChatInfo?
@@ -2379,15 +2385,20 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
}
public var chatIconName: String {
switch businessChat?.chatType {
case .none: "person.2.circle.fill"
case .business: "briefcase.circle.fill"
case .customer: "person.crop.circle.fill"
if useRelays {
"antenna.radiowaves.left.and.right.circle.fill"
} else {
switch businessChat?.chatType {
case .none: "person.2.circle.fill"
case .business: "briefcase.circle.fill"
case .customer: "person.crop.circle.fill"
}
}
}
public static let sampleData = GroupInfo(
groupId: 1,
useRelays: false,
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
fullGroupPreferences: FullGroupPreferences.sampleData,
@@ -2419,6 +2430,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable {
shortDescr: String? = nil,
description: String? = nil,
image: String? = nil,
groupLink: String? = nil,
groupPreferences: GroupPreferences? = nil,
memberAdmission: GroupMemberAdmission? = nil
) {
@@ -2427,6 +2439,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable {
self.shortDescr = shortDescr
self.description = description
self.image = image
self.groupLink = groupLink
self.groupPreferences = groupPreferences
self.memberAdmission = memberAdmission
}
@@ -2436,6 +2449,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable {
public var shortDescr: String?
public var description: String?
public var image: String?
public var groupLink: String?
public var groupPreferences: GroupPreferences?
public var memberAdmission: GroupMemberAdmission?
public var localAlias: String { "" }
@@ -2489,6 +2503,75 @@ public struct GroupShortLinkData: Codable, Hashable {
public var groupProfile: GroupProfile
}
public enum RelayStatus: String, Decodable, Equatable, Hashable {
case rsNew = "new"
case rsInvited = "invited"
case rsAccepted = "accepted"
case rsActive = "active"
}
public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable {
public var chatRelayId: Int64?
public var address: String
public var name: String
public var domains: [String]
public var preset: Bool
public var tested: Bool?
public var enabled: Bool
public var deleted: Bool
public var createdAt = Date()
public init(chatRelayId: Int64? = nil, address: String, name: String, domains: [String], preset: Bool, tested: Bool? = nil, enabled: Bool, deleted: Bool, createdAt: Date = Date()) {
self.chatRelayId = chatRelayId
self.address = address
self.name = name
self.domains = domains
self.preset = preset
self.tested = tested
self.enabled = enabled
self.deleted = deleted
self.createdAt = createdAt
}
public static func == (l: UserChatRelay, r: UserChatRelay) -> Bool {
l.chatRelayId == r.chatRelayId && l.address == r.address && l.name == r.name && l.domains == r.domains &&
l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled && l.deleted == r.deleted
}
public var id: String { "\(address) \(createdAt)" }
public enum CodingKeys: CodingKey {
case chatRelayId
case address
case name
case domains
case preset
case tested
case enabled
case deleted
}
}
public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable {
public var groupRelayId: Int64
public var groupMemberId: Int64
public var userChatRelay: UserChatRelay
public var relayStatus: RelayStatus
public var relayLink: String?
public var id: Int64 { groupRelayId }
}
extension RelayStatus {
public var text: LocalizedStringKey {
switch self {
case .rsNew: "New"
case .rsInvited: "Invited"
case .rsAccepted: "Accepted"
case .rsActive: "Active"
}
}
}
public struct BusinessChatInfo: Decodable, Hashable {
public var chatType: BusinessChatType
public var businessId: String
@@ -2517,6 +2600,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var activeConn: Connection?
public var supportChat: GroupSupportChat?
public var memberChatVRange: VersionRange
public var relayLink: String?
public var id: String { "#\(groupId) @\(groupMemberId)" }
public var ready: Bool { get { activeConn?.connStatus == .ready } }
@@ -2642,14 +2726,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
}
public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? {
if !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil }
if memberRole == .relay || !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil }
let userRole = groupInfo.membership.memberRole
return GroupMemberRole.supportedRoles.filter { $0 <= userRole }
}
public func canBlockForAll(groupInfo: GroupInfo) -> Bool {
let userRole = groupInfo.membership.memberRole
return memberRole < .moderator
return memberRole != .relay && memberRole < .moderator
&& userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive
&& !memberPending
}
@@ -2720,6 +2804,7 @@ public struct GroupMemberIds: Decodable, Hashable {
}
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable {
case relay
case observer
case author
case member
@@ -2733,6 +2818,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod
public var text: String {
switch self {
case .relay: return NSLocalizedString("relay", comment: "member role")
case .observer: return NSLocalizedString("observer", comment: "member role")
case .author: return NSLocalizedString("author", comment: "member role")
case .member: return NSLocalizedString("member", comment: "member role")
@@ -2744,12 +2830,13 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod
private var comparisonValue: Int {
switch self {
case .observer: 0
case .author: 1
case .member: 2
case .moderator: 3
case .admin: 4
case .owner: 5
case .relay: 0
case .observer: 1
case .author: 2
case .member: 3
case .moderator: 4
case .admin: 5
case .owner: 6
}
}
@@ -3217,6 +3304,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
case let (.group(groupInfo, _), .groupSnd):
let m = groupInfo.membership
return m.memberRole >= .moderator ? (groupInfo, nil) : nil
case (.group, .channelRcv):
return nil
default: return nil
}
}
@@ -3437,6 +3526,7 @@ public enum CIDirection: Decodable, Hashable {
case directRcv
case groupSnd
case groupRcv(groupMember: GroupMember)
case channelRcv
case localSnd
case localRcv
@@ -3447,6 +3537,7 @@ public enum CIDirection: Decodable, Hashable {
case .directRcv: return false
case .groupSnd: return true
case .groupRcv: return false
case .channelRcv: return false
case .localSnd: return true
case .localRcv: return false
}
@@ -3456,6 +3547,7 @@ public enum CIDirection: Decodable, Hashable {
public func sameDirection(_ dir: CIDirection) -> Bool {
switch (self, dir) {
case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId
case (.channelRcv, .channelRcv): true
default: sent == dir.sent
}
}
@@ -4047,6 +4139,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable {
case .directRcv: return nil
case .groupSnd: return membership?.displayName ?? "you"
case let .groupRcv(member): return member.displayName
case .channelRcv: return nil
case .localSnd: return "you"
case .localRcv: return nil
case nil: return nil
@@ -4689,7 +4782,7 @@ public enum SimplexLinkType: String, Decodable, Hashable {
case .invitation: return NSLocalizedString("SimpleX one-time invitation", comment: "simplex link type")
case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type")
case .channel: return NSLocalizedString("SimpleX channel link", comment: "simplex link type")
case .relay: return NSLocalizedString("SimpleX relay link", comment: "simplex link type")
case .relay: return NSLocalizedString("SimpleX relay address", comment: "simplex link type")
}
}
}
+1
View File
@@ -49,6 +49,7 @@ This document provides a structured mapping between product-level concepts, thei
| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` |
| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) |
| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) |
---
+20 -1
View File
@@ -58,7 +58,7 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi
### 3. Prepared Contact/Group Flow (Short Links)
1. For short links with embedded profile data, the app uses a two-phase flow.
2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:groupShortLinkData:)` creates a local prepared chat.
2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` creates a local prepared chat. `directLink` is `true` for standard group links, `false` for channel relay links.
3. Returns `ChatData` with the prepared contact/group shown in UI before connecting.
4. User can switch profiles or set incognito before committing.
5. `apiConnectPreparedContact(contactId:incognito:msg:)` finalizes the connection.
@@ -101,6 +101,23 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi
6. User must accept each incoming contact request individually.
7. To delete: `apiDeleteUserAddress()` removes the address and associated SMP queues.
### 7a. Relay Link Rejection
1. User scans, pastes, or opens a relay address link (URL path `/r` or `SimplexLinkType.relay`).
2. In `ContentView.connectViaUrl_()`: early return with alert "Relay address" / "This is a chat relay address, it cannot be used to connect."
3. In `NewChatView.planAndConnect()`: `.simplexLink(_, .relay, _, _)` pattern triggers the same alert.
4. The link is NOT processed further. No connection is attempted.
### 7b. Channel Prepared Group Flow
1. When connecting to a channel link (`GroupShortLinkInfo.direct == false`):
2. `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` is called with `directLink: false`, preparing the channel locally.
3. `groupShortLinkInfo.groupRelays` (hostnames) stored in `ChatModel.shared.channelRelayHostnames[groupId]`.
4. Pre-join UI shows channel icon and "Open new channel" (not "Open new group").
5. `apiConnectPreparedGroup(groupId:incognito:msg:)` returns `(GroupInfo, [RelayConnectionResult])`.
6. `RelayConnectionResult` contains `relayMember: GroupMember` and optional `relayError: ChatError?` per relay.
7. Relay members are upserted to `chatModel.groupMembers`; `channelRelayHostnames` entry is cleared.
### 7. Incognito Connection
1. Before connecting, user toggles "Incognito" in the connection UI.
@@ -121,6 +138,8 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi
| `Contact` | `SimpleXChat/ChatTypes.swift` | Full contact model with profile, connection status, preferences |
| `UserContactRequest` | `SimpleXChat/ChatTypes.swift` | Incoming contact request awaiting acceptance |
| `ChatType` | `SimpleXChat/ChatTypes.swift` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` |
| `GroupShortLinkInfo` | `Shared/Model/AppAPITypes.swift` | Contains `direct: Bool`, `groupRelays: [String]`, `sharedGroupId: String?`; transient data returned by prepare |
| `RelayConnectionResult` | `Shared/Model/AppAPITypes.swift` | Contains `relayMember: GroupMember`, `relayError: ChatError?`; per-relay join outcome |
## Error Cases
+14 -2
View File
@@ -29,6 +29,18 @@ Complete group management in SimpleX Chat iOS: creating groups, inviting members
8. User is navigated to `AddGroupMembersView` to optionally invite contacts.
9. User can also create a group link at this stage.
### 1a. Create Public Group (Channel)
1. Alternative to standard group creation for relay-backed channels.
2. Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`:
```swift
func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])
```
3. Sends `ChatCommand.apiNewPublicGroup(userId:incognito:relayIds:groupProfile:)` to core.
4. Core returns `ChatResponse2.publicGroupCreated(user, groupInfo, groupLink, groupRelays)`.
5. The resulting `GroupInfo` has `useRelays == true` and includes a group link.
6. Channel relay members (with role `.relay`) are managed by the core.
### 2. Invite Members
1. From `GroupChatInfoView`, user taps "Add members" -> `AddGroupMembersView`.
@@ -46,7 +58,7 @@ Complete group management in SimpleX Chat iOS: creating groups, inviting members
1. User receives a group link (scanned or pasted).
2. `apiConnectPlan` validates the link and identifies it as a group link.
3. For prepared groups (short links): `apiPrepareGroup(connLink:groupShortLinkData:)` shows group info before joining.
3. For prepared groups (short links): `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` shows group info before joining. `directLink` is `true` for standard group links, `false` for channel relay links.
4. `apiConnectPreparedGroup(groupId:incognito:msg:)` or `apiConnect(incognito:connLink:)` initiates joining.
5. Core processes the join request. Depending on group admission settings:
- **Auto-join**: Member is added immediately.
@@ -173,7 +185,7 @@ Complete group management in SimpleX Chat iOS: creating groups, inviting members
| `GroupInfo` | `SimpleXChat/ChatTypes.swift` | Full group model: ID, profile, membership, preferences, business chat info |
| `GroupProfile` | `SimpleXChat/ChatTypes.swift` | Name, full name, image, description, preferences |
| `GroupMember` | `SimpleXChat/ChatTypes.swift` | Member model: role, status, profile, connection info |
| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer` |
| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer`, `.relay` |
| `GroupMemberStatus` | `SimpleXChat/ChatTypes.swift` | Member lifecycle: `.invited`, `.accepted`, `.connected`, `.complete`, etc. |
| `GroupLink` | `Shared/Model/AppAPITypes.swift` | Group link with URI, member role, and short link data |
| `BusinessChatInfo` | `SimpleXChat/ChatTypes.swift` | Business chat metadata for commercial group chats |
+3 -3
View File
@@ -29,7 +29,7 @@ Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, e
mentions: [:]
)
```
6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:)`.
6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:sendAsGroup:)` (where `sendAsGroup` defaults to `false`; set to `true` when a channel owner sends as the channel identity).
7. Internally dispatches `ChatCommand.apiSendMessages(...)` to the Haskell core.
8. Core encrypts, queues via SMP, and returns `ChatResponse1.newChatItems(user, aChatItems)`.
9. `processSendMessageCmd` extracts `[ChatItem]` from response.
@@ -104,7 +104,7 @@ Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, e
2. `ChatItemForwardingView` is presented for destination chat selection.
3. `apiPlanForwardChatItems(type:id:scope:itemIds:)` validates what can be forwarded, returns `([Int64], ForwardConfirmation?)`.
4. User confirms and selects destination chat.
5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:)`.
5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:sendAsGroup:)` (where `sendAsGroup` defaults to `false`).
6. Core returns `ChatResponse1.newChatItems(...)` with the forwarded items in the destination chat.
### 9. Voice Message
@@ -136,7 +136,7 @@ Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, e
| `MsgContent` | `SimpleXChat/ChatTypes.swift` | Enum: `.text`, `.link`, `.image`, `.video`, `.voice`, `.file` |
| `CIContent` | `SimpleXChat/ChatTypes.swift` | Chat item content wrapper with sent/received variants |
| `CIStatus` | `SimpleXChat/ChatTypes.swift` | Delivery status: sndNew, sndSent, sndError, rcvNew, rcvRead |
| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)` |
| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)`, `.channelRcv` |
| `ChatItem` | `SimpleXChat/ChatTypes.swift` | Full message model: content, meta, status, direction, quotedItem |
| `ChatItemDeletion` | `SimpleXChat/ChatTypes.swift` | Deleted item info with old/new item pairs |
| `CIDeleteMode` | `SimpleXChat/ChatTypes.swift` | `.cidmInternal` (local) or `.cidmBroadcast` (for everyone) |
+3
View File
@@ -59,3 +59,6 @@ While the double-ratchet protocol provides forward secrecy, there is no UI indic
The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages.hs`, etc.) are referenced by function name but not fully specified with parameter types and return types.
**REC:** Expand database spec with key Store function signatures as the specification matures.
---
+19 -1
View File
@@ -88,7 +88,7 @@ The lifecycle state of a Connection: ConnNew (created, awaiting join), ConnJoine
The status of a contact record: CSActive (normal), CSDeleted (deleted by contact), CSDeletedByUser (deleted by user). *See: `../../src/Simplex/Chat/Types.hs` (data ContactStatus)*
### GroupMemberRole
Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver. Roles determine permissions for sending messages, managing members, and moderating content. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole)*
Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver, GRRelay. Roles determine permissions for sending messages, managing members, and moderating content. The `.relay` role is below `.observer` and is used for relay members in channels. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole), `SimpleXChat/ChatTypes.swift` L2806*
### GroupMemberStatus
The lifecycle state of a group member: GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown, GSMemInvited, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator, GSMemPendingReview, GSMemPendingApproval. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMemberStatus)*
@@ -99,6 +99,24 @@ Represents an in-progress or completed file transfer. Variants: FTSnd (sending,
### ChatTag
A user-defined label for organizing conversations in the chat list. Each tag has a text label and optional emoji. Chats can have multiple tags, and the chat list can be filtered by tag. *See: `../../src/Simplex/Chat/Types.hs` (data ChatTag), `Shared/Views/ChatList/TagListView.swift`*
### Channel
A group that uses relay infrastructure for message delivery (`groupInfo.useRelays == true`). Channels decouple the message sender from direct group membership connections, routing messages through relay members instead. Channels display the `antenna.radiowaves.left.and.right` SF Symbol as their icon and render received messages with the group avatar and "channel" role label. *See: [spec/state.md](../spec/state.md) (Relay-Related Data Model), [spec/client/chat-view.md](../spec/client/chat-view.md) (Channel Message Rendering), `SimpleXChat/ChatTypes.swift` (GroupInfo.useRelays, GroupInfo.chatIconName)*
### RelayStatus
The lifecycle state of a relay member in a channel: `.rsNew` (created), `.rsInvited` (invitation sent), `.rsAccepted` (accepted by relay), `.rsActive` (fully operational). *See: `SimpleXChat/ChatTypes.swift` L2506*
### GroupRelay
A struct representing a relay instance for a group. Contains the relay's database ID (`groupRelayId`), associated group member ID, user chat relay ID, relay status, and optional relay link (per-group link for subscribers). *See: `SimpleXChat/ChatTypes.swift` L2555*
### UserChatRelay
A struct representing a user's chat relay configuration. Contains the relay's database ID (`chatRelayId`), SMP server address, name, domains, and flags for preset/tested/enabled/deleted status. *See: `SimpleXChat/ChatTypes.swift` L2513*
### GroupShortLinkInfo
Information about a group's short link including whether it's a direct link, associated relay hostnames, and shared group identifier. Transient data returned by `APIConnectPreparedGroup` — not persisted on GroupInfo. *See: `Shared/Model/AppAPITypes.swift` L1352*
### CIDirection.channelRcv
A chat item direction case for messages received via a channel relay, as opposed to `.groupRcv` for standard group messages. *See: `SimpleXChat/ChatTypes.swift` L3529*
---
## Commands & Events
+29
View File
@@ -106,6 +106,35 @@
---
## Channel Integrity
### RULE-19: Channel owner cannot leave own channel
**Rule:** A channel owner (`groupInfo.useRelays && groupInfo.isOwner`) who is the sole owner MUST NOT be able to leave the channel. The leave button is hidden in both swipe actions and context menu.
**Enforced by:** `ChatListNavLink.swift` (swipe/context menu guards), `GroupChatInfoView.swift` (leave button conditional).
**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) | [spec/client/chat-list.md](../spec/client/chat-list.md)
### RULE-20: Relay members cannot be removed
**Rule:** Members with role `.relay` MUST NOT be removable through the member info UI. The remove button is hidden for relay members.
**Enforced by:** `GroupMemberInfoView.swift` (`mem.memberRole != .relay` guard on remove button).
**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md)
### RULE-21: Relay links cannot be used to connect
**Rule:** SimpleX links with path `/r` (relay addresses) MUST be rejected when users attempt to connect. An explanatory alert is shown instead.
**Enforced by:** `ContentView.swift` (`connectViaUrl_` early return for `/r` path), `NewChatView.swift` (`planAndConnect` guard for `.simplexLink(_, .relay, _, _)`).
**Spec:** [spec/client/navigation.md](../spec/client/navigation.md)
### RULE-22: Channel subscribers default to observer role
**Rule:** Members joining a channel via its link MUST receive the `.observer` role. The initial role picker is hidden for channels.
**Enforced by:** `AddChannelView.swift` (`groupLinkMemberRole: .observer` hardcoded), `GroupLinkView.swift` (role picker hidden when `isChannel`).
**Spec:** [spec/api.md](../spec/api.md)
### RULE-23: Channels default to history enabled
**Rule:** Newly created channels MUST have message history enabled by default (`GroupPreference(enable: .on)`).
**Enforced by:** `AddChannelView.swift` (`createChannel()` sets history preference).
**Spec:** [spec/api.md](../spec/api.md)
---
## Call Integrity
### RULE-17: Call encryption key exchange
+17
View File
@@ -66,6 +66,23 @@ Each row rendered by `ChatPreviewView` inside `ChatListNavLink`:
| Incognito indicator | Shows when connected via incognito profile |
| Connection status | Shows connecting/pending state for incomplete connections |
### Channel Adaptations
When a group has `groupInfo.useRelays == true` (channel):
| Element | Channel behavior |
|---|---|
| Chat icon | Antenna icon (`antenna.radiowaves.left.and.right.circle.fill`) instead of group icon |
| Swipe "Leave" | Hidden for channel owners (`useRelays && isOwner`) |
| Context menu "Leave" | Hidden for channel owners |
| Delete alert | "Delete channel?" (not "Delete group?") |
| Leave alert title | "Leave channel?" (not "Leave group?") |
| Leave alert message | "You will stop receiving messages from this channel. Chat history will be preserved." |
### Relay URL Handling
When a relay address link (`/r` path) is opened via URL deep link, `ContentView.connectViaUrl_()` intercepts it and shows an alert: "Relay address" / "This is a chat relay address, it cannot be used to connect." The link is not processed further.
### Swipe Actions
- **Trailing swipe**: Mute/unmute, pin/unpin, tag management
+9
View File
@@ -102,6 +102,15 @@ Emoji reactions bar displayed below messages with reaction counts.
| Group mentions | `GroupMentionsView` autocomplete popup when typing `@` in groups |
| Profile picker | `ContextProfilePickerView` for choosing incognito/main profile |
### Channel Messages
In channel conversations (`groupInfo.useRelays == true`), received messages (`.channelRcv` direction) display with:
- The **channel icon** (`antenna.radiowaves.left.and.right`) instead of the standard group icon
- The **channel name** as sender, with "channel" as the role label
- The **group profile image** as the avatar (tapping opens group info, not member info)
- Consecutive channel messages are grouped without repeating the avatar
- Channel messages cannot be moderated per-member (no member identity)
### Member Support Chat (Groups)
For groups with member support enabled:
+97
View File
@@ -128,6 +128,101 @@ Shown when `developerTools` is enabled:
| `blockMemberAlert` / `unblockMemberAlert` | Block/unblock member actions |
| `blockForAllAlert` / `unblockForAllAlert` | Moderator block/unblock for all members |
## Channel Adaptations
When `groupInfo.useRelays == true`, the group info view adapts to channel semantics. All sections below describe differences from the standard group behavior above.
### Channel Info Layout
The top section splits into a channel-specific branch:
| Element | Owner | Non-owner |
|---|---|---|
| Channel link | NavigationLink "Channel link" to `GroupLinkView` | Inline QR code (`SimpleXLinkQRCode`) + "Share link" button (if `groupProfile.groupLink` exists) |
| Members | NavigationLink "Owners & subscribers" to `ChannelMembersView` | NavigationLink "Owners" to `ChannelMembersView` |
| Relays | NavigationLink "Chat relays" to `ChannelRelaysView` | NavigationLink "Chat relays" to `ChannelRelaysView` |
### Channel Action Bar
| Button | Channel behavior |
|---|---|
| Link button | Replaces "Add members" for channel owners; navigates to `GroupLinkView` |
| Add members | Hidden for channels |
### Hidden Sections for Channels
The following are hidden when `groupInfo.useRelays == true`:
- Group preferences button and footer
- Send receipts toggle
- Member list section (replaced by ChannelMembersView navigation)
- Non-admin block section (in GroupMemberInfoView)
### Channel Leave/Delete Rules
- Sole channel owner cannot leave (button hidden when `isOwner && no other owners`)
- "Leave group" -> "Leave channel"; "Delete group" -> "Delete channel"; "Edit group profile" -> "Edit channel profile"
- `deleteGroupAlert`: "Delete channel?" / "Channel will be deleted for all subscribers - this cannot be undone!" (current member) or "Channel will be deleted for you - this cannot be undone!" (non-current member)
- `leaveGroupAlert`: "Leave channel?" / "You will stop receiving messages from this channel. Chat history will be preserved."
- `showRemoveMemberAlert`: "Remove subscriber?" / "Subscriber will be removed from channel - this cannot be undone!"
### Channel Members View (`ChannelMembersView`)
New view accessible from channel info, showing:
| Section | Content | Visibility |
|---|---|---|
| Owners | Members with role >= `.owner`, plus current user if owner | Always |
| Subscribers | Members with role < `.owner` and != `.relay` | Owner only |
- Excludes `memLeft`, `memRemoved`, and current user from member list
- Each row: profile image, verified badge, name; taps navigate to `GroupMemberInfoView`
- Empty state: "No subscribers" when subscriber list is empty
### Channel Relays View (`ChannelRelaysView`)
New view accessible from channel info, showing relay members (role == `.relay`):
| Element | Description |
|---|---|
| Relay list | Filtered from `chatModel.groupMembers` by `.relay` role |
| Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) |
| Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter |
| Empty state | "No chat relays" |
| Footer | "Chat relays forward messages to channel subscribers." |
Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only.
### Channel Link View (`GroupLinkView` with `isChannel: true`)
| Change | Channel behavior |
|---|---|
| Title | "Channel link" (not "Group link") |
| Description | "Anybody will be able to join the channel" (omits "You won't lose members...") |
| Initial role picker | Hidden |
| Upgrade link button | Hidden |
| Delete link button | Hidden (channel link deletion only via channel deletion) |
| Short/full link toggle | Hidden |
| Share button | Shares directly (no upgrade-and-share alert) |
### Channel Member Info (`GroupMemberInfoView` adaptations)
| Change | Channel behavior |
|---|---|
| Section header | "Relay" / "Owner" / "Subscriber" (based on member role) instead of "Member" |
| Group label | "Channel" instead of "Group" / "Chat" |
| Action buttons | Hidden (message/audio/video/search) |
| Role change picker | Hidden |
| Verify code button | Hidden for relay members |
| Block section | Hidden for non-moderator users |
| Remove button | Hidden for relay members |
| "Remove member" label | "Remove subscriber" |
| "Block for all?" alert | "Block subscriber for all?" |
| "Unblock for all?" alert | "Unblock subscriber for all?" |
| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` |
| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button |
| Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." |
## Related Specs
- `spec/api.md` -- Group API commands (create, update, add/remove members, roles, links)
@@ -145,3 +240,5 @@ Shown when `developerTools` is enabled:
- `Shared/Views/Chat/Group/MemberAdmissionView.swift` -- Member admission policy settings
- `Shared/Views/Chat/Group/GroupMemberInfoView.swift` -- Individual member info and actions
- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention support in groups
- `Shared/Views/Chat/Group/ChannelMembersView.swift` -- Channel owners/subscribers list
- `Shared/Views/Chat/Group/ChannelRelaysView.swift` -- Channel relay status list
+51 -1
View File
@@ -79,16 +79,66 @@ Accessed via `NewChatMenuButton` dropdown:
| Connection in progress | Chat list shows pending connection entry |
| Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options |
## Create Channel (`AddChannelView`)
Accessed via `NewChatMenuButton` dropdown: "Create channel (BETA)" with antenna icon (`antenna.radiowaves.left.and.right.circle.fill`).
### Three-Step Channel Creation Wizard
| Step | View | Description |
|---|---|---|
| 1. Profile | `profileStepView()` | Channel name input with validation, optional profile image. "Configure relays" link navigates to `NetworkAndServers`. Warning footer if no relays enabled. |
| 2. Progress | `progressStepView(_:)` | Relay connection progress: circular indicator (active/total), expandable relay list with status indicators (green=active, orange=invited/accepted, red=new). Cancel button deletes channel. |
| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` showing the channel link for sharing. |
### Channel Creation Defaults
- History preference auto-enabled (`GroupPreference(enable: .on)`)
- Group link member role hardcoded to `.observer`
- Up to 3 random enabled relays selected from user's configured relays
### Channel Creation API
Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)` which returns `publicGroupCreated` response with group info, link, and relay list. On cancel, `apiDeleteChat` deletes the channel.
### Relay Validation
- `checkHasRelays()`: validates at least one enabled, non-deleted relay exists
- Warning footer: "Enable at least one chat relay in Network & Servers."
- `getEnabledRelays()`: filters enabled/non-deleted relays from user's server config
## Channel-Specific Connection Behavior
### Relay Link Blocking
When `planAndConnect` encounters a `.simplexLink(_, .relay, _, _)`, it shows a "Relay address" alert: "This is a chat relay address, it cannot be used to connect." Connection is blocked.
### Channel Prepare/Join Alerts
| Context | Channel behavior | Group behavior |
|---|---|---|
| Prepare alert icon | `antenna.radiowaves.left.and.right.circle.fill` | `person.2.circle.fill` |
| Prepare alert title | "Open new channel" | "Open new group" |
| Error text | "Error opening channel" | "Error opening group" |
| Own-link confirm | "This is your link for channel" with only "Open channel" + "Cancel" (no incognito/profile options) | Full incognito/profile selection |
| Known group alert | "Open channel" / "Open new channel" | "Open group" / "Open new group" |
### Pre-Join Relay Info
When preparing a channel link, `groupShortLinkInfo.groupRelays` (hostnames) are stored in `ChatModel.shared.channelRelayHostnames` for display in the subscriber relay bar before joining.
## Related Specs
- `spec/api.md` -- API commands: `APIAddContact`, `APIConnect`, `APICreateUserAddress`
- `spec/client/navigation.md` -- Navigation architecture for channel creation flow
- [Chat List](chat-list.md) -- Parent view that presents this sheet
- [Chat](chat.md) -- Navigated to after successful connection
## Source Files
- `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation
- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group)
- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group, create channel)
- `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display
- `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form
- `Shared/Views/NewChat/AddChannelView.swift` -- Channel creation wizard (3 steps)
- `Shared/Views/NewChat/AddContactLearnMore.swift` -- Info sheet explaining connection process
+30 -1
View File
@@ -47,7 +47,35 @@ All rows disabled when `chatModel.chatRunning != true`. Appearance row only show
| Show sent via proxy | Toggle to show proxy indicator on sent messages |
| Show subscription % | Toggle to show server subscription percentage |
Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift`
Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift`, `ChatRelayView.swift`
##### Chat Relays
Chat relays forward messages to channel subscribers. They appear in two locations:
- **Operator View** (`OperatorView`): "Chat relays" section lists relays for each operator with `ChatRelayViewLink` rows. Footer: "Chat relays forward messages in channels you create."
- **Your Servers** (`YourServersView` in `ProtocolServersView`): "Chat relays" section for non-operator relays. "Add server" dialog includes a "Chat relay" option.
Each relay is managed via `ChatRelayView`:
| Element | Preset relay | Custom relay |
|---|---|---|
| Name | Read-only display | Editable text field |
| Address | Read-only display | Editable text field (validates as `.simplexLink(_, .relay, _, _)`) |
| Test button | "Test relay" (shows "Not implemented" alert) | Same |
| Enable toggle | "Use for new channels" | Same |
| Delete | Not available | "Delete relay" button |
Adding a relay: `NewChatRelayView` form with name, address, test, and enable toggle. Back-button validates name/address and shows alerts for invalid input.
##### Server Warnings
`ServersWarningView` displays an orange exclamation triangle with warning text when `UserServersWarning.noChatRelays` is detected. Appears in:
- Network & Servers footer (`globalServersWarning`)
- Operator view footer
- Your servers footer
Server validation (`validateServers_`) now returns both errors and warnings.
#### Privacy & Security (`PrivacySettings`)
@@ -169,4 +197,5 @@ Key `UserDefaults` / `AppStorage` keys managed by settings:
- `Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift` -- Add new server
- `Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift` -- Scan server QR code
- `Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift` -- Server operator configuration
- `Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` -- Chat relay detail/edit/add views
- `Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift` -- Operator conditions display
+289 -280
View File
@@ -32,10 +32,10 @@ The iOS app communicates with the Haskell core exclusively through a command/res
5. Async events arrive separately via `chat_recv_msg_wait`, decoded as `ChatEvent`
**Source files**:
- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L14](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L647](../Shared/Model/AppAPITypes.swift#L649)), `ChatResponse1` ([L768](../Shared/Model/AppAPITypes.swift#L771)), `ChatResponse2` ([L907](../Shared/Model/AppAPITypes.swift#L911)), `ChatEvent` ([L1050](../Shared/Model/AppAPITypes.swift#L1055)) enums
- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult<R>` ([L26](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L63](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L695](../SimpleXChat/APITypes.swift#L699))
- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L117](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L230](../Shared/Model/SimpleXAPI.swift#L237))
- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L114](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L136](../SimpleXChat/API.swift#L137))
- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L15](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L657](../Shared/Model/AppAPITypes.swift#L657)), `ChatResponse1` ([L779](../Shared/Model/AppAPITypes.swift#L779)), `ChatResponse2` ([L919](../Shared/Model/AppAPITypes.swift#L919)), `ChatEvent` ([L1069](../Shared/Model/AppAPITypes.swift#L1069)) enums
- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult<R>` ([L27](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L65](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L699](../SimpleXChat/APITypes.swift#L699))
- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L121](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L237](../Shared/Model/SimpleXAPI.swift#L237))
- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L115](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L137](../SimpleXChat/API.swift#L137))
- `SimpleXChat/ChatTypes.swift` -- Data types used in commands/responses (User, Contact, GroupInfo, ChatItem, etc.)
- `../../src/Simplex/Chat/Controller.hs` -- Haskell controller (function `chat_send_cmd_retry`, `chat_recv_msg_wait`)
@@ -43,248 +43,252 @@ The iOS app communicates with the Haskell core exclusively through a command/res
## 2. Command Categories
The `ChatCommand` enum ([`AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area.
The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area.
### 2.1 User Management
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `showActiveUser` | -- | Get current active user | [L15](../Shared/Model/AppAPITypes.swift#L16) |
| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L16](../Shared/Model/AppAPITypes.swift#L17) |
| `listUsers` | -- | List all user profiles | [L17](../Shared/Model/AppAPITypes.swift#L18) |
| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L18](../Shared/Model/AppAPITypes.swift#L19) |
| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L23](../Shared/Model/AppAPITypes.swift#L24) |
| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L24](../Shared/Model/AppAPITypes.swift#L25) |
| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L25](../Shared/Model/AppAPITypes.swift#L26) |
| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L27) |
| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L27](../Shared/Model/AppAPITypes.swift#L28) |
| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L138](../Shared/Model/AppAPITypes.swift#L139) |
| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L19](../Shared/Model/AppAPITypes.swift#L20) |
| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L20](../Shared/Model/AppAPITypes.swift#L21) |
| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L22) |
| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L22](../Shared/Model/AppAPITypes.swift#L23) |
| `showActiveUser` | -- | Get current active user | [L16](../Shared/Model/AppAPITypes.swift#L16) |
| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L17](../Shared/Model/AppAPITypes.swift#L17) |
| `listUsers` | -- | List all user profiles | [L18](../Shared/Model/AppAPITypes.swift#L18) |
| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L19](../Shared/Model/AppAPITypes.swift#L19) |
| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L24](../Shared/Model/AppAPITypes.swift#L24) |
| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L25](../Shared/Model/AppAPITypes.swift#L25) |
| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L26) |
| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L27](../Shared/Model/AppAPITypes.swift#L27) |
| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L28](../Shared/Model/AppAPITypes.swift#L28) |
| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L141](../Shared/Model/AppAPITypes.swift#L141) |
| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L20](../Shared/Model/AppAPITypes.swift#L20) |
| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L21) |
| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L22](../Shared/Model/AppAPITypes.swift#L22) |
| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L23](../Shared/Model/AppAPITypes.swift#L23) |
### 2.2 Chat Lifecycle Control
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L28](../Shared/Model/AppAPITypes.swift#L29) |
| `checkChatRunning` | -- | Check if chat is running | [L29](../Shared/Model/AppAPITypes.swift#L30) |
| `apiStopChat` | -- | Stop chat engine | [L30](../Shared/Model/AppAPITypes.swift#L31) |
| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L31](../Shared/Model/AppAPITypes.swift#L32) |
| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L32](../Shared/Model/AppAPITypes.swift#L33) |
| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L33](../Shared/Model/AppAPITypes.swift#L34) |
| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L34](../Shared/Model/AppAPITypes.swift#L35) |
| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L29](../Shared/Model/AppAPITypes.swift#L29) |
| `checkChatRunning` | -- | Check if chat is running | [L30](../Shared/Model/AppAPITypes.swift#L30) |
| `apiStopChat` | -- | Stop chat engine | [L31](../Shared/Model/AppAPITypes.swift#L31) |
| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L32](../Shared/Model/AppAPITypes.swift#L32) |
| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L33](../Shared/Model/AppAPITypes.swift#L33) |
| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L34](../Shared/Model/AppAPITypes.swift#L34) |
| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L35](../Shared/Model/AppAPITypes.swift#L35) |
### 2.3 Chat & Message Operations
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L43](../Shared/Model/AppAPITypes.swift#L44) |
| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L44](../Shared/Model/AppAPITypes.swift#L45) |
| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L45](../Shared/Model/AppAPITypes.swift#L46) |
| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L46](../Shared/Model/AppAPITypes.swift#L47) |
| `apiSendMessages` | `type, id, scope, live, ttl, composedMessages` | Send one or more messages | [L47](../Shared/Model/AppAPITypes.swift#L48) |
| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L53](../Shared/Model/AppAPITypes.swift#L54) |
| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L55](../Shared/Model/AppAPITypes.swift#L56) |
| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L56](../Shared/Model/AppAPITypes.swift#L57) |
| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L57](../Shared/Model/AppAPITypes.swift#L58) |
| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L60](../Shared/Model/AppAPITypes.swift#L61) |
| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L61](../Shared/Model/AppAPITypes.swift#L62) |
| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L62](../Shared/Model/AppAPITypes.swift#L63) |
| `apiForwardChatItems` | `toChatType, toChatId, toScope, from..., itemIds, ttl` | Forward messages | [L63](../Shared/Model/AppAPITypes.swift#L64) |
| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L54](../Shared/Model/AppAPITypes.swift#L55) |
| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L163](../Shared/Model/AppAPITypes.swift#L164) |
| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L164](../Shared/Model/AppAPITypes.swift#L165) |
| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L165](../Shared/Model/AppAPITypes.swift#L166) |
| `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) |
| `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) |
| `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) |
### 2.4 Contact Management
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiAddContact` | `userId, incognito` | Create invitation link | [L123](../Shared/Model/AppAPITypes.swift#L124) |
| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L133](../Shared/Model/AppAPITypes.swift#L134) |
| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L126](../Shared/Model/AppAPITypes.swift#L127) |
| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L127](../Shared/Model/AppAPITypes.swift#L128) |
| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L131](../Shared/Model/AppAPITypes.swift#L132) |
| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L134](../Shared/Model/AppAPITypes.swift#L135) |
| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L151](../Shared/Model/AppAPITypes.swift#L152) |
| `apiRejectContact` | `contactReqId` | Reject contact request | [L152](../Shared/Model/AppAPITypes.swift#L153) |
| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L135](../Shared/Model/AppAPITypes.swift#L136) |
| `apiClearChat` | `type, id` | Clear conversation history | [L136](../Shared/Model/AppAPITypes.swift#L137) |
| `apiListContacts` | `userId` | List all contacts | [L137](../Shared/Model/AppAPITypes.swift#L138) |
| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L139](../Shared/Model/AppAPITypes.swift#L140) |
| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L140](../Shared/Model/AppAPITypes.swift#L141) |
| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L142](../Shared/Model/AppAPITypes.swift#L143) |
| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L109](../Shared/Model/AppAPITypes.swift#L110) |
| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L124](../Shared/Model/AppAPITypes.swift#L125) |
| `apiAddContact` | `userId, incognito` | Create invitation link | [L126](../Shared/Model/AppAPITypes.swift#L126) |
| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L136](../Shared/Model/AppAPITypes.swift#L136) |
| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L129](../Shared/Model/AppAPITypes.swift#L129) |
| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L130](../Shared/Model/AppAPITypes.swift#L130) |
| `apiPrepareGroup` | `userId, connLink, directLink, groupShortLinkData` | Prepare group from link; `directLink` (required, no default) indicates whether link is a direct (non-relay) group link | [L131](../Shared/Model/AppAPITypes.swift#L131) |
| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L134](../Shared/Model/AppAPITypes.swift#L134) |
| `apiConnectPreparedGroup` | `groupId, incognito, msg` | Connect to a prepared group/channel; returns `(GroupInfo, [RelayConnectionResult])?` | [L135](../Shared/Model/AppAPITypes.swift#L135) |
| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L137](../Shared/Model/AppAPITypes.swift#L137) |
| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L154](../Shared/Model/AppAPITypes.swift#L154) |
| `apiRejectContact` | `contactReqId` | Reject contact request | [L155](../Shared/Model/AppAPITypes.swift#L155) |
| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L138](../Shared/Model/AppAPITypes.swift#L138) |
| `apiClearChat` | `type, id` | Clear conversation history | [L139](../Shared/Model/AppAPITypes.swift#L139) |
| `apiListContacts` | `userId` | List all contacts | [L140](../Shared/Model/AppAPITypes.swift#L140) |
| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L142](../Shared/Model/AppAPITypes.swift#L142) |
| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L143](../Shared/Model/AppAPITypes.swift#L143) |
| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L145](../Shared/Model/AppAPITypes.swift#L145) |
| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L112](../Shared/Model/AppAPITypes.swift#L112) |
| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L127](../Shared/Model/AppAPITypes.swift#L127) |
### 2.5 Group Management
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L71](../Shared/Model/AppAPITypes.swift#L72) |
| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L72](../Shared/Model/AppAPITypes.swift#L73) |
| `apiJoinGroup` | `groupId` | Accept group invitation | [L73](../Shared/Model/AppAPITypes.swift#L74) |
| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L74](../Shared/Model/AppAPITypes.swift#L75) |
| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L78](../Shared/Model/AppAPITypes.swift#L79) |
| `apiLeaveGroup` | `groupId` | Leave group | [L79](../Shared/Model/AppAPITypes.swift#L80) |
| `apiListMembers` | `groupId` | List group members | [L80](../Shared/Model/AppAPITypes.swift#L81) |
| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L81](../Shared/Model/AppAPITypes.swift#L82) |
| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L76](../Shared/Model/AppAPITypes.swift#L77) |
| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L77](../Shared/Model/AppAPITypes.swift#L78) |
| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L82](../Shared/Model/AppAPITypes.swift#L83) |
| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L83](../Shared/Model/AppAPITypes.swift#L84) |
| `apiDeleteGroupLink` | `groupId` | Delete group link | [L84](../Shared/Model/AppAPITypes.swift#L85) |
| `apiGetGroupLink` | `groupId` | Get existing group link | [L85](../Shared/Model/AppAPITypes.swift#L86) |
| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L86](../Shared/Model/AppAPITypes.swift#L87) |
| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L87](../Shared/Model/AppAPITypes.swift#L88) |
| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L88](../Shared/Model/AppAPITypes.swift#L89) |
| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L110](../Shared/Model/AppAPITypes.swift#L111) |
| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L75](../Shared/Model/AppAPITypes.swift#L76) |
| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L108](../Shared/Model/AppAPITypes.swift#L109) |
| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L141](../Shared/Model/AppAPITypes.swift#L142) |
| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L72](../Shared/Model/AppAPITypes.swift#L72) |
| `apiNewPublicGroup` | `userId, incognito, relayIds, groupProfile` | Create new public group (channel) with chat relays | [L73](../Shared/Model/AppAPITypes.swift#L73) |
| `apiGetGroupRelays` | `groupId` | Get group relay list with status (owner only) | [L74](../Shared/Model/AppAPITypes.swift#L74) |
| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L75](../Shared/Model/AppAPITypes.swift#L75) |
| `apiJoinGroup` | `groupId` | Accept group invitation | [L76](../Shared/Model/AppAPITypes.swift#L76) |
| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L77](../Shared/Model/AppAPITypes.swift#L77) |
| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L81](../Shared/Model/AppAPITypes.swift#L81) |
| `apiLeaveGroup` | `groupId` | Leave group | [L82](../Shared/Model/AppAPITypes.swift#L82) |
| `apiListMembers` | `groupId` | List group members | [L83](../Shared/Model/AppAPITypes.swift#L83) |
| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L84](../Shared/Model/AppAPITypes.swift#L84) |
| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L79](../Shared/Model/AppAPITypes.swift#L79) |
| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L80](../Shared/Model/AppAPITypes.swift#L80) |
| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L85](../Shared/Model/AppAPITypes.swift#L85) |
| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L86](../Shared/Model/AppAPITypes.swift#L86) |
| `apiDeleteGroupLink` | `groupId` | Delete group link | [L87](../Shared/Model/AppAPITypes.swift#L87) |
| `apiGetGroupLink` | `groupId` | Get existing group link | [L88](../Shared/Model/AppAPITypes.swift#L88) |
| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L89](../Shared/Model/AppAPITypes.swift#L89) |
| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L90](../Shared/Model/AppAPITypes.swift#L90) |
| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L91](../Shared/Model/AppAPITypes.swift#L91) |
| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L113](../Shared/Model/AppAPITypes.swift#L113) |
| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L78](../Shared/Model/AppAPITypes.swift#L78) |
| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L111](../Shared/Model/AppAPITypes.swift#L111) |
| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L144](../Shared/Model/AppAPITypes.swift#L144) |
### 2.6 Chat Tags
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiGetChatTags` | `userId` | Get all user tags | [L42](../Shared/Model/AppAPITypes.swift#L43) |
| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L48](../Shared/Model/AppAPITypes.swift#L49) |
| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L49](../Shared/Model/AppAPITypes.swift#L50) |
| `apiDeleteChatTag` | `tagId` | Delete a tag | [L50](../Shared/Model/AppAPITypes.swift#L51) |
| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L51](../Shared/Model/AppAPITypes.swift#L52) |
| `apiReorderChatTags` | `tagIds` | Reorder tags | [L52](../Shared/Model/AppAPITypes.swift#L53) |
| `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) |
### 2.7 File Operations
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L166](../Shared/Model/AppAPITypes.swift#L167) |
| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L167](../Shared/Model/AppAPITypes.swift#L168) |
| `cancelFile` | `fileId` | Cancel file transfer | [L168](../Shared/Model/AppAPITypes.swift#L169) |
| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L178](../Shared/Model/AppAPITypes.swift#L179) |
| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L179](../Shared/Model/AppAPITypes.swift#L180) |
| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L180](../Shared/Model/AppAPITypes.swift#L181) |
| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L169](../Shared/Model/AppAPITypes.swift#L169) |
| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L170](../Shared/Model/AppAPITypes.swift#L170) |
| `cancelFile` | `fileId` | Cancel file transfer | [L171](../Shared/Model/AppAPITypes.swift#L171) |
| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L181](../Shared/Model/AppAPITypes.swift#L181) |
| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L182](../Shared/Model/AppAPITypes.swift#L182) |
| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L183](../Shared/Model/AppAPITypes.swift#L183) |
### 2.8 WebRTC Call Operations
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L154](../Shared/Model/AppAPITypes.swift#L155) |
| `apiRejectCall` | `contact` | Reject incoming call | [L155](../Shared/Model/AppAPITypes.swift#L156) |
| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L156](../Shared/Model/AppAPITypes.swift#L157) |
| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L157](../Shared/Model/AppAPITypes.swift#L158) |
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L158](../Shared/Model/AppAPITypes.swift#L159) |
| `apiEndCall` | `contact` | End active call | [L159](../Shared/Model/AppAPITypes.swift#L160) |
| `apiGetCallInvitations` | -- | Get pending call invitations | [L160](../Shared/Model/AppAPITypes.swift#L161) |
| `apiCallStatus` | `contact, callStatus` | Report call status change | [L161](../Shared/Model/AppAPITypes.swift#L162) |
| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L157](../Shared/Model/AppAPITypes.swift#L157) |
| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) |
| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) |
| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) |
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) |
| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) |
| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) |
| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) |
### 2.9 Push Notifications
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiGetNtfToken` | -- | Get current notification token | [L64](../Shared/Model/AppAPITypes.swift#L65) |
| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L65](../Shared/Model/AppAPITypes.swift#L66) |
| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L66](../Shared/Model/AppAPITypes.swift#L67) |
| `apiCheckToken` | `token` | Check token status | [L67](../Shared/Model/AppAPITypes.swift#L68) |
| `apiDeleteToken` | `token` | Unregister token | [L68](../Shared/Model/AppAPITypes.swift#L69) |
| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L69](../Shared/Model/AppAPITypes.swift#L70) |
| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L71) |
| `apiGetNtfToken` | -- | Get current notification token | [L65](../Shared/Model/AppAPITypes.swift#L65) |
| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L66](../Shared/Model/AppAPITypes.swift#L66) |
| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L67](../Shared/Model/AppAPITypes.swift#L67) |
| `apiCheckToken` | `token` | Check token status | [L68](../Shared/Model/AppAPITypes.swift#L68) |
| `apiDeleteToken` | `token` | Unregister token | [L69](../Shared/Model/AppAPITypes.swift#L69) |
| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L70) |
| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L71](../Shared/Model/AppAPITypes.swift#L71) |
### 2.10 Settings & Configuration
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L40](../Shared/Model/AppAPITypes.swift#L41) |
| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L41](../Shared/Model/AppAPITypes.swift#L42) |
| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L107](../Shared/Model/AppAPITypes.swift#L108) |
| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L99](../Shared/Model/AppAPITypes.swift#L100) |
| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L100](../Shared/Model/AppAPITypes.swift#L101) |
| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L101](../Shared/Model/AppAPITypes.swift#L102) |
| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L102](../Shared/Model/AppAPITypes.swift#L103) |
| `apiGetNetworkConfig` | -- | Get network configuration | [L103](../Shared/Model/AppAPITypes.swift#L104) |
| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L104](../Shared/Model/AppAPITypes.swift#L105) |
| `reconnectAllServers` | -- | Force reconnect all servers | [L105](../Shared/Model/AppAPITypes.swift#L106) |
| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L106](../Shared/Model/AppAPITypes.swift#L107) |
| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L41](../Shared/Model/AppAPITypes.swift#L41) |
| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L42](../Shared/Model/AppAPITypes.swift#L42) |
| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L110](../Shared/Model/AppAPITypes.swift#L110) |
| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L102](../Shared/Model/AppAPITypes.swift#L102) |
| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L103](../Shared/Model/AppAPITypes.swift#L103) |
| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L104](../Shared/Model/AppAPITypes.swift#L104) |
| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L105](../Shared/Model/AppAPITypes.swift#L105) |
| `apiGetNetworkConfig` | -- | Get network configuration | [L106](../Shared/Model/AppAPITypes.swift#L106) |
| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L107](../Shared/Model/AppAPITypes.swift#L107) |
| `reconnectAllServers` | -- | Force reconnect all servers | [L108](../Shared/Model/AppAPITypes.swift#L108) |
| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L109](../Shared/Model/AppAPITypes.swift#L109) |
### 2.11 Database & Storage
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L38](../Shared/Model/AppAPITypes.swift#L39) |
| `testStorageEncryption` | `key: String` | Test encryption key | [L39](../Shared/Model/AppAPITypes.swift#L40) |
| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L35](../Shared/Model/AppAPITypes.swift#L36) |
| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L36](../Shared/Model/AppAPITypes.swift#L37) |
| `apiDeleteStorage` | -- | Delete all storage | [L37](../Shared/Model/AppAPITypes.swift#L38) |
| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L39](../Shared/Model/AppAPITypes.swift#L39) |
| `testStorageEncryption` | `key: String` | Test encryption key | [L40](../Shared/Model/AppAPITypes.swift#L40) |
| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L36](../Shared/Model/AppAPITypes.swift#L36) |
| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L37](../Shared/Model/AppAPITypes.swift#L37) |
| `apiDeleteStorage` | -- | Delete all storage | [L38](../Shared/Model/AppAPITypes.swift#L38) |
### 2.12 Server Operations
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiGetServerOperators` | -- | Get server operators | [L91](../Shared/Model/AppAPITypes.swift#L92) |
| `apiSetServerOperators` | `operators` | Set server operators | [L92](../Shared/Model/AppAPITypes.swift#L93) |
| `apiGetUserServers` | `userId` | Get user's configured servers | [L93](../Shared/Model/AppAPITypes.swift#L94) |
| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L94](../Shared/Model/AppAPITypes.swift#L95) |
| `apiValidateServers` | `userId, userServers` | Validate server configuration | [L95](../Shared/Model/AppAPITypes.swift#L96) |
| `apiGetUsageConditions` | -- | Get usage conditions | [L96](../Shared/Model/AppAPITypes.swift#L97) |
| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L98](../Shared/Model/AppAPITypes.swift#L99) |
| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L90](../Shared/Model/AppAPITypes.swift#L91) |
| `apiGetServerOperators` | -- | Get server operators | [L94](../Shared/Model/AppAPITypes.swift#L94) |
| `apiSetServerOperators` | `operators` | Set server operators | [L95](../Shared/Model/AppAPITypes.swift#L95) |
| `apiGetUserServers` | `userId` | Get user's configured servers | [L96](../Shared/Model/AppAPITypes.swift#L96) |
| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L97](../Shared/Model/AppAPITypes.swift#L97) |
| `apiValidateServers` | `userId, userServers` | Validate server configuration; returns errors and warnings | [L98](../Shared/Model/AppAPITypes.swift#L98) |
| `apiGetUsageConditions` | -- | Get usage conditions | [L99](../Shared/Model/AppAPITypes.swift#L99) |
| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L101](../Shared/Model/AppAPITypes.swift#L101) |
| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L93](../Shared/Model/AppAPITypes.swift#L93) |
### 2.13 Theme & UI
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L143](../Shared/Model/AppAPITypes.swift#L144) |
| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L144](../Shared/Model/AppAPITypes.swift#L145) |
| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L146](../Shared/Model/AppAPITypes.swift#L146) |
| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L147](../Shared/Model/AppAPITypes.swift#L147) |
### 2.14 Remote Desktop
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L170](../Shared/Model/AppAPITypes.swift#L171) |
| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L171](../Shared/Model/AppAPITypes.swift#L172) |
| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L172](../Shared/Model/AppAPITypes.swift#L173) |
| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L173](../Shared/Model/AppAPITypes.swift#L174) |
| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L174](../Shared/Model/AppAPITypes.swift#L175) |
| `listRemoteCtrls` | -- | List known remote controllers | [L175](../Shared/Model/AppAPITypes.swift#L176) |
| `stopRemoteCtrl` | -- | Stop remote session | [L176](../Shared/Model/AppAPITypes.swift#L177) |
| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L177](../Shared/Model/AppAPITypes.swift#L178) |
| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L173](../Shared/Model/AppAPITypes.swift#L173) |
| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L174](../Shared/Model/AppAPITypes.swift#L174) |
| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L175](../Shared/Model/AppAPITypes.swift#L175) |
| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L176](../Shared/Model/AppAPITypes.swift#L176) |
| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L177](../Shared/Model/AppAPITypes.swift#L177) |
| `listRemoteCtrls` | -- | List known remote controllers | [L178](../Shared/Model/AppAPITypes.swift#L178) |
| `stopRemoteCtrl` | -- | Stop remote session | [L179](../Shared/Model/AppAPITypes.swift#L179) |
| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L180](../Shared/Model/AppAPITypes.swift#L180) |
### 2.15 Diagnostics
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `showVersion` | -- | Get core version info | [L182](../Shared/Model/AppAPITypes.swift#L183) |
| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L183](../Shared/Model/AppAPITypes.swift#L184) |
| `getAgentServersSummary` | `userId` | Get server summary stats | [L184](../Shared/Model/AppAPITypes.swift#L185) |
| `resetAgentServersStats` | -- | Reset server statistics | [L185](../Shared/Model/AppAPITypes.swift#L186) |
| `showVersion` | -- | Get core version info | [L185](../Shared/Model/AppAPITypes.swift#L185) |
| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L186](../Shared/Model/AppAPITypes.swift#L186) |
| `getAgentServersSummary` | `userId` | Get server summary stats | [L187](../Shared/Model/AppAPITypes.swift#L187) |
| `resetAgentServersStats` | -- | Reset server statistics | [L188](../Shared/Model/AppAPITypes.swift#L188) |
### 2.16 Address Management
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L145](../Shared/Model/AppAPITypes.swift#L146) |
| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L146](../Shared/Model/AppAPITypes.swift#L147) |
| `apiShowMyAddress` | `userId` | Show current address | [L147](../Shared/Model/AppAPITypes.swift#L148) |
| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L148](../Shared/Model/AppAPITypes.swift#L149) |
| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L149](../Shared/Model/AppAPITypes.swift#L150) |
| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L150](../Shared/Model/AppAPITypes.swift#L151) |
| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L148](../Shared/Model/AppAPITypes.swift#L148) |
| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L149](../Shared/Model/AppAPITypes.swift#L149) |
| `apiShowMyAddress` | `userId` | Show current address | [L150](../Shared/Model/AppAPITypes.swift#L150) |
| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L151](../Shared/Model/AppAPITypes.swift#L151) |
| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L152](../Shared/Model/AppAPITypes.swift#L152) |
| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L153](../Shared/Model/AppAPITypes.swift#L153) |
### 2.17 Connection Security
| Command | Parameters | Description | Source |
|---------|-----------|-------------|--------|
| `apiGetContactCode` | `contactId` | Get verification code | [L119](../Shared/Model/AppAPITypes.swift#L120) |
| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L120](../Shared/Model/AppAPITypes.swift#L121) |
| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L121](../Shared/Model/AppAPITypes.swift#L122) |
| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L122](../Shared/Model/AppAPITypes.swift#L123) |
| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L113](../Shared/Model/AppAPITypes.swift#L114) |
| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L114](../Shared/Model/AppAPITypes.swift#L115) |
| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L115](../Shared/Model/AppAPITypes.swift#L116) |
| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L116](../Shared/Model/AppAPITypes.swift#L117) |
| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L117](../Shared/Model/AppAPITypes.swift#L118) |
| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L118](../Shared/Model/AppAPITypes.swift#L119) |
| `apiGetContactCode` | `contactId` | Get verification code | [L122](../Shared/Model/AppAPITypes.swift#L122) |
| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L123](../Shared/Model/AppAPITypes.swift#L123) |
| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L124](../Shared/Model/AppAPITypes.swift#L124) |
| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L125](../Shared/Model/AppAPITypes.swift#L125) |
| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L116](../Shared/Model/AppAPITypes.swift#L116) |
| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L117](../Shared/Model/AppAPITypes.swift#L117) |
| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L118](../Shared/Model/AppAPITypes.swift#L118) |
| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L119](../Shared/Model/AppAPITypes.swift#L119) |
| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L120](../Shared/Model/AppAPITypes.swift#L120) |
| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L121](../Shared/Model/AppAPITypes.swift#L121) |
---
@@ -294,173 +298,178 @@ Responses are split across three enums due to Swift enum size limitations:
### ChatResponse0
Synchronous query responses ([`AppAPITypes.swift` L647](../Shared/Model/AppAPITypes.swift#L649)):
Synchronous query responses ([`AppAPITypes.swift` L657](../Shared/Model/AppAPITypes.swift#L657)):
| Response | Key Fields | Description | Source |
|----------|-----------|-------------|--------|
| `activeUser` | `user: User` | Current active user | [L648](../Shared/Model/AppAPITypes.swift#L650) |
| `usersList` | `users: [UserInfo]` | All user profiles | [L649](../Shared/Model/AppAPITypes.swift#L651) |
| `chatStarted` | -- | Chat engine started | [L650](../Shared/Model/AppAPITypes.swift#L652) |
| `chatRunning` | -- | Chat is already running | [L651](../Shared/Model/AppAPITypes.swift#L653) |
| `chatStopped` | -- | Chat engine stopped | [L652](../Shared/Model/AppAPITypes.swift#L654) |
| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L653](../Shared/Model/AppAPITypes.swift#L655) |
| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L654](../Shared/Model/AppAPITypes.swift#L656) |
| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L656](../Shared/Model/AppAPITypes.swift#L658) |
| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L657](../Shared/Model/AppAPITypes.swift#L659) |
| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L658](../Shared/Model/AppAPITypes.swift#L660) |
| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L664](../Shared/Model/AppAPITypes.swift#L666) |
| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L665](../Shared/Model/AppAPITypes.swift#L667) |
| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L666](../Shared/Model/AppAPITypes.swift#L668) |
| `connectionVerified` | `verified, expectedCode` | Verification result | [L676](../Shared/Model/AppAPITypes.swift#L678) |
| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L677](../Shared/Model/AppAPITypes.swift#L679) |
| `activeUser` | `user: User` | Current active user | [L658](../Shared/Model/AppAPITypes.swift#L658) |
| `usersList` | `users: [UserInfo]` | All user profiles | [L659](../Shared/Model/AppAPITypes.swift#L659) |
| `chatStarted` | -- | Chat engine started | [L660](../Shared/Model/AppAPITypes.swift#L660) |
| `chatRunning` | -- | Chat is already running | [L661](../Shared/Model/AppAPITypes.swift#L661) |
| `chatStopped` | -- | Chat engine stopped | [L662](../Shared/Model/AppAPITypes.swift#L662) |
| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L663](../Shared/Model/AppAPITypes.swift#L663) |
| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L664](../Shared/Model/AppAPITypes.swift#L664) |
| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L666](../Shared/Model/AppAPITypes.swift#L666) |
| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L667](../Shared/Model/AppAPITypes.swift#L667) |
| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L668](../Shared/Model/AppAPITypes.swift#L668) |
| `userServersValidation` | `user, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]` | Server validation result with errors and warnings | [L671](../Shared/Model/AppAPITypes.swift#L671) |
| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L674](../Shared/Model/AppAPITypes.swift#L674) |
| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L675](../Shared/Model/AppAPITypes.swift#L675) |
| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L676](../Shared/Model/AppAPITypes.swift#L676) |
| `connectionVerified` | `verified, expectedCode` | Verification result | [L686](../Shared/Model/AppAPITypes.swift#L686) |
| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L687](../Shared/Model/AppAPITypes.swift#L687) |
### ChatResponse1
Contact, message, and profile responses ([`AppAPITypes.swift` L768](../Shared/Model/AppAPITypes.swift#L771)):
Contact, message, and profile responses ([`AppAPITypes.swift` L779](../Shared/Model/AppAPITypes.swift#L779)):
| Response | Key Fields | Description | Source |
|----------|-----------|-------------|--------|
| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L769](../Shared/Model/AppAPITypes.swift#L772) |
| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L772](../Shared/Model/AppAPITypes.swift#L775) |
| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L773](../Shared/Model/AppAPITypes.swift#L776) |
| `contactDeleted` | `user, contact` | Contact deleted | [L782](../Shared/Model/AppAPITypes.swift#L785) |
| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L800](../Shared/Model/AppAPITypes.swift#L803) |
| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L803](../Shared/Model/AppAPITypes.swift#L806) |
| `chatItemReaction` | `user, added, reaction` | Reaction change | [L805](../Shared/Model/AppAPITypes.swift#L808) |
| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L807](../Shared/Model/AppAPITypes.swift#L810) |
| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L808](../Shared/Model/AppAPITypes.swift#L811) |
| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L788](../Shared/Model/AppAPITypes.swift#L791) |
| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L796](../Shared/Model/AppAPITypes.swift#L799) |
| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L802](../Shared/Model/AppAPITypes.swift#L805) |
| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L801](../Shared/Model/AppAPITypes.swift#L804) |
| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L780](../Shared/Model/AppAPITypes.swift#L780) |
| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L783](../Shared/Model/AppAPITypes.swift#L783) |
| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L784](../Shared/Model/AppAPITypes.swift#L784) |
| `startedConnectionToGroup` | `user, groupInfo, relayResults: [RelayConnectionResult]` | Group/channel join initiated; relay results indicate per-relay connection success/failure | [L790](../Shared/Model/AppAPITypes.swift#L790) |
| `contactDeleted` | `user, contact` | Contact deleted | [L793](../Shared/Model/AppAPITypes.swift#L793) |
| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L811](../Shared/Model/AppAPITypes.swift#L811) |
| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L814](../Shared/Model/AppAPITypes.swift#L814) |
| `chatItemReaction` | `user, added, reaction` | Reaction change | [L816](../Shared/Model/AppAPITypes.swift#L816) |
| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L818](../Shared/Model/AppAPITypes.swift#L818) |
| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L819](../Shared/Model/AppAPITypes.swift#L819) |
| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L799](../Shared/Model/AppAPITypes.swift#L799) |
| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L807](../Shared/Model/AppAPITypes.swift#L807) |
| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L813](../Shared/Model/AppAPITypes.swift#L813) |
| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L812](../Shared/Model/AppAPITypes.swift#L812) |
### ChatResponse2
Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L907](../Shared/Model/AppAPITypes.swift#L911)):
Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L919](../Shared/Model/AppAPITypes.swift#L919)):
| Response | Key Fields | Description | Source |
|----------|-----------|-------------|--------|
| `groupCreated` | `user, groupInfo` | New group created | [L909](../Shared/Model/AppAPITypes.swift#L913) |
| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L910](../Shared/Model/AppAPITypes.swift#L914) |
| `groupMembers` | `user, group: Group` | Group member list | [L914](../Shared/Model/AppAPITypes.swift#L918) |
| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L918](../Shared/Model/AppAPITypes.swift#L922) |
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L920](../Shared/Model/AppAPITypes.swift#L924) |
| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L921](../Shared/Model/AppAPITypes.swift#L925) |
| `rcvFileAccepted` | `user, chatItem` | File download started | [L928](../Shared/Model/AppAPITypes.swift#L932) |
| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L937](../Shared/Model/AppAPITypes.swift#L941) |
| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L940](../Shared/Model/AppAPITypes.swift#L944) |
| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L948](../Shared/Model/AppAPITypes.swift#L952) |
| `cmdOk` | `user_` | Generic success | [L949](../Shared/Model/AppAPITypes.swift#L953) |
| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L953](../Shared/Model/AppAPITypes.swift#L957) |
| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L954](../Shared/Model/AppAPITypes.swift#L958) |
| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L955](../Shared/Model/AppAPITypes.swift#L959) |
| `groupCreated` | `user, groupInfo` | New group created | [L921](../Shared/Model/AppAPITypes.swift#L921) |
| `publicGroupCreated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | New public group (channel) created with relay info | [L922](../Shared/Model/AppAPITypes.swift#L922) |
| `groupRelays` | `user, groupInfo, groupRelays: [GroupRelay]` | Group relay list (owner API response) | [L923](../Shared/Model/AppAPITypes.swift#L923) |
| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L924](../Shared/Model/AppAPITypes.swift#L924) |
| `groupMembers` | `user, group: Group` | Group member list | [L928](../Shared/Model/AppAPITypes.swift#L928) |
| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L932](../Shared/Model/AppAPITypes.swift#L932) |
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L934](../Shared/Model/AppAPITypes.swift#L934) |
| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L935](../Shared/Model/AppAPITypes.swift#L935) |
| `rcvFileAccepted` | `user, chatItem` | File download started | [L942](../Shared/Model/AppAPITypes.swift#L942) |
| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L951](../Shared/Model/AppAPITypes.swift#L951) |
| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L954](../Shared/Model/AppAPITypes.swift#L954) |
| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L962](../Shared/Model/AppAPITypes.swift#L962) |
| `cmdOk` | `user_` | Generic success | [L963](../Shared/Model/AppAPITypes.swift#L963) |
| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L967](../Shared/Model/AppAPITypes.swift#L967) |
| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L968](../Shared/Model/AppAPITypes.swift#L968) |
| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L969](../Shared/Model/AppAPITypes.swift#L969) |
---
## 4. Event Types
The `ChatEvent` enum ([`AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands.
The `ChatEvent` enum ([`AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands.
Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266) in `SimpleXAPI.swift`.
Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2282) in `SimpleXAPI.swift`.
### Connection Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1057](../Shared/Model/AppAPITypes.swift#L1062) |
| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1058](../Shared/Model/AppAPITypes.swift#L1063) |
| `contactSndReady` | `user, contact` | Ready to send to contact | [L1059](../Shared/Model/AppAPITypes.swift#L1064) |
| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1056](../Shared/Model/AppAPITypes.swift#L1061) |
| `contactUpdated` | `user, toContact` | Contact profile updated | [L1061](../Shared/Model/AppAPITypes.swift#L1066) |
| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1060](../Shared/Model/AppAPITypes.swift#L1065) |
| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1063](../Shared/Model/AppAPITypes.swift#L1068) |
| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1076](../Shared/Model/AppAPITypes.swift#L1076) |
| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1077](../Shared/Model/AppAPITypes.swift#L1077) |
| `contactSndReady` | `user, contact` | Ready to send to contact | [L1078](../Shared/Model/AppAPITypes.swift#L1078) |
| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1075](../Shared/Model/AppAPITypes.swift#L1075) |
| `contactUpdated` | `user, toContact` | Contact profile updated | [L1080](../Shared/Model/AppAPITypes.swift#L1080) |
| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1079](../Shared/Model/AppAPITypes.swift#L1079) |
| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1082](../Shared/Model/AppAPITypes.swift#L1082) |
### Message Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1065](../Shared/Model/AppAPITypes.swift#L1070) |
| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1067](../Shared/Model/AppAPITypes.swift#L1072) |
| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1068](../Shared/Model/AppAPITypes.swift#L1073) |
| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1069](../Shared/Model/AppAPITypes.swift#L1074) |
| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1066](../Shared/Model/AppAPITypes.swift#L1071) |
| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1071](../Shared/Model/AppAPITypes.swift#L1076) |
| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1064](../Shared/Model/AppAPITypes.swift#L1069) |
| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1084](../Shared/Model/AppAPITypes.swift#L1084) |
| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1086](../Shared/Model/AppAPITypes.swift#L1086) |
| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1087](../Shared/Model/AppAPITypes.swift#L1087) |
| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1088](../Shared/Model/AppAPITypes.swift#L1088) |
| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1085](../Shared/Model/AppAPITypes.swift#L1085) |
| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1090](../Shared/Model/AppAPITypes.swift#L1090) |
| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1083](../Shared/Model/AppAPITypes.swift#L1083) |
### Group Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1072](../Shared/Model/AppAPITypes.swift#L1077) |
| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1073](../Shared/Model/AppAPITypes.swift#L1078) |
| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1074](../Shared/Model/AppAPITypes.swift#L1079) |
| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1076](../Shared/Model/AppAPITypes.swift#L1081) |
| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1078](../Shared/Model/AppAPITypes.swift#L1083) |
| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1079](../Shared/Model/AppAPITypes.swift#L1084) |
| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1080](../Shared/Model/AppAPITypes.swift#L1085) |
| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1081](../Shared/Model/AppAPITypes.swift#L1086) |
| `leftMember` | `user, groupInfo, member` | Member left | [L1082](../Shared/Model/AppAPITypes.swift#L1087) |
| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1083](../Shared/Model/AppAPITypes.swift#L1088) |
| `userJoinedGroup` | `user, groupInfo` | Successfully joined | [L1084](../Shared/Model/AppAPITypes.swift#L1089) |
| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1085](../Shared/Model/AppAPITypes.swift#L1090) |
| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1086](../Shared/Model/AppAPITypes.swift#L1091) |
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1087](../Shared/Model/AppAPITypes.swift#L1092) |
| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1062](../Shared/Model/AppAPITypes.swift#L1067) |
| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1091](../Shared/Model/AppAPITypes.swift#L1091) |
| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1092](../Shared/Model/AppAPITypes.swift#L1092) |
| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1093](../Shared/Model/AppAPITypes.swift#L1093) |
| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1095](../Shared/Model/AppAPITypes.swift#L1095) |
| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1097](../Shared/Model/AppAPITypes.swift#L1097) |
| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1098](../Shared/Model/AppAPITypes.swift#L1098) |
| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1099](../Shared/Model/AppAPITypes.swift#L1099) |
| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1100](../Shared/Model/AppAPITypes.swift#L1100) |
| `leftMember` | `user, groupInfo, member` | Member left | [L1101](../Shared/Model/AppAPITypes.swift#L1101) |
| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1102](../Shared/Model/AppAPITypes.swift#L1102) |
| `userJoinedGroup` | `user, groupInfo, hostMember` | Successfully joined; `hostMember` is upserted into group members | [L1103](../Shared/Model/AppAPITypes.swift#L1103) |
| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1104](../Shared/Model/AppAPITypes.swift#L1104) |
| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1105](../Shared/Model/AppAPITypes.swift#L1105) |
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) |
| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) |
| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) |
### File Transfer Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `rcvFileStart` | `user, chatItem` | Download started | [L1092](../Shared/Model/AppAPITypes.swift#L1097) |
| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1093](../Shared/Model/AppAPITypes.swift#L1098) |
| `rcvFileComplete` | `user, chatItem` | Download complete | [L1094](../Shared/Model/AppAPITypes.swift#L1099) |
| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1096](../Shared/Model/AppAPITypes.swift#L1101) |
| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1097](../Shared/Model/AppAPITypes.swift#L1102) |
| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1100](../Shared/Model/AppAPITypes.swift#L1105) |
| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1101](../Shared/Model/AppAPITypes.swift#L1106) |
| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1103](../Shared/Model/AppAPITypes.swift#L1108) |
| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1105](../Shared/Model/AppAPITypes.swift#L1110) |
| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1107](../Shared/Model/AppAPITypes.swift#L1112) |
| `rcvFileStart` | `user, chatItem` | Download started | [L1112](../Shared/Model/AppAPITypes.swift#L1112) |
| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1113](../Shared/Model/AppAPITypes.swift#L1113) |
| `rcvFileComplete` | `user, chatItem` | Download complete | [L1114](../Shared/Model/AppAPITypes.swift#L1114) |
| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1116](../Shared/Model/AppAPITypes.swift#L1116) |
| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1117](../Shared/Model/AppAPITypes.swift#L1117) |
| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1120](../Shared/Model/AppAPITypes.swift#L1120) |
| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1121](../Shared/Model/AppAPITypes.swift#L1121) |
| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1123](../Shared/Model/AppAPITypes.swift#L1123) |
| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1125](../Shared/Model/AppAPITypes.swift#L1125) |
| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1127](../Shared/Model/AppAPITypes.swift#L1127) |
### Call Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1110](../Shared/Model/AppAPITypes.swift#L1115) |
| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1111](../Shared/Model/AppAPITypes.swift#L1116) |
| `callAnswer` | `user, contact, answer` | SDP answer received | [L1112](../Shared/Model/AppAPITypes.swift#L1117) |
| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1113](../Shared/Model/AppAPITypes.swift#L1118) |
| `callEnded` | `user, contact` | Call ended by remote | [L1114](../Shared/Model/AppAPITypes.swift#L1119) |
| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1130](../Shared/Model/AppAPITypes.swift#L1130) |
| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1131](../Shared/Model/AppAPITypes.swift#L1131) |
| `callAnswer` | `user, contact, answer` | SDP answer received | [L1132](../Shared/Model/AppAPITypes.swift#L1132) |
| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1133](../Shared/Model/AppAPITypes.swift#L1133) |
| `callEnded` | `user, contact` | Call ended by remote | [L1134](../Shared/Model/AppAPITypes.swift#L1134) |
### Connection Security Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1052](../Shared/Model/AppAPITypes.swift#L1057) |
| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1053](../Shared/Model/AppAPITypes.swift#L1058) |
| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1054](../Shared/Model/AppAPITypes.swift#L1059) |
| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1055](../Shared/Model/AppAPITypes.swift#L1060) |
| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1071](../Shared/Model/AppAPITypes.swift#L1071) |
| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1072](../Shared/Model/AppAPITypes.swift#L1072) |
| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1073](../Shared/Model/AppAPITypes.swift#L1073) |
| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1074](../Shared/Model/AppAPITypes.swift#L1074) |
### System Events
| Event | Key Fields | Description | Source |
|-------|-----------|-------------|--------|
| `chatSuspended` | -- | Core suspended | [L1051](../Shared/Model/AppAPITypes.swift#L1056) |
| `chatSuspended` | -- | Core suspended | [L1070](../Shared/Model/AppAPITypes.swift#L1070) |
---
## 5. Error Types
Defined in [`SimpleXChat/APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699):
Defined in [`SimpleXChat/APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699):
```swift
public enum ChatError: Decodable, Hashable {
public enum ChatError: Decodable, Hashable, Error {
case error(errorType: ChatErrorType)
case errorAgent(agentError: AgentErrorType)
case errorStore(storeError: StoreError)
case errorDatabase(databaseError: DatabaseError)
case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError)
case invalidJSON(json: String)
case invalidJSON(json: Data?)
case unexpectedResult(type: String)
}
```
@@ -469,13 +478,13 @@ public enum ChatError: Decodable, Hashable {
| Category | Enum | Description | Source |
|----------|------|-------------|--------|
| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied) | [`APITypes.swift` L717](../SimpleXChat/APITypes.swift#L722) |
| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L873](../SimpleXChat/APITypes.swift#L878) |
| Database store | `StoreError` | SQLite query/constraint errors | [`APITypes.swift` L796](../SimpleXChat/APITypes.swift#L801) |
| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L860](../SimpleXChat/APITypes.swift#L865) |
| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1043](../SimpleXChat/APITypes.swift#L1048) |
| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) |
| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) |
| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied, `chatRelayExists`) | [`APITypes.swift` L722](../SimpleXChat/APITypes.swift#L722) |
| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L884](../SimpleXChat/APITypes.swift#L884) |
| Database store | `StoreError` | SQLite query/constraint errors (includes relay-related: `relayUserNotFound`, `duplicateMemberId`, `userChatRelayNotFound`, `groupRelayNotFound`, `groupRelayNotFoundByMemberId`) | [`APITypes.swift` L802](../SimpleXChat/APITypes.swift#L802) |
| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L871](../SimpleXChat/APITypes.swift#L871) |
| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1054](../SimpleXChat/APITypes.swift#L1054) |
| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) |
| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) |
---
@@ -487,7 +496,7 @@ Defined in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift):
```swift
// Throws on error, returns typed result
func chatSendCmdSync<R: ChatAPIResult>( // SimpleXAPI.swift L91
func chatSendCmdSync<R: ChatAPIResult>( // SimpleXAPI.swift L93
_ cmd: ChatCommand,
bgTask: Bool = true,
bgDelay: Double? = nil,
@@ -496,7 +505,7 @@ func chatSendCmdSync<R: ChatAPIResult>( // SimpleXAPI.swift L91
) throws -> R
// Returns APIResult (caller handles error)
func chatApiSendCmdSync<R: ChatAPIResult>( // SimpleXAPI.swift L96
func chatApiSendCmdSync<R: ChatAPIResult>( // SimpleXAPI.swift L99
_ cmd: ChatCommand,
bgTask: Bool = true,
bgDelay: Double? = nil,
@@ -510,7 +519,7 @@ func chatApiSendCmdSync<R: ChatAPIResult>( // SimpleXAPI.swift L96
```swift
// Throws on error, returns typed result
func chatSendCmd<R: ChatAPIResult>( // SimpleXAPI.swift L117
func chatSendCmd<R: ChatAPIResult>( // SimpleXAPI.swift L121
_ cmd: ChatCommand,
bgTask: Bool = true,
bgDelay: Double? = nil,
@@ -519,7 +528,7 @@ func chatSendCmd<R: ChatAPIResult>( // SimpleXAPI.swift L117
) async throws -> R
// Returns APIResult with optional retry on network errors
func chatApiSendCmdWithRetry<R: ChatAPIResult>( // SimpleXAPI.swift L122
func chatApiSendCmdWithRetry<R: ChatAPIResult>( // SimpleXAPI.swift L127
_ cmd: ChatCommand,
bgTask: Bool = true,
bgDelay: Double? = nil,
@@ -543,12 +552,12 @@ public func sendSimpleXCmd<R: ChatAPIResult>( // API.swift L115
```swift
// Polls for async events from the Haskell core
func chatRecvMsg( // SimpleXAPI.swift L230
func chatRecvMsg( // SimpleXAPI.swift L237
_ ctrl: chat_ctrl? = nil
) async -> APIResult<ChatEvent>?
// Processes a received event and updates app state
func processReceivedMsg( // SimpleXAPI.swift L2248
func processReceivedMsg( // SimpleXAPI.swift L2282
_ res: ChatEvent
) async
```
@@ -557,7 +566,7 @@ func processReceivedMsg( // SimpleXAPI.swift L2248
## 7. Result Type
Defined in [`SimpleXChat/APITypes.swift` L26](../SimpleXChat/APITypes.swift#L27):
Defined in [`SimpleXChat/APITypes.swift` L27](../SimpleXChat/APITypes.swift#L27):
```swift
public enum APIResult<R>: Decodable where R: Decodable, R: ChatAPIResult {
@@ -569,14 +578,14 @@ public enum APIResult<R>: Decodable where R: Decodable, R: ChatAPIResult {
public var unexpected: ChatError { ... }
}
public protocol ChatAPIResult: Decodable { // APITypes.swift L63
public protocol ChatAPIResult: Decodable { // APITypes.swift L65
var responseType: String { get }
var details: String { get }
static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self?
}
```
The `decodeAPIResult<R>` function ([`APITypes.swift` L83](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic:
The `decodeAPIResult<R>` function ([`APITypes.swift` L86](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic:
1. Try standard `JSONDecoder.decode(APIResult<R>.self, from: data)`
2. If that fails, try manual JSON parsing via `JSONSerialization`
3. Check for `"error"` key -- return `.error`
@@ -589,10 +598,10 @@ The `decodeAPIResult<R>` function ([`APITypes.swift` L83](../SimpleXChat/APIType
| File | Path |
|------|------|
| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15) |
| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L647, L768, L907](../Shared/Model/AppAPITypes.swift#L649) |
| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055) |
| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L26, L695](../SimpleXChat/APITypes.swift#L27) |
| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15) |
| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L657, L779, L919](../Shared/Model/AppAPITypes.swift#L657) |
| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069) |
| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L27, L699](../SimpleXChat/APITypes.swift#L27) |
| FFI bridge functions | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) |
| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) |
| Data types | `SimpleXChat/ChatTypes.swift` |
+49
View File
@@ -266,6 +266,55 @@ Optional desktop pairing allows controlling the mobile app from a desktop client
---
## 8. Chat Relay Management
### Overview
Chat relays are SMP servers that forward messages to channel subscribers. They are configured in the Network & Servers settings and selected during channel creation.
### Data Model
| Type | Location | Description |
|------|----------|-------------|
| `UserChatRelay` | `ChatTypes.swift` | Relay server config: chatRelayId, address, name, domains, preset, tested, enabled, deleted |
| `UserOperatorServers.chatRelays` | `AppAPITypes.swift` | Array of `UserChatRelay` per operator |
| `UserServersWarning` | `AppAPITypes.swift` | Enum with `.noChatRelays(user:)` case |
| `ServerSettings.serverWarnings` | `ChatListView.swift` | `[UserServersWarning]` field on `ServerSettings` struct (exposed via `SaveableSettings.servers`) |
### Relay Management Views
| View | File | Description |
|------|------|-------------|
| `ChatRelayView` | `ChatRelayView.swift` | Edit/view relay: name, address, test, enable toggle, delete |
| `ChatRelayViewLink` | `ChatRelayView.swift` | NavigationLink row showing relay status icon + display name |
| `NewChatRelayView` | `ChatRelayView.swift` | Form to add new relay (name + address + test + enable toggle) |
| `ServersWarningView` | `NetworkAndServers.swift` | Orange exclamation triangle + warning text |
### Key Functions
| Function | File | Description |
|----------|------|-------------|
| `addChatRelay(...)` | `ChatRelayView.swift` | Validates name/address, appends to `userServers[nil operator].chatRelays`, calls `validateServers_` |
| `deleteChatRelay(...)` | `ProtocolServersView.swift` | Marks relay as deleted or removes if no `chatRelayId` |
| `validRelayName(_:)` | `ChatRelayView.swift` | Non-empty + valid display name check |
| `validRelayAddress(_:)` | `ChatRelayView.swift` | Parses via `parseSimpleXMarkdown`, validates `.simplexLink(_, .relay, _, _)` |
| `showRelayTestStatus(relay:)` | `ChatRelayView.swift` | ViewBuilder returning checkmark/multiply/clear icons |
| `validateServers_` | `NetworkAndServers.swift` | Extended signature: now accepts optional `Binding<[UserServersWarning]>?`; calls `validateServers` which returns `([UserServersError], [UserServersWarning])` tuple |
| `globalServersWarning(_:)` | `NetworkAndServers.swift` | Extracts `.noChatRelays` warning text for display |
| `bindingForChatRelays(_:_:)` | `NetworkAndServers.swift` | Creates binding for `chatRelays` at operator index |
### Relay Sections in Settings
"Chat relays" sections appear in:
- `OperatorView`: lists relays for the operator, with header and footer
- `YourServersView` (in `ProtocolServersView`): lists relays for non-operator servers, with delete support and "Add server" -> "Chat relay" option
### serverWarnings Plumbing
`Binding<[UserServersWarning]>` is threaded through: `NetworkAndServers` -> `OperatorView` -> `ProtocolServersView` -> `ProtocolServerView` / `NewServerView` / `ScanProtocolServer`. All `validateServers_` calls pass the warnings binding.
---
## Source Files
| File | Path | Line |
+16
View File
@@ -134,6 +134,22 @@ Wraps `ChatPreviewView` in a navigation link with tap and swipe behavior:
- Sets `ChatModel.chatId` to trigger navigation
- `ItemsModel.loadOpenChat()` loads messages with a 250ms navigation delay for smooth animation
### Channel Adaptations in ChatListNavLink
When `groupInfo.useRelays == true`:
| Change | Behavior |
|--------|----------|
| Swipe "Leave" | Hidden when `useRelays && isOwner` |
| Context menu "Leave" | Hidden under same condition |
| `deleteGroupAlert` label | "Delete channel?" |
| `leaveGroupAlert` title | "Leave channel?" |
| `leaveGroupAlert` message | "You will stop receiving messages from this channel. Chat history will be preserved." |
### ServerSettings
`ServerSettings` struct (defined in `ChatListView.swift`) includes `serverWarnings: [UserServersWarning]` field, initialized to `[]`. This field stores validation warnings from `validateServers` and is consumed by NetworkAndServers views.
---
## 5. Filtering & Tags
+117 -57
View File
@@ -5,7 +5,7 @@
> Related specs: [Compose Module](compose.md) | [State Management](../state.md) | [API Reference](../api.md) | [README](../README.md)
> Related product: [Chat View](../../product/views/chat.md)
**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift)
**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) | [`ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [`ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift)
---
@@ -52,7 +52,7 @@ ChatView
---
## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3135)
## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3210)
**File**: [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift)
@@ -84,33 +84,33 @@ The main conversation view. Key responsibilities:
| Function | Line | Description |
|----------|------|-------------|
| [`body`](../../Shared/Views/Chat/ChatView.swift#L76) | L74 | Main view body |
| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L675) | L672 | Initializes chat view state on appear |
| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L821) | L814 | Builds the scrollable message list |
| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L735) | L731 | Scrolls to a specific message by ID |
| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L769) | L764 | In-chat search toolbar UI |
| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1087 | Handles search query changes |
| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1519 | Loads chat items with pagination |
| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L807) | L801 | Filters items by content type |
| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1264 | Audio/video call toolbar button |
| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1284 | Search toggle toolbar button |
| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1352 | Group add-members toolbar button |
| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1409 | Forwards batch-selected messages |
| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1401 | Deletes batch-selected messages |
| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1559 | Reacts to chat items model changes |
| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1292 | Content filter dropdown menu |
| [`body`](../../Shared/Views/Chat/ChatView.swift#L75) | L75 | Main view body |
| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L660) | L660 | Initializes chat view state on appear |
| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L817) | L817 | Builds the scrollable message list |
| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L731) | L731 | Scrolls to a specific message by ID |
| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L765) | L765 | In-chat search toolbar UI |
| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1095 | Handles search query changes |
| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1531 | Loads chat items with pagination |
| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L803) | L803 | Filters items by content type |
| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1273 | Audio/video call toolbar button |
| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1293 | Search toggle toolbar button |
| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1361 | Group add-members toolbar button |
| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1420 | Forwards batch-selected messages |
| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1411 | Deletes batch-selected messages |
| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1572 | Reacts to chat items model changes |
| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1301 | Content filter dropdown menu |
### Supporting Types
| Type | Line | Description |
|------|------|-------------|
| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1586 | Wraps each chat item with context menu |
| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2712) | L2697 | Manages scroll-to-bottom button state |
| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2899) | L2882 | Reaction picker context menu |
| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L2997) | L2980 | Mute/unmute notifications button |
| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3049) | L3031 | Enum for message content filter types |
| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2795) | L2779 | Deletes messages with confirmation |
| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2842) | L2826 | Archives report messages |
| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1600 | Wraps each chat item with context menu |
| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2787) | L2787 | Manages scroll-to-bottom button state |
| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2974) | L2974 | Reaction picker context menu |
| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L3072) | L3072 | Mute/unmute notifications button |
| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3124) | L3124 | Enum for message content filter types |
| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2870) | L2870 | Deletes messages with confirmation |
| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2917) | L2917 | Archives report messages |
---
@@ -124,17 +124,17 @@ Routes each `ChatItem` to the appropriate renderer based on its `CIContent` type
| Content Type | Renderer | Line | Description |
|-------------|----------|------|-------------|
| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L13 | Standard sent/received text+media message |
| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L13 | Locally deleted message placeholder |
| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L14 | Standard sent/received text+media message |
| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L14 | Locally deleted message placeholder |
| `sndCall` / `rcvCall` | [`CICallItemView`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | L13 | Call event (missed, ended, duration) |
| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L13 | Message integrity error |
| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L15 | Decryption failure |
| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L13 | Group invite |
| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Group system event |
| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Connection event |
| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L13 | Feature toggle event |
| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L13 | Preference change |
| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L13 | Failed to decode |
| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L14 | Message integrity error |
| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L16 | Decryption failure |
| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L14 | Group invite |
| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Group system event |
| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Connection event |
| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L14 | Feature toggle event |
| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L14 | Preference change |
| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L14 | Failed to decode |
### Bubble Direction
- Sent messages: aligned right, sender-colored bubble
@@ -149,6 +149,19 @@ Each [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) may depe
`ChatItemDummyModel.shared.sendUpdate()` forces a re-render of all items when global appearance changes.
### Channel Message Rendering (`.channelRcv`)
Channel messages (`CIDirection.channelRcv`) are rendered with the group avatar and group name as sender, with "channel" as the role label. This mirrors the `.groupRcv` path's `showGroupAsSender` visual but uses a dedicated code branch in [`chatItemListView()`](../../Shared/Views/Chat/ChatView.swift#L1846).
Key differences from `.groupRcv`:
- No `prevMember`/`memCount` logic — channels have no per-member identity
- Always shows group avatar (via `ProfileImage` with `groupInfo.image` / `groupInfo.chatIconName`)
- Tapping avatar opens `showChatInfoSheet` (not member info)
- [`shouldShowAvatar()`](../../Shared/Views/Chat/ChatView.swift#L1670) treats consecutive `.channelRcv` items as same sender
- [`getItemSeparation()`](../../Shared/Views/Chat/ChatView.swift#L1649) treats consecutive `.channelRcv` items as `sameMemberAndDirection`
- [`showMemberImage()`](../../Shared/Views/Chat/ChatView.swift#L2116) returns `true` when previous item is `.channelRcv` (different sender type)
- [`memberToModerate()`](../../SimpleXChat/ChatTypes.swift#L3297) returns `nil` for `.channelRcv` (no per-member moderation)
---
## 4. Message Renderers
@@ -301,31 +314,78 @@ Multi-selection mode allows batch operations on messages:
---
## GroupChatInfoView — Channel Adaptations
When `groupInfo.useRelays == true`, [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) adapts its sections:
### Section Structure (Channel)
| Section | Owner | Subscriber |
|---------|-------|-----------|
| 1. Links & Members | Channel link (manage via GroupLinkView), Owners & subscribers | Channel link (read-only QR from `groupProfile.groupLink`), Owners |
| 2. Profile & Welcome | Edit channel profile, Welcome message | Welcome message (if exists) |
| 3. Theme & TTL | Chat theme, Delete messages after | Chat theme, Delete messages after |
| 4. Actions | Chat relays, Clear chat, Delete channel | Chat relays, Clear chat, Leave channel |
**Hidden for channels:** Member support, group reports, user support chat, send receipts, inline members list, group preferences.
### Label Replacements
All "group" labels are replaced with "channel" equivalents via `groupInfo.useRelays ? "Channel..." :` ternary prepended before existing `businessChat` ternary. Affected: delete/leave buttons, delete/leave alerts, remove member alert, edit profile button, group link nav title. Channel link button uses a separate `channelLinkButton()` with hardcoded "Channel link" label.
### [`channelMembersButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L627) → [`ChannelMembersView`](../../Shared/Views/Chat/Group/ChannelMembersView.swift)
Navigates to a dedicated members view with two sections:
- **Owners**: current user (if owner) + members with `memberRole >= .owner`
- **Subscribers** (admin+ only): members with `memberRole < .owner`
Member rows show profile image, display name (with verified shield), connection status, and role badge. Non-user rows link to `GroupMemberInfoView`.
### Channel Link
Owner sees [`channelLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L605) (navigates to `GroupLinkView` for full link management), guarded by `groupInfo.isOwner && groupLink != nil` — channel links can only be created during channel creation, not from the info view. A TODO marks the need for protocol changes to allow other owners to manage the same channel link. Non-owner sees read-only QR code displaying `groupProfile.groupLink` via `SimpleXLinkQRCode`. `apiGetGroupLink` is skipped in `onAppear` for non-owner channels.
Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L593) which supports both "Create group link" and "Group link" labels.
### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift)
Navigates to relay list view with role-based branches:
- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`).
- **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data).
### Leave Button Logic
Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`.
---
## Source Files
| File | Path | Line |
|------|------|------|
| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L17](../../Shared/Views/Chat/ChatView.swift#L18) |
| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L41](../../Shared/Views/Chat/ChatItemView.swift#L42) |
| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) |
| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) |
| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) |
| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) |
| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) |
| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) |
| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) |
| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L18](../../Shared/Views/Chat/ChatView.swift#L18) |
| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L42](../../Shared/Views/Chat/ChatItemView.swift#L42) |
| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) |
| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) |
| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) |
| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) |
| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) |
| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) |
| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) |
| Call event | [`Shared/Views/Chat/ChatItem/CICallItemView.swift`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) |
| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) |
| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L12](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) |
| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) |
| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) |
| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) |
| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L27](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) |
| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) |
| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) |
| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) |
| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) |
| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) |
| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L10](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) |
| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) |
| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) |
| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) |
| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L13](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) |
| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) |
| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) |
| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) |
| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L28](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) |
| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) |
| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) |
| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) |
| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) |
| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) |
| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L11](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) |
| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) |
| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) |
| Channel members | [`Shared/Views/Chat/Group/ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelMembersView.swift#L12) |
| Channel relays | [`Shared/Views/Chat/Group/ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelRelaysView.swift#L12) |
+53 -36
View File
@@ -69,21 +69,21 @@ ComposeView
| Function | Line | Description |
|----------|------|-------------|
| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L369) | L360 | Main view body |
| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L693) | L683 | Builds the send-message UI |
| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1106) | L1091 | Entry point: initiates send |
| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1115) | L1099 | Async send implementation |
| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1467) | L1446 | Resets compose state after send |
| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L893) | L882 | Adds media attachment |
| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L866) | L856 | Checks link preview before connect |
| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L754) | L744 | Builds commands menu button |
| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L371) | L371 | Main view body |
| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L870) | L870 | Builds the send-message UI |
| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1286) | L1286 | Entry point: initiates send |
| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1295) | L1295 | Async send implementation |
| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1649) | L1649 | Resets compose state after send |
| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1073) | L1073 | Adds media attachment |
| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1046) | L1046 | Checks link preview before connect |
| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L931) | L931 | Builds commands menu button |
### Draft Persistence
| Function | Line | Description |
|----------|------|-------------|
| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1481) | L1459 | Saves compose state to `ChatModel.draft` |
| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1487) | L1464 | Clears persisted draft |
| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1663) | L1663 | Saves compose state to `ChatModel.draft` |
| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1669) | L1669 | Clears persisted draft |
- When navigating away from a chat, compose state is saved to `ChatModel.draft` / `ChatModel.draftChatId`
- When returning to the same chat, draft is restored
@@ -118,14 +118,14 @@ The compose bar operates as a state machine with these primary states:
| Type | Line | Description |
|------|------|-------------|
| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L10 | Preview variants (image, voice, file, etc.) |
| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L18 | Context item for reply/quote |
| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L26 | Recording state enum |
| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L40 | Full compose state struct |
| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L93 | Copy compose state with overrides |
| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L113 | Format mention display name |
| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L260 | Build preview from chat item |
| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L280 | Upload content variants |
| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L11 | Preview variants (image, voice, file, etc.) |
| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L20 | Context item for reply/quote |
| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L29 | Recording state enum |
| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L45 | Full compose state struct |
| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L98 | Copy compose state with overrides |
| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L118 | Format mention display name |
| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L266 | Build preview from chat item |
| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L287 | Upload content variants |
### States
@@ -253,10 +253,10 @@ Optional feature where the recipient sees typing in real-time.
| Function | Line | Description |
|----------|------|-------------|
| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L922) | L910 | Initiates a live message |
| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L940) | L927 | Sends incremental live update |
| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L959) | L945 | Determines text diff to send |
| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L964) | L950 | Truncates text at word boundary |
| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1102) | L1102 | Initiates a live message |
| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1120) | L1120 | Sends incremental live update |
| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1139) | L1139 | Determines text diff to send |
| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1144) | L1144 | Truncates text at word boundary |
### API
- Initial: `apiSendMessages(live: true, composedMessages: [...])` -- creates live message
@@ -279,12 +279,12 @@ Optional feature where the recipient sees typing in real-time.
| Function | Line | Description |
|----------|------|-------------|
| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1382) | L1365 | Begins audio recording |
| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1423) | L1405 | Stops recording, shows preview |
| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1434) | L1415 | Enables voice messages for contact |
| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1441) | L1422 | Updates state after recording finishes |
| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1453) | L1434 | Cancels in-progress recording |
| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1460) | L1440 | Cancels and cleans up recording file |
| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1564) | L1564 | Begins audio recording |
| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1605) | L1605 | Stops recording, shows preview |
| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1616) | L1616 | Enables voice messages for contact |
| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1623) | L1623 | Updates state after recording finishes |
| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1635) | L1635 | Cancels in-progress recording |
| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1642) | L1642 | Cancels and cleans up recording file |
### Constraints
- Maximum duration: `MAX_VOICE_MESSAGE_LENGTH = 300` seconds (5 minutes)
@@ -310,12 +310,12 @@ Optional feature where the recipient sees typing in real-time.
| Function | Line | Description |
|----------|------|-------------|
| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1495) | L1471 | Triggers link preview loading |
| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1515) | L1490 | Extracts URLs from formatted text |
| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) | L1501 | Checks if URL is a SimpleX link |
| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1530) | L1505 | Cancels pending preview |
| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1542) | L1516 | Fetches OpenGraph metadata |
| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1559) | L1533 | Resets preview state |
| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1677) | L1677 | Triggers link preview loading |
| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1697) | L1697 | Extracts URLs from formatted text |
| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1708) | L1708 | Checks if URL is a SimpleX link |
| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1712) | L1712 | Cancels pending preview |
| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1724) | L1724 | Fetches OpenGraph metadata |
| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1741) | L1741 | Resets preview state |
### Behavior
- Only the first URL in the message generates a preview
@@ -342,12 +342,29 @@ In group chats, typing `@` triggers member name autocomplete:
---
## Channel Compose Behavior
When `chat.chatInfo.groupInfo?.useRelays == true` (channel mode), compose behaves differently:
### Owner/Admin Compose
- [`send()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1498) passes `sendAsGroup: true` to `apiSendMessages` when `useRelays && memberRole >= .owner`
- [`forwardItems()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) passes `sendAsGroup: true` to `apiForwardChatItems` under same condition
- Placeholder text shows "Broadcast" instead of "Message" (via `sendMessageView()` `placeholder:` parameter)
- Share Extension ([`ShareAPI.swift`](../../SimpleX%20SE/ShareAPI.swift#L71)) uses the same `sendAsGroup` expression
### Subscriber Compose
- [`userCantSendReason`](../../SimpleXChat/ChatTypes.swift#L1566) returns `("you are subscriber", nil)` when `useRelays && memberRole == .observer`
- This check is evaluated after `memberPending` (which takes priority) but replaces the `observer` message
- Compose field is disabled; tapping shows "You can't send messages!" alert with no body text
---
## Source Files
| File | Path | Struct/Class | Line |
|------|------|--------------|------|
| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L321](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) |
| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L14](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) |
| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L329](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) |
| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L15](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) |
| Image preview | [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | `ComposeImageView` | [L12](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) |
| File preview | [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | `ComposeFileView` | [L11](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) |
| Voice preview | [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | `ComposeVoiceView` | [L26](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) |
+69 -1
View File
@@ -198,7 +198,7 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh
}
```
URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1169).
URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181).
### Call Deep Link
@@ -293,6 +293,72 @@ Migration state (`ChatModel.migrationState != nil`) takes precedence over onboar
---
## 9. Channel Creation Flow (`AddChannelView`)
**Source:** [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift)
### Entry Point
`NewChatMenuButton` includes a NavigationLink "Create channel (BETA)" with antenna icon, navigating to `AddChannelView`.
### Three-Step Wizard
| Step | Function | Description |
|------|----------|-------------|
| 1. Profile | `profileStepView()` | Channel name input (`channelNameTextField()`), profile image picker. "Configure relays" link to `NetworkAndServers`. Validates via `canCreateProfile()` (non-empty + valid display name) and `checkHasRelays()`. |
| 2. Progress | `progressStepView(_:)` | Relay connection progress with `RelayProgressIndicator` (circular active/total or spinner). Expandable relay list with `relayStatusIndicator(_:)` (green/red/orange dots). Cancel via `cancelChannelCreation(_:)` which calls `apiDeleteChat`. |
| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` for channel link sharing. |
### Key Functions
| Function | Scope | Description |
|----------|-------|-------------|
| `createChannel()` | private | Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`, sets `ChannelRelaysModel` |
| `getEnabledRelays()` | private | Filters enabled/non-deleted relays, selects random 3 |
| `checkHasRelays()` | private | Validates at least one relay exists |
| `relayDisplayName(_:)` | module | name > domain > link host > fallback |
| `relayStatusIndicator(_:)` | module | Green/red/orange dot + status text |
| `RelayProgressIndicator` | module | Circular progress (active/total) or spinner |
## 10. Relay URL Interception
**Source:** [`Shared/ContentView.swift`](../../Shared/ContentView.swift#L454)
In `connectViaUrl_()`, relay address links (URL path `/r`) are intercepted before processing:
```swift
if path == "/r" {
showAlert(NSLocalizedString("Relay address", ...),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", ...))
return
}
```
Similarly, in `planAndConnect()` (`NewChatView.swift`), `.simplexLink(_, .relay, _, _)` patterns trigger the same alert and block connection.
## 11. Channel-Specific NewChatView Behavior
**Source:** [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift)
### Prepared Group Alert (`showPrepareGroupAlert`)
When `groupShortLinkInfo?.direct == false` (channel relay link), the prepare alert uses:
- Channel icon: `antenna.radiowaves.left.and.right.circle.fill`
- Title: "Open new channel"
- Error: "Error opening channel"
- `apiPrepareGroup` call passes `directLink: false`
- Stores `groupShortLinkInfo.groupRelays` in `ChatModel.shared.channelRelayHostnames`
### Own Link Confirmation (`showOwnGroupLinkConfirmConnectSheet`)
For channels: shows "This is your link for channel" with only "Open channel" + "Cancel" buttons. No incognito or profile selection options.
### Known Group Alert (`showOpenKnownGroupAlert`)
For channels (`groupInfo.useRelays`): titles become "Open channel" / "Open new channel".
---
## Source Files
| File | Path |
@@ -304,6 +370,8 @@ Migration state (`ChatModel.migrationState != nil`) takes precedence over onboar
| Nav link wrapper | `Shared/Views/ChatList/ChatListNavLink.swift` |
| User picker | `Shared/Views/ChatList/UserPicker.swift` |
| New chat view | [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) |
| Channel creation | [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) |
| New chat menu | [`Shared/Views/NewChat/NewChatMenuButton.swift`](../../Shared/Views/NewChat/NewChatMenuButton.swift) |
| Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) |
| User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) |
| Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) |
+19 -14
View File
@@ -40,6 +40,7 @@
| PC28 | Chat Tags |
| PC29 | User Address |
| PC30 | Member Support Chat |
| PC31 | Channels (Relays) |
---
@@ -48,42 +49,46 @@
| 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 PC30 | High | App entry point — initialization affects everything |
| 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 | High | Core conversation UI — most messaging features |
| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11 | High | Message composition — send path for all messages |
| 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/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 | High | Group management hub |
| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30, PC31 | High | Group management hub, channel info adaptations |
| Shared/Views/Chat/Group/ChannelMembersView.swift | PC31 | Medium | Channel owners/subscribers list |
| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list |
| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow |
| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management |
| Shared/Views/NewChat/NewChatView.swift | PC12 | High | New connection creation — onramp for all contacts |
| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels |
| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility |
| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering |
| Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle |
| Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management |
| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub |
| Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI |
| Shared/Views/UserSettings/NetworkAndServers/ | PC25 | High | Server configuration — affects connectivity |
| Shared/Views/UserSettings/NetworkAndServers/ | PC25, PC31 | High | Server configuration — affects connectivity and relay validation |
| Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management |
| Shared/Views/Onboarding/ | PC1 | Medium | First-time setup — affects initial state |
| 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 PC30 | High | Central state — all features depend on it |
| Shared/Model/SimpleXAPI.swift | PC1 through PC30 | High | FFI bridge — all commands flow through here |
| Shared/Model/AppAPITypes.swift | PC1 through PC30 | High | Command/response types — all API communication |
| 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/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 PC30 | High | Core data types — all features use them |
| SimpleXChat/APITypes.swift | PC1 through PC30 | High | API result types and error handling |
| SimpleXChat/ChatTypes.swift | PC1 through PC31 | High | Core data types — all features use them |
| 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 |
| SimpleXChat/Notifications.swift | PC18 | Medium | Notification type definitions |
| SimpleX NSE/NotificationService.swift | PC18 | High | Push notification decryption and display |
| Shared/Views/Chat/ChatItemsMerger.swift | PC2, PC3, PC31 | Low | Chat item merge categories — added channelRcv hash |
| SimpleX SE/ShareAPI.swift | PC4, PC31 | Medium | Share extension API — sendAsGroup support |
---
@@ -91,9 +96,9 @@
| Source File | Product Concepts Affected | Risk Level | Notes |
|-------------|--------------------------|------------|-------|
| src/Simplex/Chat/Controller.hs | PC1 through PC30 | High | Command processor — all API commands |
| src/Simplex/Chat/Types.hs | PC1 through PC30 | High | Core data types shared across all features |
| src/Simplex/Chat/Core.hs | PC1 through PC30 | High | Chat engine lifecycle |
| src/Simplex/Chat/Controller.hs | PC1 through PC31 | High | Command processor — all API commands |
| src/Simplex/Chat/Types.hs | PC1 through PC31 | High | Core data types shared across all features |
| src/Simplex/Chat/Core.hs | PC1 through PC31 | High | Chat engine lifecycle |
| src/Simplex/Chat/Protocol.hs | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) |
| src/Simplex/Chat/Messages.hs | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content |
| src/Simplex/Chat/Messages/CIContent.hs | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants |
+157 -103
View File
@@ -1,6 +1,6 @@
# SimpleX Chat iOS -- State Management
**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1375) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5284)
**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1404) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5377)
> Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage.
>
@@ -15,10 +15,11 @@
2. [ChatModel -- Primary App State](#2-chatmodel)
3. [ItemsModel -- Per-Chat Message State](#3-itemsmodel)
4. [ChatTagsModel -- Tag Filtering State](#4-chattagsmodel)
5. [Chat -- Single Conversation State](#5-chat)
6. [ChatInfo -- Conversation Metadata](#6-chatinfo)
7. [State Flow](#7-state-flow)
8. [Preference Storage](#8-preference-storage)
5. [ChannelRelaysModel -- Channel Relay State](#5-channelrelaysmodel)
6. [Chat -- Single Conversation State](#6-chat)
7. [ChatInfo -- Conversation Metadata](#7-chatinfo)
8. [State Flow](#8-state-flow)
9. [Preference Storage](#9-preference-storage)
---
@@ -62,122 +63,122 @@ ChatTagsModel (singleton -- filter state)
---
## 2. [ChatModel](../Shared/Model/ChatModel.swift#L337-L1260)
## 2. [ChatModel](../Shared/Model/ChatModel.swift#L353-L1289)
**Class**: `final class ChatModel: ObservableObject`
**Singleton**: `ChatModel.shared`
**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337)
**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L353)
### Key Published Properties
#### App Lifecycle
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L331](../Shared/Model/ChatModel.swift#L338) |
| `chatInitialized` | `Bool` | Whether chat has been initialized | [L340](../Shared/Model/ChatModel.swift#L347) |
| `chatRunning` | `Bool?` | Whether chat engine is running | [L341](../Shared/Model/ChatModel.swift#L348) |
| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L342](../Shared/Model/ChatModel.swift#L349) |
| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L343](../Shared/Model/ChatModel.swift#L350) |
| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L344](../Shared/Model/ChatModel.swift#L351) |
| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L345](../Shared/Model/ChatModel.swift#L352) |
| `migrationState` | `MigrationToState?` | Device migration state | [L390](../Shared/Model/ChatModel.swift#L398) |
| `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) |
#### User State
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L334](../Shared/Model/ChatModel.swift#L341) |
| `users` | `[UserInfo]` | All user profiles | [L339](../Shared/Model/ChatModel.swift#L346) |
| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L333](../Shared/Model/ChatModel.swift#L340) |
| `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) |
#### Chat List
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `chats` | `[Chat]` (private set) | All conversations for current user | [L351](../Shared/Model/ChatModel.swift#L358) |
| `deletedChats` | `Set<String>` | Chat IDs pending deletion animation | [L352](../Shared/Model/ChatModel.swift#L359) |
| `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) |
#### Active Chat
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `chatId` | `String?` | Currently open chat ID | [L354](../Shared/Model/ChatModel.swift#L361) |
| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L355](../Shared/Model/ChatModel.swift#L362) |
| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L356](../Shared/Model/ChatModel.swift#L363) |
| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L357](../Shared/Model/ChatModel.swift#L364) |
| `chatToTop` | `String?` | Chat to scroll to top | [L358](../Shared/Model/ChatModel.swift#L365) |
| `groupMembers` | `[GMember]` | Members of active group | [L359](../Shared/Model/ChatModel.swift#L366) |
| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L360](../Shared/Model/ChatModel.swift#L367) |
| `membersLoaded` | `Bool` | Whether members have been loaded | [L361](../Shared/Model/ChatModel.swift#L368) |
| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L408](../Shared/Model/ChatModel.swift#L416) |
| `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) |
#### Authentication
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L348](../Shared/Model/ChatModel.swift#L355) |
| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L349](../Shared/Model/ChatModel.swift#L356) |
| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L371](../Shared/Model/ChatModel.swift#L371) |
| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L372](../Shared/Model/ChatModel.swift#L372) |
#### Notifications
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `deviceToken` | `DeviceToken?` | Current APNs device token | [L369](../Shared/Model/ChatModel.swift#L376) |
| `savedToken` | `DeviceToken?` | Previously saved token | [L370](../Shared/Model/ChatModel.swift#L377) |
| `tokenRegistered` | `Bool` | Whether token is registered with server | [L371](../Shared/Model/ChatModel.swift#L378) |
| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L373](../Shared/Model/ChatModel.swift#L380) |
| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L374](../Shared/Model/ChatModel.swift#L381) |
| `notificationServer` | `String?` | Notification server URL | [L375](../Shared/Model/ChatModel.swift#L382) |
| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L376](../Shared/Model/ChatModel.swift#L383) |
| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L346](../Shared/Model/ChatModel.swift#L353) |
| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L378](../Shared/Model/ChatModel.swift#L385) |
| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L379](../Shared/Model/ChatModel.swift#L386) |
| `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) |
#### Calls
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L381](../Shared/Model/ChatModel.swift#L388) |
| `activeCall` | `Call?` | Currently active call | [L382](../Shared/Model/ChatModel.swift#L389) |
| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L383](../Shared/Model/ChatModel.swift#L390) |
| `showCallView` | `Bool` | Whether to show full-screen call UI | [L384](../Shared/Model/ChatModel.swift#L391) |
| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L385](../Shared/Model/ChatModel.swift#L392) |
| `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) |
#### Remote Desktop
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L387](../Shared/Model/ChatModel.swift#L395) |
| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L414](../Shared/Model/ChatModel.swift#L414) |
#### Misc
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `userAddress` | `UserContactLink?` | User's SimpleX address | [L365](../Shared/Model/ChatModel.swift#L372) |
| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L366](../Shared/Model/ChatModel.swift#L373) |
| `appOpenUrl` | `URL?` | URL opened while app active | [L367](../Shared/Model/ChatModel.swift#L374) |
| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L368](../Shared/Model/ChatModel.swift#L375) |
| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L389](../Shared/Model/ChatModel.swift#L397) |
| `draft` | `ComposeState?` | Saved compose draft | [L393](../Shared/Model/ChatModel.swift#L401) |
| `draftChatId` | `String?` | Chat ID for saved draft | [L394](../Shared/Model/ChatModel.swift#L402) |
| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L395](../Shared/Model/ChatModel.swift#L403) |
| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L397](../Shared/Model/ChatModel.swift#L405) |
| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L392](../Shared/Model/ChatModel.swift#L400) |
| `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) |
### Non-Published Properties
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L399](../Shared/Model/ChatModel.swift#L407) |
| `filesToDelete` | `Set<URL>` | Files queued for deletion | [L401](../Shared/Model/ChatModel.swift#L409) |
| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L405](../Shared/Model/ChatModel.swift#L413) |
| `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) |
### Key Methods
| Method | Description | Line |
|--------|-------------|------|
| `getUser(_ userId:)` | Find user by ID | [L427](../Shared/Model/ChatModel.swift#L436) |
| `updateUser(_ user:)` | Update user in list and current | [L437](../Shared/Model/ChatModel.swift#L447) |
| `removeUser(_ user:)` | Remove user from list | [L446](../Shared/Model/ChatModel.swift#L457) |
| `getChat(_ id:)` | Find chat by ID | [L456](../Shared/Model/ChatModel.swift#L468) |
| `addChat(_ chat:)` | Add chat to list | [L510](../Shared/Model/ChatModel.swift#L523) |
| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L523](../Shared/Model/ChatModel.swift#L537) |
| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L574](../Shared/Model/ChatModel.swift#L589) |
| `removeChat(_ id:)` | Remove chat from list | [L1180](../Shared/Model/ChatModel.swift#L1198) |
| `popChat(_ id:, _ ts:)` | Move chat to top of list | [L1157](../Shared/Model/ChatModel.swift#L1174) |
| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1058](../Shared/Model/ChatModel.swift#L1074) |
| `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) |
---
@@ -192,21 +193,21 @@ ChatTagsModel (singleton -- filter state)
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L78](../Shared/Model/ChatModel.swift#L80) |
| `itemAdded` | `Bool` | Flag indicating a new item was added | [L81](../Shared/Model/ChatModel.swift#L83) |
| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L85](../Shared/Model/ChatModel.swift#L87) |
| `isLoading` | `Bool` | Whether messages are currently loading | [L89](../Shared/Model/ChatModel.swift#L91) |
| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L90](../Shared/Model/ChatModel.swift#L92) |
| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L75](../Shared/Model/ChatModel.swift#L77) |
| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L74](../Shared/Model/ChatModel.swift#L76) |
| `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) |
### Computed Properties
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L95](../Shared/Model/ChatModel.swift#L97) |
| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L154](../Shared/Model/ChatModel.swift#L159) |
| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L162](../Shared/Model/ChatModel.swift#L167) |
| `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) |
### Throttling
@@ -225,9 +226,9 @@ Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throt
| Method | Description | Line |
|--------|-------------|------|
| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L113](../Shared/Model/ChatModel.swift#L117) |
| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L138](../Shared/Model/ChatModel.swift#L143) |
| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L107](../Shared/Model/ChatModel.swift#L110) |
| `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) |
### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70)
@@ -252,10 +253,10 @@ enum SecondaryItemsModelFilter {
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `userTags` | `[ChatTag]` | User-defined tags | [L186](../Shared/Model/ChatModel.swift#L192) |
| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L187](../Shared/Model/ChatModel.swift#L193) |
| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L188](../Shared/Model/ChatModel.swift#L194) |
| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L189](../Shared/Model/ChatModel.swift#L195) |
| `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) |
### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52)
@@ -269,10 +270,34 @@ enum ActiveFilter {
---
## 5. [Chat](../Shared/Model/ChatModel.swift#L1311-L1323)
## 5. [ChannelRelaysModel](../Shared/Model/ChatModel.swift#L336-L350)
**Class**: `class ChannelRelaysModel: ObservableObject`
**Singleton**: `ChannelRelaysModel.shared`
**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L336)
Holds runtime relay state for the currently viewed channel. Used by `ChannelRelaysView` to display and manage relays. Reset when the view is dismissed.
### Properties
| 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) |
### 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) |
---
## 6. [Chat](../Shared/Model/ChatModel.swift#L1301-L1353)
**Class**: `final class Chat: ObservableObject, Identifiable, ChatLike`
**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1271)
**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1301)
Represents a single conversation in the chat list. Each `Chat` is an independent observable object.
@@ -280,12 +305,12 @@ Represents a single conversation in the chat list. Each `Chat` is an independent
| Property | Type | Description | Line |
|----------|------|-------------|------|
| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1253](../Shared/Model/ChatModel.swift#L1272) |
| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1254](../Shared/Model/ChatModel.swift#L1273) |
| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1255](../Shared/Model/ChatModel.swift#L1274) |
| `created` | `Date` | Creation timestamp | [L1256](../Shared/Model/ChatModel.swift#L1275) |
| `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) |
### [ChatStats](../SimpleXChat/ChatTypes.swift#L1877-L1899)
### [ChatStats](../SimpleXChat/ChatTypes.swift#L1881-L1903)
```swift
struct ChatStats: Decodable, Hashable {
@@ -301,17 +326,17 @@ struct ChatStats: Decodable, Hashable {
| Property | Description | Line |
|----------|-------------|------|
| `id` | Chat ID from `chatInfo.id` | [L1287](../Shared/Model/ChatModel.swift#L1306) |
| `viewId` | Unique view identity including creation time | [L1289](../Shared/Model/ChatModel.swift#L1308) |
| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1279](../Shared/Model/ChatModel.swift#L1298) |
| `supportUnreadCount` | Unread count for group support scope | [L1291](../Shared/Model/ChatModel.swift#L1310) |
| `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) |
---
## 6. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1372-L1852)
## 7. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1374-L1856)
**Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable`
**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1372)
**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1374)
Represents the type and metadata of a conversation:
@@ -348,9 +373,38 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
| `chatSettings` | `ChatSettings?` | Notification/favorite settings |
| `chatTags` | `[Int64]?` | Assigned tag IDs |
### Relay-Related Data Model (Channels)
A **channel** is a group with `groupInfo.useRelays == true`. These types support the relay/channel infrastructure:
#### New Fields on Existing Types
| 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` | `groupLink` | `String?` | Group's short link | [L2452](../SimpleXChat/ChatTypes.swift#L2452) |
#### 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) |
#### 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) |
---
## 7. State Flow
## 8. State Flow
### App Start
```
@@ -400,7 +454,7 @@ User taps send in ComposeView
---
## 8. Preference Storage
## 9. Preference Storage
### UserDefaults (via @AppStorage)
@@ -457,7 +511,7 @@ Chat-level preferences stored in the SQLite database (managed by Haskell core):
| File | Path |
|------|------|
| ChatModel, ItemsModel, Chat, ChatTagsModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) |
| ChatModel, ItemsModel, Chat, ChatTagsModel, ChannelRelaysModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) |
| ChatInfo, User, Contact, GroupInfo, ChatItem | [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift) |
| ActiveFilter | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift#L52) |
| Preference defaults | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift), [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) |
+39
View File
@@ -31,6 +31,7 @@ This file is generated automatically.
- [APIListMembers](#apilistmembers)
- [APINewGroup](#apinewgroup)
- [APINewPublicGroup](#apinewpublicgroup)
- [APIGetGroupRelays](#apigetgrouprelays)
- [APIUpdateGroupProfile](#apiupdategroupprofile)
[Group link commands](#group-link-commands)
@@ -983,6 +984,44 @@ ChatCmdError: Command error (only used in WebSockets API).
---
### APIGetGroupRelays
Get group relays.
*Network usage*: no.
**Parameters**:
- groupId: int64
**Syntax**:
```
/_get relays #<groupId>
```
```javascript
'/_get relays #' + groupId // JavaScript
```
```python
'/_get relays #' + str(groupId) # Python
```
**Responses**:
GroupRelays: Group relays.
- type: "groupRelays"
- user: [User](./TYPES.md#user)
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)]
ChatCmdError: Command error (only used in WebSockets API).
- type: "chatCmdError"
- chatError: [ChatError](./TYPES.md#chaterror)
---
### APIUpdateGroupProfile
Update group profile.
+18 -1
View File
@@ -169,6 +169,7 @@ This file is generated automatically.
- [UIThemeEntityOverrides](#uithemeentityoverrides)
- [UpdatedMessage](#updatedmessage)
- [User](#user)
- [UserChatRelay](#userchatrelay)
- [UserContact](#usercontact)
- [UserContactLink](#usercontactlink)
- [UserContactRequest](#usercontactrequest)
@@ -2243,6 +2244,7 @@ Known:
- updatedAt: UTCTime
- supportChat: [GroupSupportChat](#groupsupportchat)?
- memberPubKey: string?
- relayLink: string?
---
@@ -2366,7 +2368,7 @@ Known:
**Record type**:
- groupRelayId: int64
- groupMemberId: int64
- userChatRelayId: int64
- userChatRelay: [UserChatRelay](#userchatrelay)
- relayStatus: [RelayStatus](#relaystatus)
- relayLink: string?
@@ -3843,6 +3845,21 @@ Handshake:
- userChatRelay: bool
---
## UserChatRelay
**Record type**:
- chatRelayId: int64
- address: string
- name: string
- domains: [string]
- preset: bool
- tested: bool?
- enabled: bool
- deleted: bool
---
## UserContact
+1
View File
@@ -118,6 +118,7 @@ chatCommandsDocsData =
("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"),
("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"),
("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"),
("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"),
("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile")
]
),
+1
View File
@@ -69,6 +69,7 @@ chatResponsesDocsData =
("CRGroupLinkDeleted", ""),
("CRGroupCreated", ""),
("CRPublicGroupCreated", ""),
("CRGroupRelays", ""),
("CRGroupMembers", ""),
("CRGroupUpdated", ""),
("CRGroupsList", "Groups"),
+5
View File
@@ -2,6 +2,7 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
@@ -31,6 +32,8 @@ import Simplex.Chat.Messages.CIContent.Events
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Operators
import Simplex.Messaging.Agent.Store.Entity (DBStored (..))
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
@@ -349,6 +352,7 @@ chatTypesDocsData =
(sti @UIThemeEntityOverrides, STRecord, "", [], "", ""),
(sti @UpdatedMessage, STRecord, "", [], "", ""),
(sti @User, STRecord, "", [], "", ""),
((sti @UserChatRelay) {typeName = "UserChatRelay"}, STRecord, "", [], "", ""),
(sti @UserContact, STRecord, "", [], "", ""),
(sti @UserContactLink, STRecord, "", [], "", ""),
(sti @UserContactRequest, STRecord, "", [], "", ""),
@@ -545,6 +549,7 @@ deriving instance Generic UIThemeEntityOverride
deriving instance Generic UIThemeEntityOverrides
deriving instance Generic UpdatedMessage
deriving instance Generic User
deriving instance Generic (UserChatRelay' 'DBStored)
deriving instance Generic UserContact
deriving instance Generic UserContactLink
deriving instance Generic UserContactRequest
+2
View File
@@ -167,12 +167,14 @@ toTypeInfo tr =
_ -> TIType (simpleType tr)
simpleType tr' = primitiveToLower $ case tyConName (typeRepTyCon tr') of
"AgentUserId" -> ST TInt64 []
"DBEntityId'" -> ST TInt64 []
"Integer" -> ST TInt64 []
"Version" -> ST TInt []
"BoolDef" -> ST TBool []
"PQEncryption" -> ST TBool []
"PQSupport" -> ST TBool []
"ACreatedConnLink" -> ST "CreatedConnLink" []
"UserChatRelay'" -> ST "UserChatRelay" []
"CChatItem" -> ST "ChatItem" []
"FormatColor" -> ST "Color" []
"CustomData" -> ST "JSONObject" []
@@ -358,6 +358,20 @@ export namespace APINewPublicGroup {
}
}
// Get group relays.
// Network usage: no.
export interface APIGetGroupRelays {
groupId: number // int64
}
export namespace APIGetGroupRelays {
export type Response = CR.GroupRelays | CR.ChatCmdError
export function cmdString(self: APIGetGroupRelays): string {
return '/_get relays #' + self.groupId
}
}
// Update group profile.
// Network usage: background.
export interface APIUpdateGroupProfile {
@@ -28,6 +28,7 @@ export type ChatResponse =
| CR.GroupLinkDeleted
| CR.GroupCreated
| CR.PublicGroupCreated
| CR.GroupRelays
| CR.GroupMembers
| CR.GroupUpdated
| CR.GroupsList
@@ -80,6 +81,7 @@ export namespace CR {
| "groupLinkDeleted"
| "groupCreated"
| "publicGroupCreated"
| "groupRelays"
| "groupMembers"
| "groupUpdated"
| "groupsList"
@@ -255,6 +257,13 @@ export namespace CR {
groupRelays: T.GroupRelay[]
}
export interface GroupRelays extends Interface {
type: "groupRelays"
user: T.User
groupInfo: T.GroupInfo
groupRelays: T.GroupRelay[]
}
export interface GroupMembers extends Interface {
type: "groupMembers"
user: T.User
@@ -2534,6 +2534,7 @@ export interface GroupMember {
updatedAt: string // ISO-8601 timestamp
supportChat?: GroupSupportChat
memberPubKey?: string
relayLink?: string
}
export interface GroupMemberAdmission {
@@ -2617,7 +2618,7 @@ export interface GroupProfile {
export interface GroupRelay {
groupRelayId: number // int64
groupMemberId: number // int64
userChatRelayId: number // int64
userChatRelay: UserChatRelay
relayStatus: RelayStatus
relayLink?: string
}
@@ -4549,6 +4550,17 @@ export interface User {
userChatRelay: boolean
}
export interface UserChatRelay {
chatRelayId: number // int64
address: string
name: string
domains: string[]
preset: boolean
tested?: boolean
enabled: boolean
deleted: boolean
}
export interface UserContact {
userContactLinkId: number // int64
connReqContact: string
@@ -0,0 +1,506 @@
# Channels on iOS — Product Plan
## Contents
1. [Overview](#1-overview)
2. [Screens](#2-screens)
- 2.1 [Chat List](#21-chat-list)
- 2.2 [Channel Messages & Compose](#22-channel-messages--compose)
- 2.3 [Channel Creation](#23-channel-creation)
- 2.4 [Channel Info](#24-channel-info)
- 2.5 [Chat Relay Management (Network & Servers)](#25-chat-relay-management-network--servers)
- 2.6 [Joining a Channel](#26-joining-a-channel)
3. [Implementation Order](#3-implementation-order)
---
## 1. Overview
### What
Channels are one-to-many broadcast groups where messages flow **owner → chat relays → subscribers**. Unlike regular groups (N-to-N connections), channels use chat relay infrastructure to scale delivery — an owner sends once, chat relays fan out to all subscribers.
Technically, a channel is a group with `useRelays = true`. All subscribers are observers (read-only). The owner posts as the channel identity.
### Why
Regular SimpleX groups require direct connections between all members. While there is no hard technical limit, in practice large groups of even several hundred members become very inefficient — group state desynchronizes, delivery becomes inefficient and unreliable, and the experience degrades. Channels solve the broadcast use case: organizations, projects, and individuals publishing to large audiences while preserving SimpleX's privacy model (no user identifiers, relay-mediated delivery).
### For Whom
**Channel owners** — creators who want to broadcast to a large audience. They create channels, configure chat relays, post content. Their problem: no way to efficiently reach many people on SimpleX because large groups work badly in practice.
**Channel subscribers** — readers who want to follow public content. They join via link and receive messages through chat relays. Their problem: can't follow public channels/announcements on SimpleX.
---
## 2. Screens
### 2.1 Chat List
New icon (`antenna.radiowaves.left.and.right`) to differentiate channels.
```
┌────────────────────────────────────────┐
│ [👥] Team Chat 3:42 PM │
│ alice: Hey everyone... ● 1 │
├────────────────────────────────────────┤
│ [📡] SimpleX News 3:38 PM │
│ Latest update about... ● 3 │
├────────────────────────────────────────┤
│ [👤] Bob 2:15 PM │
│ See you tomorrow ✓✓ │
└────────────────────────────────────────┘
```
Chat header uses channel icon when no profile image, same as groups:
```
┌────────────────────────────────────────┐
│ < [📡] SimpleX News ··· │
└────────────────────────────────────────┘
```
---
### 2.2 Channel Messages & Compose
Messages render with channel avatar + channel name as sender (via existing `showGroupAsSender` path). Consecutive messages group without repeating avatar/name.
**Subscriber view** — compose disabled with "you are subscriber" label (vs. "you are observer" in groups):
```
┌────────────────────────────────────────┐
│ < [📡] SimpleX News ··· │
├────────────────────────────────────────┤
│ │
│ [📡] SimpleX News │
│ ┌──────────────────────────────────┐ │
│ │ We're excited to announce v7.0! │ │
│ │ New channel feature allows... │ │
│ │ 3:42 PM │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Check out the blog post: │ │
│ │ simplex.chat/blog/v7 │ │
│ │ 3:45 PM │ │
│ └──────────────────────────────────┘ │
│ │
├────────────────────────────────────────┤
│ you are subscriber │
└────────────────────────────────────────┘
```
**Owner view** — compose field shows "Broadcast" placeholder. Always sends `asGroup=true` (MVP). Backend also supports sending "as member" (like in regular groups), but this will not be available in MVP UI.
```
├────────────────────────────────────────┤
│ ┌───────────────────────────────┐ │
│ 📎 │ Broadcast ➤ │ │
│ └───────────────────────────────┘ │
└────────────────────────────────────────┘
```
**Note**: If all chat relays are removed or stop serving the channel, this won't be visible in the UI in MVP.
---
### 2.3 Channel Creation
Entry point: "Create channel" in New Chat menu, after "Create group".
```
┌────────────────────────────────────────┐
│ New message │
├────────────────────────────────────────┤
│ 🔗 Create 1-time link > │
│ 📷 Scan / Paste link > │
│ 👥 Create group > │
│ 📡 Create channel > │
├────────────────────────────────────────┤
│ 📦 Archived contacts > │
└────────────────────────────────────────┘
```
#### Step 1 — Channel profile
```
┌────────────────────────────────────────┐
│ Cancel Create channel │
├────────────────────────────────────────┤
│ [ 📷 ] │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Enter channel name... │ │
│ └──────────────────────────────────┘ │
│ │
│ Configure relays... > │
│ │
│ Your profile will be shared with │
│ chat relays and subscribers. │
│ Random relays will be selected from │
│ the list of enabled chat relays. │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Create channel │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
```
"Configure relays..." opens Network & Servers view (full settings view) where the user can enable/disable chat relays globally.
There is no explicit relay selection — the app randomly selects from enabled chat relays, same as for SMP/XFTP servers.
> **API note**: Currently `apiNewPublicGroup` takes an explicit list of chat relay IDs. Either the API should be reworked to select relays automatically (consistent with SMP/XFTP server selection), or the UI should randomly select from enabled relays and pass the IDs.
"Create channel" disabled when name is invalid or no relays enabled.
#### Step 2 — Relay connection progress
After tapping "Create channel", chat relays are selected automatically and `apiNewPublicGroup` sends relay invitations. Progress shown as a progress bar with label.
```
┌────────────────────────────────────────┐
│ Creating channel... │
├────────────────────────────────────────┤
│ [ 📷 ] │
│ SimpleX News │
│ │
│ [████████████░░░░░░░░░░░░░░░░░░░░░] │
│ 1/3 relays connected │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Channel link │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
```
Tap progress label to expand relay list:
```
│ [████████████░░░░░░░░░░░░░░░░░░░░░] │
│ ▼ 1/3 relays connected │
│ relay1.simplex.im ✓ Active │
│ relay2.simplex.im Connecting │
│ relay3.simplex.im Connecting │
```
"Channel link" button enabled when ≥1 relay is active. If tapped while relays are still connecting, warning alert: "Not all relays have connected yet. Channel will start working with N relays. Proceed?" — Proceed / Wait.
#### Step 3 — Channel link
Shown after tapping "Channel link" or auto-transition when all relays active. Standard `GroupLinkView` with QR code + share (same as group creation).
```
┌────────────────────────────────────────┐
│ Back Channel link Continue │
├────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ [ QR CODE ] │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ https://simplex.chat/... │ │
│ └──────────────────────────────────┘ │
│ │
│ ^ Share link │
└────────────────────────────────────────┘
```
#### Failure modes (inline on Step 2)
- **API call fails** (sync — relay invitation send failed): Alert "Error creating channel" + error detail. Retry / Cancel.
- **Partial relay error** (async — some relays don't connect): Progress shows "2/3 relays connected, 1 failed". Expanded view: failed relay with red ● Error. "Channel link" enabled — channel works with fewer relays.
- **All relays error** (async): Progress shows "0/3 relays connected, 3 failed" in red. Alert with Retry / Cancel.
---
### 2.4 Channel Info
Extends `GroupChatInfoView` with conditional sections for `useRelays = true`.
**Design rationale:** Owners/subscribers lists live in a sub-view (not inline) to match patterns familiar from other messengers and reduce main info screen clutter.
#### Owner view
```
┌────────────────────────────────────────┐
│ Done SimpleX News Edit │
├────────────────────────────────────────┤
│ [📡 avatar] │
│ SimpleX News │
│ │
│ Set chat name... │
├────────────────────────────────────────┤
│ 🔍 Search │ 🔇 Mute │
├────────────────────────────────────────┤
│ Channel link > │
│ Owners & subscribers > │
├────────────────────────────────────────┤
│ Edit channel profile > │
│ Welcome message > │
├────────────────────────────────────────┤
│ Chat theme > │
│ Delete messages after > │
├────────────────────────────────────────┤
│ Chat relays > │
│ Clear chat │
│ Delete channel │
└────────────────────────────────────────┘
```
No "Leave channel" for single (last) owner.
Post-MVP: "Chats with subscribers" navigation link in section 1 for subscriber support.
TBC: share link button in action buttons row.
#### Subscriber view
```
┌────────────────────────────────────────┐
│ Done SimpleX News │
├────────────────────────────────────────┤
│ [📡 avatar] │
│ SimpleX News │
│ │
│ Set chat name... │
├────────────────────────────────────────┤
│ 🔍 Search │ 🔇 Mute │
├────────────────────────────────────────┤
│ Channel link > │
│ Owners > │
├────────────────────────────────────────┤
│ Welcome message > │
├────────────────────────────────────────┤
│ Chat theme > │
│ Delete messages after > │
├────────────────────────────────────────┤
│ Chat relays > │
│ Clear chat │
│ Leave channel │
└────────────────────────────────────────┘
```
Differences from owner view:
- **Owners & subscribers**: replaced with **Owners**
- **Edit channel profile**: hidden
- **Delete channel**: replaced with **Leave channel**
#### Owners & subscribers sub-view
Separate sub-view following familiar channel UI patterns from other messengers to increase adoption.
**Owner's view** ("Owners & subscribers"):
```
┌────────────────────────────────────────┐
│ < Back Owners & subscribers │
├────────────────────────────────────────┤
│ OWNERS │
│ alice (you) > │
├────────────────────────────────────────┤
│ 150 SUBSCRIBERS │
│ bob > │
│ charlie > │
│ ... │
└────────────────────────────────────────┘
```
**Subscriber's view** ("Owners"):
```
┌────────────────────────────────────────┐
│ < Back Owners │
├────────────────────────────────────────┤
│ OWNERS │
│ alice > │
└────────────────────────────────────────┘
```
> **Protocol note**: Correct subscriber and owner lists with counts must be implemented for MVP. This requires protocol changes to support relay-reported subscriber counts and subscriber list synchronization. See launch plan §3.3.
#### Chat relays sub-view
```
┌────────────────────────────────────────┐
│ < Back Chat relays │
├────────────────────────────────────────┤
│ relay1.simplex.im ● Active │
│ relay2.simplex.im ● Active │
│ relay3.simplex.im ● Active │
│ │
│ Chat relays forward messages to │
│ channel subscribers. │
└────────────────────────────────────────┘
```
Read-only for MVP. In future, owner will be able to manage (add, remove) relays from this view.
Relay statuses differ by role:
- **Owner**: based on `RelayStatus` — New, Invited, Accepted, Active
- **Subscriber**: based on connection state — Connecting, Connected, Error (TBC: new type or inferred from connection status)
---
### 2.5 Chat Relay Management (Network & Servers)
Chat relays follow the same placement pattern as SMP/XFTP servers: preset relays appear inside each operator page, custom relays appear in "Your servers" page.
#### Operator page (e.g. SimpleX Chat)
New "Chat relays" section added after "Operator" section, before message and file server sections:
```
┌────────────────────────────────────────┐
│ < Back SimpleX Chat servers │
├────────────────────────────────────────┤
│ OPERATOR │
│ ... │
├────────────────────────────────────────┤
│ CHAT RELAYS │
│ relay1.simplex.im ✓ │
│ relay2.simplex.im ✓ │
│ relay3.simplex.im ✓ │
│ │
│ Chat relays forward messages in │
│ channels you create. │
├────────────────────────────────────────┤
│ (message server sections) │
│ (file server sections) │
├────────────────────────────────────────┤
│ Test servers │
└────────────────────────────────────────┘
```
#### Your servers page
New "Chat relays" section before "Message servers":
```
┌────────────────────────────────────────┐
│ < Back Your servers │
├────────────────────────────────────────┤
│ CHAT RELAYS │
│ myrelay.example.com ✗ │
│ │
│ Chat relays forward messages in │
│ channels you create. │
├────────────────────────────────────────┤
│ MESSAGE SERVERS │
│ ... │
├────────────────────────────────────────┤
│ MEDIA & FILE SERVERS │
│ ... │
├────────────────────────────────────────┤
│ Add server... │
│ Test servers │
│ How to use your servers > │
└────────────────────────────────────────┘
```
#### Relay detail view
Follows `ProtocolServerView` pattern. Preset: read-only address + test + enable toggle. Custom: editable address + test + enable + delete. TBC editable name (present in backend).
```
┌────────────────────────────────────────┐
│ < Back relay1.simplex.im │
├────────────────────────────────────────┤
│ RELAY ADDRESS │
│ ┌──────────────────────────────────┐ │
│ │ https://relay1.simplex.im/... │ │
│ └──────────────────────────────────┘ │
│ │
│ Test relay ✓ │
│ Use for new channels [ON] │
├────────────────────────────────────────┤
│ Delete relay │
└────────────────────────────────────────┘
```
If all relays are disabled: footer warning "No chat relays enabled. Channels require at least one relay."
---
### 2.6 Joining a Channel
User taps channel link → pre-join view.
#### Pre-join
```
┌────────────────────────────────────────┐
│ < [📡] SimpleX News ··· │
├────────────────────────────────────────┤
│ │
│ [📡 avatar] │
│ SimpleX News │
│ │
│ 3 relays ▶ │
│ ┌──────────────────────────────────┐ │
│ │ Join channel │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
```
Relay count visible (from link data). Tapping "3 relays" expands to show relay hostnames.
**Why:** Subscriber can decide whether to join based on which relays are used.
#### Connecting
After "Join channel", relay connections proceed. Progress bar shown above "you are subscriber" — channel already functions with even a single relay connected.
```
┌────────────────────────────────────────┐
│ < [📡] SimpleX News ··· │
├────────────────────────────────────────┤
│ │
│ (chat area — welcome message etc.) │
│ │
├────────────────────────────────────────┤
│ [████████████░░░░░░░░░░░░░░░░░░░░░] │
│ Connecting... 1/3 relays │
├────────────────────────────────────────┤
│ you are subscriber │
└────────────────────────────────────────┘
```
Tap progress label to expand:
```
├────────────────────────────────────────┤
│ [████████████░░░░░░░░░░░░░░░░░░░░░] │
│ ▼ Connecting... 1/3 relays │
│ relay1.simplex.im ✓ Connected │
│ relay2.simplex.im Connecting │
│ relay3.simplex.im Connecting │
├────────────────────────────────────────┤
│ you are subscriber │
└────────────────────────────────────────┘
```
All connected → progress bar disappears.
#### Failure modes (inline)
- **Sync failure** (all relays fail on connect call): Alert "Failed to join channel" + Retry / Cancel.
- **Partial failure**: "2/3 relays connected, 1 failed". Channel works. Expanded view shows failed relay with red indicator.
- **All relays fail async**: Red error bar "Channel not connected". TBC: programmatic retry, or only failure indication.
---
## 3. Implementation Order
| # | Screen | Backend Dependency | Complexity |
|---|--------|--------------------|------------|
| 1 | Chat List — channel icon | None | Low |
| 2 | Channel Messages — `CIChannelRcv` rendering | None | Low |
| 3 | Owner Compose — "Broadcast" placeholder + `asGroup` | None | Low |
| 4 | Channel Info — extended `GroupChatInfoView` | Subscriber/owner lists: protocol changes (§3.3) | Medium |
| 5 | Chat Relay Management — Network & Servers | `APITestChatRelay` (launch plan §2.5) | Medium |
| 6 | Channel Creation — 3-step flow | Relay state events (launch plan §3.2) | High |
| 7 | Join Channel — progress bar + relay states | Relay state events (launch plan §3.2) | Medium |
Items 13 have no backend blockers and can start immediately. Item 4 requires protocol changes for subscriber/owner lists and counts. Items 57 depend on backend work.
+2
View File
@@ -37,6 +37,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Types
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Chat.Util (shuffle)
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.Messaging.Agent
@@ -113,6 +114,7 @@ defaultChatConfig =
highlyAvailable = False,
deliveryWorkerDelay = 0,
deliveryBucketSize = 10000,
channelSubscriberRole = GRObserver,
deviceNameForRemote = "",
remoteCompression = True,
chatHooks = defaultChatHooks
+13 -2
View File
@@ -157,6 +157,7 @@ data ChatConfig = ChatConfig
ciExpirationInterval :: Int64, -- microseconds
deliveryWorkerDelay :: Int64, -- microseconds
deliveryBucketSize :: Int,
channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays
highlyAvailable :: Bool,
deviceNameForRemote :: Text,
remoteCompression :: Bool,
@@ -509,8 +510,9 @@ data ChatCommand
| ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text}
| APINewGroup {userId :: UserId, incognito :: IncognitoEnabled, groupProfile :: GroupProfile}
| NewGroup IncognitoEnabled GroupProfile
-- TODO [relays] owner: TBC group link's default member role for APINewPublicGroup
-- TODO [relays] starting role should be communicated in protocol from owner to relays (see channelSubscriberRole config)
| APINewPublicGroup {userId :: UserId, incognito :: IncognitoEnabled, relayIds :: NonEmpty Int64, groupProfile :: GroupProfile}
| APIGetGroupRelays {groupId :: GroupId}
| NewPublicGroup IncognitoEnabled (NonEmpty Int64) GroupProfile
| AddMember GroupName ContactName GroupMemberRole
| JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter}
@@ -638,6 +640,12 @@ allowRemoteCommand = \case
ExecAgentStoreSQL _ -> False
_ -> True
data RelayConnectionResult = RelayConnectionResult
{ relayMember :: GroupMember,
relayError :: Maybe ChatError
}
deriving (Show)
data ChatResponse
= CRActiveUser {user :: User}
| CRUsersList {users :: [UserInfo]}
@@ -687,6 +695,7 @@ data ChatResponse
| CRWelcome {user :: User}
| CRGroupCreated {user :: User, groupInfo :: GroupInfo}
| CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]}
| CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]}
| CRGroupMembers {user :: User, group :: Group}
| CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]}
-- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]}
@@ -715,7 +724,7 @@ data ChatResponse
| CRSentConfirmation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile}
| CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile}
| CRStartedConnectionToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile}
| CRStartedConnectionToGroup {user :: User, groupInfo :: GroupInfo, customUserProfile :: Maybe Profile}
| CRStartedConnectionToGroup {user :: User, groupInfo :: GroupInfo, customUserProfile :: Maybe Profile, relayResults :: [RelayConnectionResult]}
| CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile}
| CRItemsReadForChat {user :: User, chatInfo :: AChatInfo}
| CRContactDeleted {user :: User, contact :: Contact}
@@ -1664,6 +1673,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent)
$(JQ.deriveJSON defaultJSON ''RelayConnectionResult)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent)
+35 -18
View File
@@ -1880,7 +1880,7 @@ processChatCommand vr nm = \case
groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences
groupProfile = businessGroupProfile profile groupPreferences
gVar <- asks random
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False GRMember
hostMember <- maybe (throwCmdError "no host member") pure hostMember_
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
let cd = CDGroupRcv gInfo Nothing hostMember
@@ -1909,8 +1909,9 @@ processChatCommand vr nm = \case
let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData
welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId
let useRelays = not direct
subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember
gVar <- asks random
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays subRole
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_
cInfo = GroupChat gInfo Nothing
@@ -2017,15 +2018,17 @@ processChatCommand vr nm = \case
Just (_, _, Left e) -> throwError e
_ -> throwChatError $ CEException "no relay connection results" -- shouldn't happen
else do
withFastStore' $ \db -> setPreparedGroupStartedConnection db groupId
gInfo'' <- withFastStore $ \db -> do
liftIO $ setPreparedGroupStartedConnection db groupId
getGroupInfo db vr user groupId
-- Async retry failed relays with temporary errors
let retryable = [(l, m) | r@(l, m, _) <- failed, isTempErr r]
void $ mapConcurrently (uncurry $ retryRelayConnectionAsync gInfo') retryable
-- TODO [relays] member: TBC response type for UI to display state of relays connection
-- TODO - differentiate success, temporary failure, permanent failure
-- TODO - possibly, additional status on relay member record
pure $ CRStartedConnectionToGroup user gInfo' incognitoProfile
let relayResults = [RelayConnectionResult m (leftToMaybe r) | (_, m, r) <- rs]
pure $ CRStartedConnectionToGroup user gInfo'' incognitoProfile relayResults
where
leftToMaybe (Left e) = Just e
leftToMaybe _ = Nothing
isTempErr = \case
(_, _, Left ChatErrorAgent {agentError = e}) -> temporaryOrHostError e
_ -> False
@@ -2075,14 +2078,17 @@ processChatCommand vr nm = \case
forM_ msg_ $ \(sharedMsgId, mc) -> do
ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing
toView $ CEvtNewChatItems user [ci]
pure $ CRStartedConnectionToGroup user gInfo' customUserProfile
pure $ CRStartedConnectionToGroup user gInfo' customUserProfile []
CVRConnectedContact _ct -> throwChatError $ CEException "contact already exists when connecting to group"
APIConnect userId incognito (Just acl) -> withUserId userId $ \user -> case acl of
ACCL SCMInvitation ccLink -> do
(conn, incognitoProfile) <- connectViaInvitation user incognito ccLink Nothing
let pcc = mkPendingContactConnection conn $ Just ccLink
pure $ CRSentConfirmation user pcc incognitoProfile
ACCL SCMContact ccLink ->
ACCL SCMContact ccLink@(CCLink _ sLnk) -> do
case sLnk of
Just (CSLContact _ CCTChannel _ _) -> throwChatError $ CECommandError "channel links must be connected via APIConnectPreparedGroup"
_ -> pure ()
connectViaContact user Nothing incognito ccLink Nothing Nothing >>= \case
CVRConnectedContact ct -> pure $ CRContactAlreadyExists user ct
CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile
@@ -2348,7 +2354,7 @@ processChatCommand vr nm = \case
let crClientData = encodeJSON $ CRDataGroup groupLinkId
-- prepare link with sharedGroupId as linkEntityId (no server request)
((_, rootPrivKey), ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) (Just sharedGroupId) True (Just crClientData)
ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink
ccLink' <- createdChannelLink <$> shortenCreatedLink ccLink
sLnk <- case toShortLinkContact ccLink' of
Just sl -> pure sl
Nothing -> throwChatError $ CEException "failed to create relayed group link: no short link"
@@ -2362,13 +2368,21 @@ processChatCommand vr nm = \case
connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOff subMode
let groupKeys = GroupKeys {sharedGroupId = B64UrlByteString sharedGroupId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey}
setupLink gInfo = do
gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId GRMember subMode
-- TODO [relays] starting role should be communicated in protocol from owner to relays
subRole <- asks $ channelSubscriberRole . config
gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId subRole subMode
relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds)
groupRelays <- addRelays user gInfo sLnk relays
pure (gLink, groupRelays)
pure (groupProfile', memberId, groupKeys, setupLink)
NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} ->
processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile
APIGetGroupRelays groupId -> withUser $ \user -> do
(gInfo, relays) <- withFastStore $ \db -> do
gInfo <- getGroupInfo db vr user groupId
relays <- liftIO $ getGroupRelays db gInfo
pure (gInfo, relays)
pure $ CRGroupRelays user gInfo relays
APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do
-- TODO for large groups: no need to load all members to determine if contact is a member
(group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId
@@ -3801,10 +3815,7 @@ processChatCommand vr nm = \case
CLFull cReq -> do
plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError)
pure (ACCL SCMContact $ CCLink cReq Nothing, plan)
CLShort l@(CSLContact _ ct _ _) -> do
let l' = serverShortLink l
con cReq = ACCL SCMContact $ CCLink cReq (Just l')
gPlan (cReq, g) = if memberRemoved (membership g) then Nothing else Just (con cReq, CPGroupLink (GLPKnown g))
CLShort l@(CSLContact _ ct _ _) ->
case ct of
CCTContact ->
knownLinkPlans >>= \case
@@ -3825,7 +3836,14 @@ processChatCommand vr nm = \case
getContactViaShortLinkToConnect db vr user l' >>= \case
Just (cReq, ct') -> pure $ if contactDeleted ct' then Nothing else Just (con cReq, CPContactAddress (CAPKnown ct'))
Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l'
CCTGroup ->
CCTGroup -> groupShortLinkPlan
CCTChannel -> groupShortLinkPlan
CCTRelay -> throwCmdError "chat relay links are not supported in this version"
where
l' = serverShortLink l
con cReq = ACCL SCMContact $ CCLink cReq (Just l')
gPlan (cReq, g) = if memberRemoved (membership g) then Nothing else Just (con cReq, CPGroupLink (GLPKnown g))
groupShortLinkPlan =
knownLinkPlans >>= \case
Just r -> pure r
Nothing -> do
@@ -3840,8 +3858,6 @@ processChatCommand vr nm = \case
liftIO (getGroupInfoViaUserShortLink db vr user l') >>= \case
Just (cReq, g) -> pure $ Just (con cReq, CPGroupLink (GLPOwnLink g))
Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l'
CCTChannel -> throwCmdError "channel links are not supported in this version"
CCTRelay -> throwCmdError "chat relay links are not supported in this version"
connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse
connectWithPlan user@User {userId} incognito ccLink plan
| connectionPlanProceed plan = do
@@ -4762,6 +4778,7 @@ chatCommandP =
"/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP),
("/public group" <|> "/pg") *> (NewPublicGroup <$> incognitoP <* " relays=" <*> strP <* A.space <* char_ '#' <*> groupProfile),
"/_public group " *> (APINewPublicGroup <$> A.decimal <*> incognitoOnOffP <*> _strP <* A.space <*> jsonP),
"/_get relays #" *> (APIGetGroupRelays <$> A.decimal),
("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)),
("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)),
"/accept member " *> char_ '#' *> (AcceptMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)),
+7 -1
View File
@@ -1313,7 +1313,7 @@ setGroupLinkDataAsync user gInfo gLink = do
groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData)
groupLinkData gInfo@GroupInfo {groupProfile} GroupLink {groupLinkId} groupRelays =
let direct = not $ useRelays' gInfo
relays = mapMaybe relayLink groupRelays
relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays
userData = encodeShortLinkData $ GroupShortLinkData groupProfile
userLinkData = UserContactLinkData UserContactData {direct, owners = [], relays, userData}
crClientData = encodeJSON $ CRDataGroup groupLinkId
@@ -1370,6 +1370,12 @@ createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> sho
toShortGroupLink :: ShortLinkContact -> ShortLinkContact
toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k
createdChannelLink :: CreatedLinkContact -> CreatedLinkContact
createdChannelLink (CCLink cReq shortLink) = CCLink cReq (toShortChannelLink <$> shortLink)
toShortChannelLink :: ShortLinkContact -> ShortLinkContact
toShortChannelLink (CSLContact sch _ srv k) = CSLContact sch CCTChannel srv k
createdRelayLink :: CreatedLinkContact -> CreatedLinkContact
createdRelayLink (CCLink cReq shortLink) = CCLink cReq (toShortRelayLink <$> shortLink)
+33 -21
View File
@@ -62,6 +62,7 @@ import Simplex.Chat.Store.Messages
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.RelayRequests
import Simplex.Chat.Store.Shared
import Simplex.Chat.Operators
import Simplex.Chat.Types
import Simplex.Chat.Types.MemberRelations
import Simplex.Chat.Types.Preferences
@@ -1392,23 +1393,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
-- TODO [short links] deduplicate request by xContactId?
gInfo <- withStore $ \db -> getGroupInfo db vr user groupId
acceptMember_ <- asks $ acceptMember . chatHooks . config
maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case
Right (acceptance, useRole)
| v < groupFastLinkJoinVersion ->
messageError "processContactConnMessage: chat version range incompatible for accepting group join request"
| otherwise -> do
let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
Left rjctReason
| v < groupJoinRejectVersion ->
messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked"
| otherwise -> do
mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason
toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason
if useRelays' gInfo
then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)"
else do
acceptMember_ <- asks $ acceptMember . chatHooks . config
maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case
Right (acceptance, useRole)
| v < groupFastLinkJoinVersion ->
messageError "processContactConnMessage: chat version range incompatible for accepting group join request"
| otherwise -> do
let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
Left rjctReason
| v < groupJoinRejectVersion ->
messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked"
| otherwise -> do
mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason
toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason
xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM ()
xGrpRelayInv invId chatVRange groupRelayInv = do
(_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange
@@ -2892,7 +2896,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
brokerTs
| membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency
| otherwise = do
(bm, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memId Nothing
unknownRole <- unknownMemberRole gInfo
(bm, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memId Nothing unknownRole
let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm
if
| blockedByAdmin == mrsBlocked restriction -> pure Nothing
@@ -2990,6 +2995,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
| useRelays' gInfo = isRelay m
| otherwise = memberRole' m >= GRAdmin
unknownMemberRole :: GroupInfo -> CM GroupMemberRole
unknownMemberRole gInfo
| useRelays' gInfo = asks $ channelSubscriberRole . config
| otherwise = pure GRAuthor
xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
xGrpLeave gInfo m msg brokerTs = do
deleteMemberConnection m
@@ -3138,7 +3148,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName)
case memberId_ of
Just memberId -> do
(author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName_
unknownRole <- unknownMemberRole gInfo
(author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName_ unknownRole
when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author
processForwardedMsg (Just author)
Nothing -> processForwardedMsg Nothing
@@ -3553,9 +3564,10 @@ runRelayRequestWorker a Worker {doWork} = do
createRelayLink gi@GroupInfo {groupProfile} = do
-- TODO [relays] relay: set relay link data
-- TODO - link data: relay key for group, relay identity (profile, certificate, relay identity key)
-- TODO - TBC link's member role - owner to communicate in invitation?
-- TODO - starting role should be communicated in protocol from owner to relays
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
subMode <- chatReadVar subscriptionMode
subRole <- asks $ channelSubscriberRole . config
let userData = encodeShortLinkData $ GroupShortLinkData groupProfile
userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData}
crClientData = encodeJSON $ CRDataGroup groupLinkId
@@ -3565,7 +3577,7 @@ runRelayRequestWorker a Worker {doWork} = do
Just sl -> pure sl
Nothing -> throwChatError $ CEException "failed to create relay link: no short link"
gVar <- asks random
void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId GRMember subMode
void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode
pure sLnk
acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM ()
acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do
+14
View File
@@ -47,6 +47,7 @@ import Data.Time.Clock (UTCTime, nominalDay)
import Language.Haskell.TH.Syntax (lift)
import Simplex.Chat.Operators.Conditions
import Simplex.Chat.Types (ShortLinkContact, User)
import Simplex.Chat.Types.Shared (RelayStatus)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Protocol (sameShortLinkContact)
import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_)
@@ -271,6 +272,17 @@ data UserChatRelay' s = UserChatRelay
}
deriving (Show)
deriving instance Eq UserChatRelay
data GroupRelay = GroupRelay
{ groupRelayId :: Int64,
groupMemberId :: Int64,
userChatRelay :: UserChatRelay,
relayStatus :: RelayStatus,
relayLink :: Maybe ShortLinkContact
}
deriving (Eq, Show)
-- for setting chat relays via CLI API
data CLINewRelay = CLINewRelay
{ address :: ShortLinkContact,
@@ -585,3 +597,5 @@ instance FromJSON UpdatedUserOperatorServers where
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USW") ''UserServersWarning)
$(JQ.deriveJSON defaultJSON ''GroupRelay)
+2 -2
View File
@@ -149,12 +149,12 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link,
-- from GroupMember
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
JOIN groups g ON g.group_id = m.group_id
+49 -29
View File
@@ -193,6 +193,7 @@ import Simplex.Chat.Types.UITheme
import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), InvitationId, UserId)
import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow)
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
import Simplex.Messaging.Agent.Store.Entity (DBEntityId)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff)
@@ -208,11 +209,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519)
type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact)
toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember
toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) =
Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey))
toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) =
Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink))
toMaybeGroupMember _ _ = Nothing
createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink
@@ -329,7 +330,7 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC
-- | creates completely new group with a single member - the current user
createNewGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> ExceptT StoreError IO GroupInfo
createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys = ExceptT $ do
let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile
let GroupProfile {displayName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission} = groupProfile
fullGroupPreferences = mergeGroupPreferences groupPreferences
currentTs <- getCurrentTime
customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile
@@ -344,8 +345,14 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays
groupId <- liftIO $ do
DB.execute
db
"INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)"
(displayName, fullName, shortDescr, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs)
[sql|
INSERT INTO group_profiles
(display_name, full_name, short_descr, description, image, group_link,
user_id, preferences, member_admission, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|]
((displayName, fullName, shortDescr, description, image, groupLink)
:. (userId, groupPreferences, memberAdmission, currentTs, currentTs))
profileId <- insertedRowId db
DB.execute
db
@@ -532,7 +539,8 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe
createdAt,
updatedAt = createdAt,
supportChat = Nothing,
memberPubKey
memberPubKey,
relayLink = Nothing
}
where
memberChatVRange@(VersionRange minV maxV) = vr
@@ -581,8 +589,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile {
DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId)
DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId)
createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember)
createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays = do
createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember)
createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole = do
currentTs <- liftIO getCurrentTime
let prepared = Just (connLinkToConnect, welcomeSharedMsgId)
(groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing currentTs
@@ -594,7 +602,7 @@ createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile bu
if useRelays
then liftIO $ MemberId <$> encodedRandomBytes gVar 12
else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id"
let userMember = MemberIdRole userMemberId GRMember
let userMember = MemberIdRole userMemberId userMemberRole
-- TODO [member keys] user key must be included here. Should key be added when group is prepared?
membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr
hostMember_ <- forM hostMemberId_ $ getGroupMember db vr user groupId
@@ -822,13 +830,19 @@ createGroupViaLink'
createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> UTCTime -> ExceptT StoreError IO (GroupId, Text)
createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus currentTs = ExceptT $ do
let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile
let GroupProfile {displayName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission} = groupProfile
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
liftIO $ do
DB.execute
db
"INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)"
(displayName, fullName, shortDescr, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs)
[sql|
INSERT INTO group_profiles
(display_name, full_name, short_descr, description, image, group_link,
user_id, preferences, member_admission, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|]
((displayName, fullName, shortDescr, description, image, groupLink)
:. (userId, groupPreferences, memberAdmission, currentTs, currentTs))
profileId <- insertedRowId db
DB.execute
db
@@ -1072,13 +1086,13 @@ getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId =
(groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?")
(groupId, memberId)
getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe ContactName -> ExceptT StoreError IO (GroupMember, Bool)
getCreateUnknownGMByMemberId db vr user gInfo memberId memberName = do
getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe ContactName -> GroupMemberRole -> ExceptT StoreError IO (GroupMember, Bool)
getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole = do
liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case
Right m -> pure (m, False)
Left (SEGroupMemberNotFoundByMemberId _) -> do
let name = fromMaybe (nameFromMemberId memberId) memberName
m <- createNewUnknownGroupMember db vr user gInfo memberId name
m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole
pure (m, True)
Left e -> throwError e
@@ -1215,7 +1229,8 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId,
createdAt,
updatedAt = createdAt,
supportChat = Nothing,
memberPubKey = Nothing
memberPubKey = Nothing,
relayLink = Nothing
}
where
insertMember_ = do
@@ -1256,7 +1271,7 @@ getGroupRelayById db relayId =
ExceptT . firstRow toGroupRelay (SEGroupRelayNotFound relayId) $
DB.query
db
(groupRelayQuery <> " WHERE group_relay_id = ?")
(groupRelayQuery <> " WHERE gr.group_relay_id = ?")
(Only relayId)
getGroupRelayByGMId :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO GroupRelay
@@ -1264,7 +1279,7 @@ getGroupRelayByGMId db groupMemberId =
ExceptT . firstRow toGroupRelay (SEGroupRelayNotFoundByMemberId groupMemberId) $
DB.query
db
(groupRelayQuery <> " WHERE group_member_id = ?")
(groupRelayQuery <> " WHERE gr.group_member_id = ?")
(Only groupMemberId)
getGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay]
@@ -1272,19 +1287,23 @@ getGroupRelays db GroupInfo {groupId} =
map toGroupRelay
<$> DB.query
db
(groupRelayQuery <> " WHERE group_id = ?")
(groupRelayQuery <> " WHERE gr.group_id = ?")
(Only groupId)
groupRelayQuery :: Query
groupRelayQuery =
[sql|
SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link
FROM group_relays
SELECT gr.group_relay_id, gr.group_member_id,
cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
gr.relay_status, gr.relay_link
FROM group_relays gr
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
|]
toGroupRelay :: (Int64, GroupMemberId, Int64, RelayStatus, Maybe ShortLinkContact) -> GroupRelay
toGroupRelay (groupRelayId, groupMemberId, userChatRelayId, relayStatus, relayLink) =
GroupRelay {groupRelayId, groupMemberId, userChatRelayId, relayStatus, relayLink}
toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay
toGroupRelay (groupRelayId, groupMemberId, chatRelayId, address, name, domains, BI preset, tested, BI enabled, BI deleted, relayStatus, relayLink) =
let userChatRelay = UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted}
in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink}
createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember
createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {name} = do
@@ -1873,7 +1892,8 @@ createNewMember_
updatedAt = createdAt,
supportChat = Nothing,
-- TODO [member keys] is it used with relay/public groups?
memberPubKey = Nothing
memberPubKey = Nothing,
relayLink = Nothing
}
checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId)
@@ -2754,8 +2774,8 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do
"UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?"
(BI xGrpLinkMemReceived, currentTs, mId)
createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember
createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do
createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> GroupMemberRole -> ExceptT StoreError IO GroupMember
createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do
currentTs <- liftIO getCurrentTime
let memberProfile = profileFromName memberName
(localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs
@@ -2770,7 +2790,7 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g
peer_chat_min_version, peer_chat_max_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown)
( (groupId, indexInGroup, memberId, unknownMemberRole, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
:. (minV, maxV)
)
+4 -4
View File
@@ -690,7 +690,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe
m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN contacts c ON m.contact_id = c.contact_id
@@ -2994,7 +2994,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
-- quoted ChatItem
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent,
-- quoted GroupMember
@@ -3002,13 +3002,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences,
rm.created_at, rm.updated_at,
rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key,
rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link,
-- deleted by GroupMember
dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category,
dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id,
dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences,
dbm.created_at, dbm.updated_at,
dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key
dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
+25 -2
View File
@@ -921,10 +921,22 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s
| deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False)
| otherwise -> Just s <$ updateProtocolServer db p ts s
upsertOrDeleteCRelay :: AUserChatRelay -> IO (Maybe UserChatRelay)
upsertOrDeleteCRelay (AUCR _ relay@UserChatRelay {chatRelayId, deleted}) = case chatRelayId of
upsertOrDeleteCRelay (AUCR _ relay@UserChatRelay {chatRelayId, address, deleted}) = case chatRelayId of
DBNewEntity
| deleted -> pure Nothing
| otherwise -> Just <$> insertChatRelay db user ts relay
| otherwise -> do
-- When a relay referenced in group_relays is deleted, it's soft-deleted (deleted=1).
-- On re-add with the same address, un-delete the existing row to preserve group_relays FK.
-- Only address is matched — it's the relay's identity. Name and other settings are updated.
-- Re-adding with same name but different address is a different relay and will fail on UNIQUE constraint.
existing <- maybeFirstRow fromOnly $ DB.query db
"SELECT chat_relay_id FROM chat_relays WHERE user_id = ? AND address = ? AND deleted = 1 LIMIT 1"
(userId, address)
case existing of
Just existingId -> do
undeleteRelay existingId relay
pure $ Just (relay :: NewUserChatRelay) {chatRelayId = DBEntityId existingId}
Nothing -> Just <$> insertChatRelay db user ts relay
DBEntityId relayId
| deleted -> do
-- If relay is referenced in group_relays, mark it as deleted instead of deleting
@@ -934,6 +946,17 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s
else DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, relayId, BI False)
pure Nothing
| otherwise -> Just relay <$ updateChatRelay db ts relay
-- Un-delete soft-deleted relay, updating name and settings but keeping the address unchanged.
undeleteRelay :: Int64 -> NewUserChatRelay -> IO ()
undeleteRelay existingId UserChatRelay {name = nm, domains, preset, tested, enabled} =
DB.execute db
[sql|
UPDATE chat_relays
SET name = ?, domains = ?,
preset = ?, tested = ?, enabled = ?, deleted = 0, updated_at = ?
WHERE chat_relay_id = ?
|]
(nm, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, ts, existingId)
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do
+1
View File
@@ -18,6 +18,7 @@ import Data.Text (Text)
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
import Simplex.Chat.Types.Shared
import Simplex.Messaging.Agent.Protocol (InvitationId)
import Simplex.Messaging.Agent.Store.AgentStore (getWorkItem, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.DB as DB
@@ -1197,10 +1197,6 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ?
Plan:
SEARCH connections USING PRIMARY KEY (conn_id=?)
Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ?
Plan:
SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ?
Plan:
SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?)
@@ -1209,6 +1205,10 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ?
Plan:
SEARCH ratchets USING PRIMARY KEY (conn_id=?)
Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ?
Plan:
SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ?
Plan:
SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
@@ -156,12 +156,12 @@ Query:
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link,
-- from GroupMember
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
JOIN groups g ON g.group_id = m.group_id
@@ -1010,7 +1010,7 @@ Query:
m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN contacts c ON m.contact_id = c.contact_id
@@ -1203,6 +1203,14 @@ SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_mem
SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?)
SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?)
Query:
INSERT INTO group_profiles
(display_name, full_name, short_descr, description, image, group_link,
user_id, preferences, member_admission, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
Plan:
Query:
INSERT INTO groups
(group_profile_id, local_display_name, user_id, enable_ntfs,
@@ -1276,7 +1284,7 @@ Query:
m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
-- quoted ChatItem
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent,
-- quoted GroupMember
@@ -1284,13 +1292,13 @@ Query:
rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences,
rm.created_at, rm.updated_at,
rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key,
rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link,
-- deleted by GroupMember
dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category,
dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id,
dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences,
dbm.created_at, dbm.updated_at,
dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key
dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
@@ -1640,6 +1648,15 @@ Query:
Plan:
SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?)
Query:
UPDATE chat_relays
SET name = ?, domains = ?,
preset = ?, tested = ?, enabled = ?, deleted = 0, updated_at = ?
WHERE chat_relay_id = ?
Plan:
SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE connections SET via_contact_uri = NULL, via_contact_uri_hash = NULL, xcontact_id = NULL
WHERE user_id = ? AND via_group_link = 1 AND contact_id IN (
@@ -4992,6 +5009,14 @@ Query:
Plan:
SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE rcv_files
SET to_receive = 1, user_approved_relays = ?, updated_at = ?
WHERE file_id = ?
Plan:
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE remote_controllers
SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key
@@ -5097,7 +5122,7 @@ Query:
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
@@ -5133,7 +5158,7 @@ Query:
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
@@ -5162,7 +5187,7 @@ Query:
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
@@ -5210,7 +5235,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5237,7 +5262,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5256,7 +5281,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5275,7 +5300,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5294,7 +5319,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5313,7 +5338,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5332,7 +5357,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5351,7 +5376,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5370,7 +5395,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5389,7 +5414,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5408,7 +5433,7 @@ Query:
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -5477,25 +5502,37 @@ SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (us
SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?)
Query:
SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link
FROM group_relays
WHERE group_id = ?
SELECT gr.group_relay_id, gr.group_member_id,
cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
gr.relay_status, gr.relay_link
FROM group_relays gr
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
WHERE gr.group_id = ?
Plan:
SEARCH group_relays USING INDEX idx_group_relays_group_id (group_id=?)
SEARCH gr USING INDEX idx_group_relays_group_id (group_id=?)
SEARCH cr USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link
FROM group_relays
WHERE group_member_id = ?
SELECT gr.group_relay_id, gr.group_member_id,
cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
gr.relay_status, gr.relay_link
FROM group_relays gr
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
WHERE gr.group_member_id = ?
Plan:
SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH gr USING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH cr USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link
FROM group_relays
WHERE group_relay_id = ?
SELECT gr.group_relay_id, gr.group_member_id,
cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
gr.relay_status, gr.relay_link
FROM group_relays gr
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
WHERE gr.group_relay_id = ?
Plan:
SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?)
SEARCH gr USING INTEGER PRIMARY KEY (rowid=?)
SEARCH cr USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias
@@ -6466,6 +6503,10 @@ Query: SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1
Plan:
SEARCH settings USING INDEX idx_settings_user_id (user_id=?)
Query: SELECT chat_relay_id FROM chat_relays WHERE user_id = ? AND address = ? AND deleted = 1 LIMIT 1
Plan:
SEARCH chat_relays USING INDEX idx_chat_relays_user_id_address (user_id=? AND address=?)
Query: SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ?
Plan:
SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?)
@@ -6678,6 +6719,10 @@ Query: UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ?
Plan:
SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE chat_relays SET deleted = 1, updated_at = ? WHERE chat_relay_id = ?
Plan:
SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE connections SET auth_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ?
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
+4 -4
View File
@@ -667,7 +667,7 @@ type GroupKeysRow = (Maybe B64UrlByteString, Maybe C.PrivateKeyEd25519, Maybe C.
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519)
type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact)
type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences)
@@ -697,7 +697,7 @@ toGroupKeys = \case
_ -> Nothing
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) =
toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) =
let memberProfile = rowToLocalProfile profileRow
memberSettings = GroupMemberSettings {showMessages}
blockedByAdmin = maybe False mrsBlocked memberRestriction_
@@ -724,7 +724,7 @@ groupMemberQuery =
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
@@ -767,7 +767,7 @@ groupInfoQueryFields =
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link
|]
groupInfoQueryFrom :: Query
+1 -42
View File
@@ -988,26 +988,11 @@ data GroupMember = GroupMember
createdAt :: UTCTime,
updatedAt :: UTCTime,
supportChat :: Maybe GroupSupportChat,
memberPubKey :: Maybe C.PublicKeyEd25519
}
deriving (Eq, Show)
data GroupRelay = GroupRelay
{ groupRelayId :: Int64,
groupMemberId :: GroupMemberId,
userChatRelayId :: Int64, -- ID of configured UserChatRelay
relayStatus :: RelayStatus,
memberPubKey :: Maybe C.PublicKeyEd25519,
relayLink :: Maybe ShortLinkContact
}
deriving (Eq, Show)
data RelayStatus
= RSNew -- only for owner
| RSInvited
| RSAccepted
| RSActive
deriving (Eq, Show)
data RelayRequestData = RelayRequestData
{ relayInvId :: InvitationId,
reqGroupLink :: ShortLinkContact,
@@ -1015,30 +1000,6 @@ data RelayRequestData = RelayRequestData
}
deriving (Eq, Show)
relayStatusText :: RelayStatus -> Text
relayStatusText = \case
RSNew -> "new"
RSInvited -> "invited"
RSAccepted -> "accepted"
RSActive -> "active"
instance TextEncoding RelayStatus where
textEncode = \case
RSNew -> "new"
RSInvited -> "invited"
RSAccepted -> "accepted"
RSActive -> "active"
textDecode = \case
"new" -> Just RSNew
"invited" -> Just RSInvited
"accepted" -> Just RSAccepted
"active" -> Just RSActive
_ -> Nothing
instance FromField RelayStatus where fromField = fromTextField_ textDecode
instance ToField RelayStatus where toField = toField . textEncode
data GroupSupportChat = GroupSupportChat
{ chatTs :: UTCTime,
unread :: Int64,
@@ -2044,8 +2005,6 @@ $(JQ.deriveJSON defaultJSON ''GroupSupportChat)
$(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus)
$(JQ.deriveJSON defaultJSON ''GroupRelay)
$(JQ.deriveJSON defaultJSON ''GroupMember)
$(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter)
+31
View File
@@ -74,3 +74,34 @@ instance FromJSON GroupAcceptance where
instance ToJSON GroupAcceptance where
toJSON = strToJSON
toEncoding = strToJEncoding
data RelayStatus
= RSNew -- only for owner
| RSInvited
| RSAccepted
| RSActive
deriving (Eq, Show)
relayStatusText :: RelayStatus -> Text
relayStatusText = \case
RSNew -> "new"
RSInvited -> "invited"
RSAccepted -> "accepted"
RSActive -> "active"
instance TextEncoding RelayStatus where
textEncode = \case
RSNew -> "new"
RSInvited -> "invited"
RSAccepted -> "accepted"
RSActive -> "active"
textDecode = \case
"new" -> Just RSNew
"invited" -> Just RSInvited
"accepted" -> Just RSAccepted
"active" -> Just RSActive
_ -> Nothing
instance FromField RelayStatus where fromField = fromTextField_ textDecode
instance ToField RelayStatus where toField = toField . textEncode
+11 -3
View File
@@ -179,6 +179,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"]
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays
CRGroupMembers u g -> ttyUser u $ viewGroupMembers g
CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms
-- CRGroupConversationsArchived u _g _conversations -> ttyUser u []
@@ -204,7 +205,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
CRSentConfirmation u _ _customUserProfile -> ttyUser u ["confirmation sent!"]
CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRStartedConnectionToContact u c customUserProfile -> ttyUser u $ viewStartedConnectionToContact c customUserProfile testView
CRStartedConnectionToGroup u g customUserProfile -> ttyUser u $ viewStartedConnectionToGroup g customUserProfile testView
CRStartedConnectionToGroup u g customUserProfile _relayResults -> ttyUser u $ viewStartedConnectionToGroup g customUserProfile testView
CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRItemsReadForChat u _chatId -> ttyUser u ["items read for chat"]
CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"]
@@ -1161,6 +1162,15 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} =
"to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)"
]
showRelay :: GroupRelay -> StyledString
showRelay GroupRelay {groupRelayId, relayStatus} =
" - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus)
viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString]
viewGroupRelays g relays =
[ttyFullGroup g <> ": group relays:"]
<> map showRelay relays
viewGroupLinkRelaysUpdated :: GroupInfo -> GroupLink -> [GroupRelay] -> [StyledString]
viewGroupLinkRelaysUpdated g groupLink relays =
[ttyFullGroup g <> ": group link relays updated, current relays:"]
@@ -1170,8 +1180,6 @@ viewGroupLinkRelaysUpdated g groupLink relays =
plain $ maybe cReqStr strEncode shortLink
]
where
showRelay GroupRelay {groupRelayId, relayStatus} =
" - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus)
GroupLink {connLinkContact = CCLink cReq shortLink} = groupLink
cReqStr = strEncode $ simplexChatContact cReq
+2
View File
@@ -38,6 +38,7 @@ import Simplex.Chat.Store.Profiles
import Simplex.Chat.Terminal
import Simplex.Chat.Terminal.Output (newChatTerminal)
import Simplex.Chat.Types
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.FileTransfer.Description (kb, mb)
import Simplex.FileTransfer.Server (runXFTPServerBlocking)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration)
@@ -209,6 +210,7 @@ testCfg =
shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"],
testView = True,
tbqSize = 16,
channelSubscriberRole = GRMember,
confirmMigrations = MCYesUp
}
+83 -1
View File
@@ -5,11 +5,12 @@ import ChatTests.DBUtils
import ChatTests.Utils
import Test.Hspec hiding (it)
-- TODO [relays] test deleting relay (from configuration), referenced in group_relays.
chatRelayTests :: SpecWith TestParams
chatRelayTests = do
describe "configure chat relays" $ do
it "get and set chat relays" testGetSetChatRelays
it "re-add soft-deleted relay by same address" testReAddRelaySameAddress
it "re-add soft-deleted relay by same name" testReAddRelaySameName
testGetSetChatRelays :: HasCallStack => TestParams -> IO ()
testGetSetChatRelays ps =
@@ -48,3 +49,84 @@ testGetSetChatRelays ps =
<### [ ConsoleString $ " bob_relay: " <> bobSLink,
ConsoleString $ " cath_relay: " <> cathSLink
]
-- Relay used by a channel is soft-deleted (referenced in group_relays).
-- Re-adding with same address should un-delete it.
testReAddRelaySameAddress :: HasCallStack => TestParams -> IO ()
testReAddRelaySameAddress ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
-- Configure bob as relay and create channel (creates group_relays reference)
alice ##> ("/relays name=bob_relay " <> bobSLink)
alice <## "ok"
createChannelWithRelay "team" alice bob
-- Replace bob_relay with cath_relay (bob_relay is soft-deleted, referenced in group_relays)
alice ##> ("/relays name=cath_relay " <> cathSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" cath_relay: " <> cathSLink)
-- Re-add with same address but different name - should succeed (un-deletes soft-deleted row by address)
alice ##> ("/relays name=bob_relay2 " <> bobSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" bob_relay2: " <> bobSLink)
-- Relay used by a channel is soft-deleted (referenced in group_relays).
-- Re-adding with same name and same address should un-delete it.
testReAddRelaySameName :: HasCallStack => TestParams -> IO ()
testReAddRelaySameName ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
-- Configure bob as relay named "my_relay" and create channel
alice ##> ("/relays name=my_relay " <> bobSLink)
alice <## "ok"
createChannelWithRelay "team" alice bob
-- Replace with cath_relay (my_relay is soft-deleted)
alice ##> ("/relays name=cath_relay " <> cathSLink)
alice <## "ok"
-- Re-add with same name and same address - should succeed (un-deletes by address match)
alice ##> ("/relays name=my_relay " <> bobSLink)
alice <## "ok"
alice ##> "/relays"
alice <## "Your servers"
alice <## " Chat relays"
alice <## (" my_relay: " <> bobSLink)
-- Create a public group with relay=1, wait for relay to join
createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO ()
createChannelWithRelay gName owner relay = do
owner ##> ("/public group relays=1 #" <> gName)
owner <## ("group #" <> gName <> " is created")
owner <## "wait for selected relay(s) to join, then you can invite members via group link"
concurrentlyN_
[ do
owner <## ("#" <> gName <> ": group link relays updated, current relays:")
owner <## " - relay id 1: active"
owner <## "group link:"
_ <- getTermLine owner
pure (),
relay <## ("#" <> gName <> ": you joined the group as relay")
]