From b97868d79fade4e68945742c89b6875e48cd22cc Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:13:24 +0000 Subject: [PATCH] ios: channels and chat relays ui (#6634) --- apps/ios/CODE.md | 4 + apps/ios/Shared/ContentView.swift | 7 +- apps/ios/Shared/Model/AppAPITypes.swift | 76 ++- apps/ios/Shared/Model/ChatModel.swift | 30 + apps/ios/Shared/Model/SimpleXAPI.swift | 51 +- .../Shared/Views/Chat/ChatItemsMerger.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 127 +++- .../Chat/ComposeMessage/ComposeView.swift | 338 ++++++++--- .../Views/Chat/Group/ChannelMembersView.swift | 90 +++ .../Views/Chat/Group/ChannelRelaysView.swift | 118 ++++ .../Views/Chat/Group/GroupChatInfoView.swift | 199 ++++-- .../Views/Chat/Group/GroupLinkView.swift | 28 +- .../Chat/Group/GroupMemberInfoView.swift | 99 ++- .../Views/ChatList/ChatListNavLink.swift | 12 +- .../Shared/Views/ChatList/ChatListView.swift | 3 +- .../Shared/Views/Helpers/ViewModifiers.swift | 9 + .../Shared/Views/NewChat/AddChannelView.swift | 396 ++++++++++++ .../Shared/Views/NewChat/AddGroupView.swift | 3 +- .../Views/NewChat/NewChatMenuButton.swift | 8 + .../Shared/Views/NewChat/NewChatView.swift | 134 +++-- .../NetworkAndServers/ChatRelayView.swift | 323 ++++++++++ .../NetworkAndServers/NetworkAndServers.swift | 53 +- .../NetworkAndServers/NewServerView.swift | 9 +- .../NetworkAndServers/OperatorView.swift | 49 +- .../ProtocolServerView.swift | 4 +- .../ProtocolServersView.swift | 76 ++- .../ScanProtocolServer.swift | 6 +- apps/ios/SimpleX SE/ShareAPI.swift | 8 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 + apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 123 +++- apps/ios/product/concepts.md | 1 + apps/ios/product/flows/connection.md | 21 +- apps/ios/product/flows/group-lifecycle.md | 16 +- apps/ios/product/flows/messaging.md | 6 +- apps/ios/product/gaps.md | 3 + apps/ios/product/glossary.md | 20 +- apps/ios/product/rules.md | 29 + apps/ios/product/views/chat-list.md | 17 + apps/ios/product/views/chat.md | 9 + apps/ios/product/views/group-info.md | 97 +++ apps/ios/product/views/new-chat.md | 52 +- apps/ios/product/views/settings.md | 31 +- apps/ios/spec/api.md | 569 +++++++++--------- apps/ios/spec/architecture.md | 49 ++ apps/ios/spec/client/chat-list.md | 16 + apps/ios/spec/client/chat-view.md | 174 ++++-- apps/ios/spec/client/compose.md | 89 +-- apps/ios/spec/client/navigation.md | 70 ++- apps/ios/spec/impact.md | 33 +- apps/ios/spec/state.md | 260 ++++---- bots/api/COMMANDS.md | 39 ++ bots/api/TYPES.md | 19 +- bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Responses.hs | 1 + bots/src/API/Docs/Types.hs | 5 + bots/src/API/TypeInfo.hs | 2 + .../types/typescript/src/commands.ts | 14 + .../types/typescript/src/responses.ts | 9 + .../types/typescript/src/types.ts | 14 +- plans/2026-02-17-ios-channels-product-plan.md | 506 ++++++++++++++++ src/Simplex/Chat.hs | 2 + src/Simplex/Chat/Controller.hs | 15 +- src/Simplex/Chat/Library/Commands.hs | 53 +- src/Simplex/Chat/Library/Internal.hs | 8 +- src/Simplex/Chat/Library/Subscriber.hs | 54 +- src/Simplex/Chat/Operators.hs | 14 + src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 78 ++- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Profiles.hs | 27 +- src/Simplex/Chat/Store/RelayRequests.hs | 1 + .../SQLite/Migrations/agent_query_plans.txt | 8 +- .../SQLite/Migrations/chat_query_plans.txt | 109 +++- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 43 +- src/Simplex/Chat/Types/Shared.hs | 31 + src/Simplex/Chat/View.hs | 14 +- tests/ChatClient.hs | 2 + tests/ChatTests/ChatRelays.hs | 84 ++- 80 files changed, 4170 insertions(+), 971 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift create mode 100644 apps/ios/Shared/Views/NewChat/AddChannelView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift create mode 100644 plans/2026-02-17-ios-channels-product-plan.md diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md index adb5ef8c42..5a8356f656 100644 --- a/apps/ios/CODE.md +++ b/apps/ios/CODE.md @@ -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/`) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index a6896fa51d..ba49c767da 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -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( diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index f82a2fd2eb..336d21da3b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -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 diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 46e9df1ef8..023dc1926c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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 = [:] // 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 { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7eb2de11ab..0819d74ec1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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? = 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? = 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 = 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 { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 5f2102b8bc..0b074c6370 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -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 } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 057bf7f75f..87f6b8a787 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 = 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 } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 2c462df9e4..af9b4673c0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -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(@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(@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(), diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift new file mode 100644 index 0000000000..b3317cbc6b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -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 + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift new file mode 100644 index 0000000000..2ed55d1f28 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -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) +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 257d5aac93..a30e54ef79 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -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!") ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 43bc26e8f8..13b6c0e682 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -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) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 17a05ffca4..6631fc23c5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -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) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 381057db5b..b4590fc124 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -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." ) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d84fa29c81..3050b0d4cd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index 85ef85c611..902a3f95d7 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -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 { diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift new file mode 100644 index 0000000000..15be6aa969 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -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() +} diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 901b2deeab..c74e016974 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -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) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 7adb04cb7e..a1cf1007e0 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -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) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 71a155949b..95f7fa2e9d 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -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, diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift new file mode 100644 index 0000000000..790edb9be7 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -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) { + 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) + }) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 64e3d15de0..3ff1a2ee68 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -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 { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index b44271bd89..0a3c82b4dd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -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([]) ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index abd8be03b9..f8b66d3697 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -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 ) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 97bf9ebc93..5299b7d415 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -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" diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index 49e1ff79ea..e521c7ea26 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -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 @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 ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift index fd29fd906e..b2b4a64f4e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift @@ -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([]) ) } } diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 6495d09b03..f13401d437 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -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)" } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 314f1c072c..cd03ae150a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; + 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; + 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; @@ -959,6 +967,7 @@ 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */, 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */, + 647B15E72F4C8D2500EB431E /* AddChannelView.swift */, ); path = NewChat; sourceTree = ""; @@ -1122,6 +1131,7 @@ 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */, + 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */, ); path = NetworkAndServers; sourceTree = ""; @@ -1141,6 +1151,8 @@ 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */, 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */, 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, + 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, + 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, ); path = Group; sourceTree = ""; @@ -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 */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b31a799e68..5ef5c5d14b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -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) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0b15666d2..59e1be5c2e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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") } } } diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index a60fe98cbb..3fa722d47a 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -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`) | --- diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md index 7b9c8ee304..05051141f9 100644 --- a/apps/ios/product/flows/connection.md +++ b/apps/ios/product/flows/connection.md @@ -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 diff --git a/apps/ios/product/flows/group-lifecycle.md b/apps/ios/product/flows/group-lifecycle.md index 78d4f28738..e102fa982a 100644 --- a/apps/ios/product/flows/group-lifecycle.md +++ b/apps/ios/product/flows/group-lifecycle.md @@ -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 | diff --git a/apps/ios/product/flows/messaging.md b/apps/ios/product/flows/messaging.md index 527079995c..d37fefdd7d 100644 --- a/apps/ios/product/flows/messaging.md +++ b/apps/ios/product/flows/messaging.md @@ -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) | diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md index 04cf97a6a7..50d6bf2938 100644 --- a/apps/ios/product/gaps.md +++ b/apps/ios/product/gaps.md @@ -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. + +--- + diff --git a/apps/ios/product/glossary.md b/apps/ios/product/glossary.md index 0353c8f606..7b2e227ffa 100644 --- a/apps/ios/product/glossary.md +++ b/apps/ios/product/glossary.md @@ -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 diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md index b41792898b..0cb3f8e96a 100644 --- a/apps/ios/product/rules.md +++ b/apps/ios/product/rules.md @@ -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 diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md index 6c2d868d64..04d19bef9e 100644 --- a/apps/ios/product/views/chat-list.md +++ b/apps/ios/product/views/chat-list.md @@ -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 diff --git a/apps/ios/product/views/chat.md b/apps/ios/product/views/chat.md index 57202846eb..bf84bf4feb 100644 --- a/apps/ios/product/views/chat.md +++ b/apps/ios/product/views/chat.md @@ -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: diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md index 9291b3ed2f..3ec322ec0e 100644 --- a/apps/ios/product/views/group-info.md +++ b/apps/ios/product/views/group-info.md @@ -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 diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md index e53659e622..2ab5f9ba8f 100644 --- a/apps/ios/product/views/new-chat.md +++ b/apps/ios/product/views/new-chat.md @@ -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 diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md index 58507ce52b..3cc4da5d2b 100644 --- a/apps/ios/product/views/settings.md +++ b/apps/ios/product/views/settings.md @@ -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 diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index fe40a4c4ec..45a06c371f 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -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` ([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` ([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( // SimpleXAPI.swift L91 +func chatSendCmdSync( // SimpleXAPI.swift L93 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -496,7 +505,7 @@ func chatSendCmdSync( // SimpleXAPI.swift L91 ) throws -> R // Returns APIResult (caller handles error) -func chatApiSendCmdSync( // SimpleXAPI.swift L96 +func chatApiSendCmdSync( // SimpleXAPI.swift L99 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -510,7 +519,7 @@ func chatApiSendCmdSync( // SimpleXAPI.swift L96 ```swift // Throws on error, returns typed result -func chatSendCmd( // SimpleXAPI.swift L117 +func chatSendCmd( // SimpleXAPI.swift L121 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -519,7 +528,7 @@ func chatSendCmd( // SimpleXAPI.swift L117 ) async throws -> R // Returns APIResult with optional retry on network errors -func chatApiSendCmdWithRetry( // SimpleXAPI.swift L122 +func chatApiSendCmdWithRetry( // SimpleXAPI.swift L127 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -543,12 +552,12 @@ public func sendSimpleXCmd( // 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? // 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: Decodable where R: Decodable, R: ChatAPIResult { @@ -569,14 +578,14 @@ public enum APIResult: 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` function ([`APITypes.swift` L83](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: +The `decodeAPIResult` function ([`APITypes.swift` L86](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: 1. Try standard `JSONDecoder.decode(APIResult.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` 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` | diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md index 84d9d3269d..9ab3eb1fd2 100644 --- a/apps/ios/spec/architecture.md +++ b/apps/ios/spec/architecture.md @@ -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 | diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index 0eb3cd75f7..d35de1f80a 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -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 diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index b913287746..111b8ec1f4 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -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) | diff --git a/apps/ios/spec/client/compose.md b/apps/ios/spec/client/compose.md index 03116ddf6b..f86e323ade 100644 --- a/apps/ios/spec/client/compose.md +++ b/apps/ios/spec/client/compose.md @@ -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) | diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index e755115827..22985c6fe1 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -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) | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index 9593419b87..eaf646e7f4 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -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 | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index 68b5f3cbcc..c989547299 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -1,6 +1,6 @@ # SimpleX Chat iOS -- State Management -**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-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` | 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` | 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` | 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` | 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) | diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 54ffc977d5..7f6c6c106d 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -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 # +``` + +```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. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 328b923f90..facb2ce444 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -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 diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index fa5dc49c9c..dcb2b62a13 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -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") ] ), diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 321fac1d9c..915496cec0 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -69,6 +69,7 @@ chatResponsesDocsData = ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), ("CRPublicGroupCreated", ""), + ("CRGroupRelays", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 10d8368857..3af99b71c0 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -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 diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 61e2595fa9..37f74e4275 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -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" [] diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 742f5b8dd2..7711324890 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -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 { diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index de80b8666d..ff913fdfa9 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -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 diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 0660f0e968..6c316fbaf3 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -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 diff --git a/plans/2026-02-17-ios-channels-product-plan.md b/plans/2026-02-17-ios-channels-product-plan.md new file mode 100644 index 0000000000..de448ec27e --- /dev/null +++ b/plans/2026-02-17-ios-channels-product-plan.md @@ -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. diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0125a75fc1..debe95825c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a0a8111160..7b44a66d98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3fd2a42fac..3a22a9a69a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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)), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 9ca16e299b..3547f1f9fc 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -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) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 10f216940c..2fcec2c8ee 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -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 diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index ac3af273a8..b0dda4aad1 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -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) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0e689267d4..349afcc7aa 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -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 diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index cba800c9de..451ef5d825 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -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) ) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 00ab18e939..50bd3eaeae 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -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 diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c58099d2b0..79376a38bb 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -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 diff --git a/src/Simplex/Chat/Store/RelayRequests.hs b/src/Simplex/Chat/Store/RelayRequests.hs index 04731d8ef3..3858281878 100644 --- a/src/Simplex/Chat/Store/RelayRequests.hs +++ b/src/Simplex/Chat/Store/RelayRequests.hs @@ -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 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index ebdf7e1f5c..1b881bd446 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -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=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index a69485129c..f666ea4b72 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -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=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 90ccd4dedc..273fea1211 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -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 diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2329c21e74..2ed3298460 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -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) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index fafac46da8..f33719a434 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -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 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5784e9b7e0..197d0de3cf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -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 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d018eed4ee..5ea1649a4a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -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 } diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 2a45f79a73..606c199a82 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -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") + ]