mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-03 11:12:06 +00:00
ios: channels and chat relays ui (#6634)
This commit is contained in:
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
),
|
||||
|
||||
@@ -69,6 +69,7 @@ chatResponsesDocsData =
|
||||
("CRGroupLinkDeleted", ""),
|
||||
("CRGroupCreated", ""),
|
||||
("CRPublicGroupCreated", ""),
|
||||
("CRGroupRelays", ""),
|
||||
("CRGroupMembers", ""),
|
||||
("CRGroupUpdated", ""),
|
||||
("CRGroupsList", "Groups"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–3 have no backend blockers and can start immediately. Item 4 requires protocol changes for subscriber/owner lists and counts. Items 5–7 depend on backend work.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=?)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user