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..1131069d88 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) @@ -89,6 +91,7 @@ enum ChatCommand: ChatCmdProtocol { case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) case apiAcceptMemberContact(contactId: Int64) case apiTestProtoServer(userId: Int64, server: String) + case apiTestChatRelay(userId: Int64, address: String) case apiGetServerOperators case apiSetServerOperators(operators: [ServerOperator]) case apiGetUserServers(userId: Int64) @@ -107,6 +110,7 @@ enum ChatCommand: ChatCmdProtocol { case reconnectServer(userId: Int64, smpServer: String) case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) + case apiGetUpdatedGroupLinkData(groupId: Int64) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiContactQueueInfo(contactId: Int64) @@ -126,7 +130,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 +234,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 +257,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 +269,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)" @@ -282,6 +290,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" case let .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case let .apiTestChatRelay(userId, address): return "/_relay test \(userId) \(address)" case .apiGetServerOperators: return "/_operators" case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" case let .apiGetUserServers(userId): return "/_servers \(userId)" @@ -300,6 +309,7 @@ enum ChatCommand: ChatCmdProtocol { case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" + case let .apiGetUpdatedGroupLinkData(groupId): return "/_get group link data #\(groupId)" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" @@ -329,7 +339,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 +459,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" @@ -468,6 +480,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiAcceptMemberContact: return "apiAcceptMemberContact" case .apiTestProtoServer: return "apiTestProtoServer" + case .apiTestChatRelay: return "apiTestChatRelay" case .apiGetServerOperators: return "apiGetServerOperators" case .apiSetServerOperators: return "apiSetServerOperators" case .apiGetUserServers: return "apiGetUserServers" @@ -486,6 +499,7 @@ enum ChatCommand: ChatCmdProtocol { case .reconnectServer: return "reconnectServer" case .apiSetChatSettings: return "apiSetChatSettings" case .apiSetMemberSettings: return "apiSetMemberSettings" + case .apiGetUpdatedGroupLinkData: return "apiGetUpdatedGroupLinkData" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiContactQueueInfo: return "apiContactQueueInfo" @@ -658,13 +672,15 @@ enum ChatResponse0: Decodable, ChatAPIResult { case chatTags(user: UserRef, userTags: [ChatTag]) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case chatRelayTestResult(user: UserRef, relayProfile: RelayProfile?, relayTestFailure: RelayTestFailure?) 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) case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) + case groupInfo(user: UserRef, groupInfo: GroupInfo) case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) @@ -691,6 +707,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatTags: "chatTags" case .chatItemInfo: "chatItemInfo" case .serverTestResult: "serverTestResult" + case .chatRelayTestResult: "chatRelayTestResult" case .serverOperatorConditions: "serverOperators" case .userServers: "userServers" case .userServersValidation: "userServersValidation" @@ -698,6 +715,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatItemTTL: "chatItemTTL" case .networkConfig: "networkConfig" case .contactInfo: "contactInfo" + case .groupInfo: "groupInfo" case .groupMemberInfo: "groupMemberInfo" case .queueInfo: "queueInfo" case .contactSwitchStarted: "contactSwitchStarted" @@ -726,13 +744,15 @@ enum ChatResponse0: Decodable, ChatAPIResult { case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .chatRelayTestResult(u, relayProfile, relayTestFailure): return withUser(u, "relayProfile: \(String(describing: relayProfile))\nresult: \(String(describing: relayTestFailure))") 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) case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupInfo(u, groupInfo): return withUser(u, "groupInfo: \(String(describing: groupInfo))") case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") case let .queueInfo(u, rcvMsgInfo, queueInfo): let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } @@ -779,7 +799,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 +920,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 +931,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 +983,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 +1031,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 +1112,12 @@ 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 groupLinkDataUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay], relaysChanged: Bool) + case groupRelayUpdated(user: UserRef, groupInfo: GroupInfo, member: GroupMember, groupRelay: GroupRelay) case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) @@ -1166,6 +1194,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .joinedGroupMember: "joinedGroupMember" case .connectedToGroupMember: "connectedToGroupMember" case .groupUpdated: "groupUpdated" + case .groupLinkDataUpdated: "groupLinkDataUpdated" + case .groupRelayUpdated: "groupRelayUpdated" case .newMemberContactReceivedInv: "newMemberContactReceivedInv" case .rcvFileAccepted: "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" @@ -1242,10 +1272,12 @@ 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 .groupLinkDataUpdated(u, groupInfo, groupLink, groupRelays, relaysChanged): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)\nrelaysChanged: \(relaysChanged)") + case let .groupRelayUpdated(u, groupInfo, member, groupRelay): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\ngroupRelay: \(groupRelay)") 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 +1316,7 @@ enum ChatEvent: Decodable, ChatAPIResult { struct NewUser: Encodable { var profile: Profile? var pastTimestamp: Bool + var userChatRelay: Bool = false } enum ChatPagination { @@ -1331,8 +1364,14 @@ enum ContactAddressPlan: Decodable, Hashable { case contactViaAddress(contact: Contact) } +public struct GroupShortLinkInfo: Decodable, Hashable { + public var direct: Bool + public var groupRelays: [String] + public var publicGroupId: 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 +1751,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 +1781,28 @@ 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 duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { @@ -1913,6 +1960,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 @@ -1964,6 +2016,41 @@ struct ProtocolTestFailure: Decodable, Error, Equatable { } } +public enum RelayTestStep: String, Decodable { + case getLink + case decodeLink + case connect + case waitResponse + case verify + + var text: String { + switch self { + case .getLink: return NSLocalizedString("Get link", comment: "relay test step") + case .decodeLink: return NSLocalizedString("Decode link", comment: "relay test step") + case .connect: return NSLocalizedString("Connect", comment: "relay test step") + case .waitResponse: return NSLocalizedString("Wait response", comment: "relay test step") + case .verify: return NSLocalizedString("Verify", comment: "relay test step") + } + } +} + +public struct RelayTestFailure: Decodable, Error { + public var rtfStep: RelayTestStep + public var rtfError: ChatError + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "relay test failure"), rtfStep.text) + switch rtfError { + case .errorAgent(agentError: .SMP(_, .AUTH)): + return err + " " + NSLocalizedString("Server requires authorization to connect to relay, check password.", comment: "relay test error") + case .errorAgent(agentError: .BROKER(_, .NETWORK(.unknownCAError))): + return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "relay test error") + default: + return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "relay test error"), String(describing: rtfError)) + } + } +} + struct MigrationFileLinkData: Codable { let networkConfig: NetworkConfig? diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 46e9df1ef8..9c23ac6307 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -333,6 +333,29 @@ 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 updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) { + if groupId == groupInfo.groupId, + let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) { + groupRelays[i] = relay + } + } + + func reset() { + groupId = nil + groupRelays = [] + } +} + // Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @@ -363,9 +386,13 @@ final class ChatModel: ObservableObject { @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? + @Published var creatingChannelId: String? @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 +1223,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 { @@ -1211,13 +1249,19 @@ final class ChatModel: ObservableObject { updateGroup(groupInfo) return false } - // update current chat - if chatId == groupInfo.id { + // update current chat or channel being created + if chatId == groupInfo.id || creatingChannelId == groupInfo.id { if let i = groupMembersIndexes[member.groupMemberId] { + let connStatusChanged = self.groupMembers[i].wrapped.activeConn?.connStatus != member.activeConn?.connStatus withAnimation(.default) { self.groupMembers[i].wrapped = member self.groupMembers[i].created = Date.now } + // Updating wrapped on a reference-type GMember doesn't mutate the groupMembers array, + // so ChatModel.objectWillChange doesn't fire automatically — notify views explicitly. + if connStatusChanged { + objectWillChange.send() + } return false } else { withAnimation { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7eb2de11ab..e527df1abd 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) } @@ -758,6 +758,15 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail throw r.unexpected } +func testChatRelay(address: String) async throws -> (RelayProfile?, RelayTestFailure?) { + let userId = try currentUserId("testChatRelay") + let r: ChatResponse0 = try await chatSendCmd(.apiTestChatRelay(userId: userId, address: address)) + if case let .chatRelayTestResult(_, relayProfile, relayTestFailure) = r { + return (relayProfile, relayTestFailure) + } + throw r.unexpected +} + func getServerOperators() async throws -> ServerOperatorConditions { let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } @@ -795,10 +804,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 } @@ -890,6 +899,12 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings)) } +func apiGetUpdatedGroupLinkData(_ groupId: Int64) async -> GroupInfo? { + let r: APIResult = await chatApiSendCmd(.apiGetUpdatedGroupLinkData(groupId: groupId)) + if case let .result(.groupInfo(_, groupInfo)) = r { return groupInfo } + return nil +} + func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } @@ -1121,9 +1136,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 +1162,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 +1841,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 +2492,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 +2557,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 +2593,23 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(toGroup) } } + case let .groupLinkDataUpdated(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 .groupRelayUpdated(user, groupInfo, member, groupRelay): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, member) + ChannelRelaysModel.shared.updateRelay(groupInfo, groupRelay) + } + } case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 37f3b982a1..e158b9374f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -57,6 +57,18 @@ struct ChatInfoToolbar: View { .padding(.top, -2) } } + .if (channelSubscriberCount != nil) { v in + VStack(spacing: 0) { + v + if let count = channelSubscriberCount { + Text(subscriberCountStr(count)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .lineLimit(1) + .padding(.top, -2) + } + } + } if let contact = chat.chatInfo.contact, contact.ready && contact.active, let chatSubStatus = m.chatSubStatus, @@ -69,6 +81,17 @@ struct ChatInfoToolbar: View { .frame(width: 220) } + private var channelSubscriberCount: Int64? { + if case let .group(groupInfo, _) = chat.chatInfo, + groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount, + count > 0 { + count + } else { + nil + } + } + private var contactVerifiedShield: Text { (Text(Image(systemName: "checkmark.shield")) + textSpace) .font(.caption) @@ -102,6 +125,12 @@ struct ChatInfoToolbar: View { } } +public func subscriberCountStr(_ count: Int64) -> String { + count == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d subscriber", comment: "channel subscriber count"), count) + : String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count) +} + struct ChatInfoToolbar_Previews: PreviewProvider { static var previews: some View { ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 8b5172eccf..b56f1f9f2a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -98,12 +98,13 @@ struct CIImageView: View { if img.imageData == nil { Image(uiImage: img) .resizable() - .scaledToFit() - .frame(width: w) + .scaledToFill() + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() } else { - SwiftyGif(image: img) - .frame(width: w, height: w * img.size.height / img.size.width) - .scaledToFit() + SwiftyGif(image: img, contentMode: .scaleAspectFill) + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() } if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { loadingIndicator() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index a09518ffdb..b3fdd3f8e3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -21,7 +21,8 @@ struct CILinkView: View { if let uiImage = imageFromBase64(linkPreview.image) { Image(uiImage: uiImage) .resizable() - .scaledToFit() + .aspectRatio(1 / heightRatio(uiImage.size), contentMode: .fill) + .clipped() .modifier(PrivacyBlur(blurred: $blurred)) .if(!blurred) { v in v.simultaneousGesture(TapGesture().onEnded { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 80bea997d3..e1172dab92 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -187,7 +187,8 @@ struct CIVideoView: View { ZStack(alignment: .center) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) VideoPlayerView(player: player, url: url, showControls: false) - .frame(width: w, height: w * preview.size.height / preview.size.width) + .frame(width: w, height: w * heightRatio(preview.size)) + .clipped() .onChange(of: m.stopPreviousRecPlay) { playingUrl in if playingUrl != url { player.pause() @@ -315,8 +316,9 @@ struct CIVideoView: View { return ZStack(alignment: .topTrailing) { Image(uiImage: img) .resizable() - .scaledToFit() - .frame(width: w) + .scaledToFill() + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() .modifier(PrivacyBlur(blurred: $blurred)) if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { fileStatusIcon() 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..1898fd2851 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, @@ -269,6 +253,18 @@ struct ChatView: View { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } + .appSheet(isPresented: $showGroupLinkSheet) { + if case let .group(groupInfo, _) = cInfo { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false, + isChannel: groupInfo.useRelays + ) + } + } .sheet(isPresented: Binding( get: { !forwardedChatItems.isEmpty }, set: { isPresented in @@ -351,6 +347,13 @@ struct ChatView: View { if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { scrollView.scrollToItem(index) + } else if let viewedIdx = mergedItems.boxedValue.items.firstIndex(where: { !$0.hasUnread() }) { + // scroll to first unread after last viewed item (items reversed: 0 = newest) + if viewedIdx > 0 { + scrollView.scrollToItem(viewedIdx - 1) + } else { + scrollView.scrollToBottom() + } } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { scrollView.scrollToItem(unreadIndex) } else { @@ -405,6 +408,7 @@ struct ChatView: View { chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false + ChannelRelaysModel.shared.reset() } } } @@ -533,32 +537,51 @@ struct ChatView: View { case let .direct(contact): HStack { let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + let canStartCall = callsPrefEnabled && contact.ready && contact.active && chatModel.activeCall == nil if let call = chatModel.activeCall, call.contact.id == cInfo.id { endCallButton(call) - } else { - contentFilterMenu(withLabel: false) - } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { + } else if canStartCall { + // Call button always in toolbar; tap opens Audio/Video submenu + Menu { Button { CallController.shared.startCall(contact, .audio) } label: { Label("Audio call", systemImage: "phone") } - .disabled(!contact.ready || !contact.active) Button { CallController.shared.startCall(contact, .video) } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready || !contact.active) - } - if let call = chatModel.activeCall, call.contact.id == cInfo.id { - contentFilterMenu(withLabel: true) + } label: { + Image(systemName: "phone") } + } else if chatModel.activeCall == nil { + // Calls unavailable: show filter button in place of call button + contentFilterMenu(withLabel: false) + } + Menu { searchButton() ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) + // Filter options in menu when call button is shown (or during any active call) + if !availableContent.isEmpty && (canStartCall || chatModel.activeCall != nil) { + Divider() + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } } label: { Image(systemName: "ellipsis") } @@ -568,17 +591,8 @@ struct ChatView: View { contentFilterMenu(withLabel: false) Menu { if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { + if chat.chatInfo.incognito || groupInfo.useRelays { groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } } else { addMembersButton() } @@ -591,7 +605,26 @@ struct ChatView: View { } case .local: HStack { - contentFilterMenu(withLabel: false) + if !availableContent.isEmpty { + Menu { + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } label: { + Image(systemName: "ellipsis") + } + } searchButton() } default: @@ -701,6 +734,25 @@ 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) + } + } + } else { + Task { + if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } + } + } updateAvailableContent() } if chatModel.draftChatId == cInfo.id && !composeState.forwarding, @@ -865,6 +917,7 @@ struct ChatView: View { selectedChatItems: $selectedChatItems, forwardedChatItems: $forwardedChatItems, searchText: $searchText, + contentFilter: $contentFilter, closeKeyboardAndRun: closeKeyboardAndRun ) } @@ -1029,12 +1082,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 +1115,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 } @@ -1384,7 +1441,11 @@ struct ChatView: View { } } } label: { - Label("Group link", systemImage: "link.badge.plus") + if case let .group(gInfo, _) = chat.chatInfo, gInfo.useRelays { + Label("Channel link", systemImage: "link") + } else { + Label("Group link", systemImage: "link.badge.plus") + } } } @@ -1631,12 +1692,14 @@ struct ChatView: View { @Binding var forwardedChatItems: [ChatItem] @Binding var searchText: String + @Binding var contentFilter: ContentFilter? var closeKeyboardAndRun: (@escaping () -> Void) -> Void @State private var allowMenu: Bool = true @State private var markedRead = false @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil + @State private var swipeOffset: CGFloat = 0 var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -1653,6 +1716,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 +1733,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 } @@ -1788,7 +1858,7 @@ struct ChatView: View { private var searchIsNotBlank: Bool { get { - searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + (searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) || contentFilter != nil } } @@ -1843,7 +1913,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) { @@ -1971,33 +2108,69 @@ struct ChatView: View { func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - return VStack(alignment: alignment.horizontal, spacing: 3) { - HStack { - if ci.chatDir.sent { - goToItemButton(true) + let live = composeState.liveMessage != nil + let canReply = ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && selectedChatItems == nil + return ZStack(alignment: .trailing) { + Image(systemName: "arrowshape.turn.up.left") + .font(.system(size: 18)) + .foregroundColor(.secondary) + .opacity(min(1, -swipeOffset / 30)) + .offset(x: swipeOffset + 40) + VStack(alignment: alignment.horizontal, spacing: 3) { + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + im: im, + chatItem: ci, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: live) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } } - ChatItemView( - chat: chat, - im: im, - chatItem: ci, - scrollToItem: scrollToItem, - scrollToItemId: $scrollToItemId, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") - if !ci.chatDir.sent { - goToItemButton(false) + if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { + chatItemReactions(ci) + .padding(.bottom, 4) } } - if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { - chatItemReactions(ci) - .padding(.bottom, 4) - } + .offset(x: swipeOffset) + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + guard canReply else { return } + let x = value.translation.width + if x < 0 { + swipeOffset = max(x * 0.63, -56) + } + } + .onEnded { _ in + if swipeOffset < -42 { + withAnimation { + if composeState.editing { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } + } + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + withAnimation(.spring(response: 0.25)) { + swipeOffset = 0 + } + } + ) } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { @@ -2043,6 +2216,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..f37eb614b9 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,152 @@ 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, + ![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) { + if gInfo.membership.memberRole == .owner { + let relays = channelRelaysModel.groupId == gInfo.groupId + ? channelRelaysModel.groupRelays : [] + let failedCount = relays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = relays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + if !relays.isEmpty && activeCount < relays.count { + ownerChannelRelayBar(relays: relays, activeCount: activeCount, failedCount: failedCount) + } } 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 failedCount = relayMembers.filter { $0.wrapped.activeConn?.connFailedErr != nil }.count + let errorCount = deletedCount + failedCount + 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, + errorCount: errorCount, + 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 +723,175 @@ struct ComposeView: View { } } + private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int) -> some View { + let total = relays.count + let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } + return VStack(spacing: 0) { + relayBarHeader { + if activeCount + failedCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + } + } + if relayListExpanded { + ForEach(sorted) { relay in + let failedErr = relayMemberConnFailed(relay) + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + ownerRelayDetailRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + ownerRelayDetailRow(relay, connFailed: false) + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + + private func subscriberChannelRelayBar( + hostnames: [String], + relayMembers: [GMember], + connectedCount: Int, + errorCount: Int, + total: Int, + showProgress: Bool + ) -> some View { + VStack(spacing: 0) { + relayBarHeader { + if showProgress && connectedCount + errorCount < total { + RelayProgressIndicator(active: connectedCount, total: total) + } + if showProgress { + if errorCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar progress with errors"), connectedCount, total, errorCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total)) + } + } 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) } + let failedErr = m.activeConn?.connFailedErr + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + subscriberRelayDetailRow(m, host: host, connFailed: true) + } + .buttonStyle(.plain) + } else { + subscriberRelayDetailRow(m, host: host, connFailed: false) + } + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View { + 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) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + } + } + } + + 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 relayMemberConnFailed(_ relay: GroupRelay) -> String? { + chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + 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 +1078,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 +1552,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 +1578,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..abcadc6c3f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -0,0 +1,96 @@ +// +// 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 members = chatModel.groupMembers + .filter { m in + let s = m.wrapped.memberStatus + return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay + } + if groupInfo.isOwner { + let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) + List { + Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { + memberRow(GMember(groupInfo.membership), user: true, showRole: true) + ForEach(members) { member in + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner) + } + } + } + } else { + let owners = members.filter { $0.wrapped.memberRole >= .owner } + List { + Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { + ForEach(owners) { member in + memberRow(member, user: false, showRole: false) + } + } + } + } + } + + @ViewBuilder private func memberRow(_ gMember: GMember, user: Bool, showRole: 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) + VStack(alignment: .leading) { + displayName + .lineLimit(1) + if user { + Text("you") + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + } + Spacer() + if showRole { + Text(member.memberRole.text) + .foregroundColor(theme.colors.secondary) + } + } + 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..1a4e384e24 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -0,0 +1,129 @@ +// +// 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 { + List { + relaysList() + } + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + if groupInfo.isOwner { + groupRelays = await apiGetGroupRelays(groupInfo.groupId) + } + } + } + } + + @ViewBuilder private func relaysList() -> 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: { + let statusText = groupInfo.isOwner + ? ownerRelayStatusText(member.wrapped) + : subscriberRelayStatusText(member.wrapped) + relayMemberRow(member.wrapped, statusText: statusText) + } + } + } footer: { + Text("Chat relays forward messages to channel subscribers.") + } + } + } + + private func subscriberRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + relayConnStatus(member).text + } + } + + private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if case .failed = member.activeConn?.connStatus { + "failed" + } else if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text + ?? relayConnStatus(member).text + } + } + + private func relayMemberRow(_ member: GroupMember, statusText: LocalizedStringKey) -> some View { + HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + Text(member.chatViewName) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + Text(statusText) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + } + } +} + +func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) { + switch member.activeConn?.connStatus { + case .ready: ("connected", .green) + case .deleted: ("deleted", .red) + case .failed: ("failed", .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 4113b75d0a..c02f4dae36 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -90,22 +90,57 @@ 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 && groupInfo.membership.memberIncognito { + Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { + HStack { + Text("Your random profile") + Spacer() + Text(groupInfo.membership.chatViewName) + .foregroundStyle(.indigo) + } } - if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { - memberSupportButton() + } + + 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.publicGroup?.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.publicGroup?.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.canModerate { - GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } 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.membership.memberActive - && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { - UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - } header: { - Text("") } Section { @@ -115,22 +150,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 +183,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 +215,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 +267,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))") } } } @@ -259,6 +308,14 @@ struct GroupChatInfoView: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } + if groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount, + count > 0 { + Text(subscriberCountStr(count)) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 2) + } } .frame(maxWidth: .infinity, alignment: .center) } @@ -299,7 +356,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 +419,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" @@ -547,19 +623,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 ? "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 @@ -654,7 +762,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") } } @@ -676,7 +784,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: { @@ -695,7 +803,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: { @@ -706,7 +814,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), @@ -743,9 +851,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." ) @@ -796,9 +906,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: {[ @@ -840,10 +954,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..56ee370402 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,7 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - if !creatingGroup { + if !creatingGroup && !isChannel { Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash") } @@ -110,7 +117,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 135efae74f..af7054db01 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,11 @@ 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) { + if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) { Picker("Change role", selection: $newRole) { ForEach(roles) { role in Text(role.text) @@ -155,6 +182,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,13 +235,20 @@ struct GroupMemberInfoView: View { if let connFailedErr = member.activeConn?.connFailedErr { Section { - infoRow("Connection failed", connFailedErr) + Text(connFailedErr) + .foregroundColor(theme.colors.secondary) + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Connection failed") + } } } if groupInfo.membership.memberRole >= .moderator { adminDestructiveSection(member) - } else { + } else if !groupInfo.useRelays { nonAdminBlockSection(member) } @@ -209,16 +260,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) } } } } @@ -582,7 +635,9 @@ struct GroupMemberInfoView: View { blockForAllButton(mem) } } - if canRemove { + // TODO [relays] removing relay should also remove its link from group link data; + // TODO - removing last relay should be prohibited or show warning + if canRemove && mem.memberRole != .relay { if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft { deleteMemberMessagesButton(mem) } else { @@ -644,7 +699,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) } } @@ -824,7 +879,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) @@ -835,7 +890,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/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 9610b4a24d..f7f253a617 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -79,12 +79,25 @@ struct DatabaseErrorView: View { fileNameText(dbFile) } case let .downgrade(downMigrations): + let warnings = downMigrationWarnings(downMigrations).reversed() titleText("Database downgrade") + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .frame(width: 40, height: 36) + .foregroundColor(.red) Text("Warning: you may lose some data!") .bold() .padding(.horizontal, 25) .multilineTextAlignment(.center) - + if !warnings.isEmpty { + ForEach(warnings, id: \.self) { warning in + Text(warning) + .bold() + .multilineTextAlignment(.center) + .padding(.horizontal, 25) + } + } migrationsText(downMigrations) Spacer() VStack(spacing: 10) { diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 86a5dc7aaa..2ef928c7c5 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -100,6 +100,7 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let subtitle: String? private let cancelTitle: String private let confirmTitle: String private let onCancel: () -> Void @@ -109,6 +110,7 @@ class OpenChatAlertViewController: UIViewController { profileName: String, profileFullName: String, profileImage: UIView, + subtitle: String? = nil, cancelTitle: String = "Cancel", confirmTitle: String = "Open", onCancel: @escaping () -> Void, @@ -117,6 +119,7 @@ class OpenChatAlertViewController: UIViewController { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.subtitle = subtitle self.cancelTitle = cancelTitle self.confirmTitle = confirmTitle self.onCancel = onCancel @@ -171,6 +174,18 @@ class OpenChatAlertViewController: UIViewController { profileViews.append(fullNameLabel) } + // Subtitle label (e.g. subscriber count) + if let subtitle { + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + subtitleLabel.textColor = .secondaryLabel + subtitleLabel.numberOfLines = 1 + subtitleLabel.textAlignment = .center + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + profileViews.append(subtitleLabel) + } + // Horizontal stack for image + name let stack = UIStackView(arrangedSubviews: profileViews) stack.axis = .vertical @@ -291,6 +306,7 @@ func showOpenChatAlert( profileFullName: String, profileImage: Content, theme: AppTheme, + subtitle: String? = nil, cancelTitle: String = "Cancel", confirmTitle: String = "Open", onCancel: @escaping () -> Void = {}, @@ -306,6 +322,7 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + subtitle: subtitle, cancelTitle: cancelTitle, confirmTitle: confirmTitle, onCancel: onCancel, diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 33acf22ebe..71316cc5aa 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -29,6 +29,7 @@ struct VideoPlayerView: UIViewRepresentable { func makeUIView(context: UIViewRepresentableContext) -> UIView { let controller = AVPlayerViewController() controller.showsPlaybackControls = showControls + controller.videoGravity = .resizeAspectFill if #available(iOS 16.0, *) { controller.speeds = [] } 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/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index a28acfcba1..cb3832b727 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -374,10 +374,12 @@ struct MigrateToDevice: View { "Upgrade and open chat", "", .yesUp) - case .downgrade: + case let .downgrade(downMigrations): ("Database downgrade", "Downgrade and open chat", - NSLocalizedString("Warning: you may lose some data!", comment: ""), + ([NSLocalizedString("Warning: you may lose some data!", comment: "")] + + downMigrationWarnings(downMigrations).reversed()) + .joined(separator: "\n"), .yesUpDown) case let .migrationError(mtrError): ("Incompatible database version", diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift new file mode 100644 index 0000000000..098cccef1b --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -0,0 +1,473 @@ +// +// 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 public channel") } + } + .disabled(!canCreate) + } footer: { + if !hasRelays { + ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning")) + } else { + let name = ChatModel.shared.currentUser?.displayName ?? "" + Text("Your profile **\(name)** will be shared with channel relays and subscribers.\nRelays can access channel messages.") + .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 chooseRandomRelays() + 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) + m.creatingChannelId = gInfo.id + 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) + ) + } + } + } + } + + private let maxRelays = 3 + + private func chooseRandomRelays() async throws -> [UserChatRelay] { + let servers = try await getUserServers() + // Operator relays are grouped per operator; custom relays (nil operator) + // are treated independently to maximize trust distribution. + var operatorGroups: [[UserChatRelay]] = [] + var customRelays: [UserChatRelay] = [] + for op in servers { + let relays = op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + guard !relays.isEmpty else { continue } + if op.operator != nil { + operatorGroups.append(relays.shuffled()) + } else { + customRelays = relays.shuffled() + } + } + var selected: [UserChatRelay] = [] + // Prefer at least one custom relay when available - + // user's own infrastructure for trust distribution. + if let relay = customRelays.first { + selected.append(relay) + customRelays.removeFirst() + if selected.count >= maxRelays { return selected } + } + // Round-robin across shuffled groups to distribute relays across operators. + var groups = operatorGroups + customRelays.map { [$0] } + groups.shuffle() + let maxDepth = groups.map(\.count).max() ?? 0 + for depth in 0..= maxRelays { return selected } + } + } + } + return selected + } + + 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 failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.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 + failedCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount)) + } else { + 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 + let failed = relayMemberConnFailed(relay) + if let err = failed { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + relayRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + relayRow(relay, connFailed: false) + } + } + } + } + .compactSectionSpacing() + + Section { + Button("Channel link") { + if activeCount >= total { + showLinkStep = true + } else if activeCount > 0 { + let actions: [UIAlertAction] = if activeCount + failedCount < total { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } + ] + } else { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + cancelAlertAction + ] + } + 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: { actions } + ) + } + } + .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 && relayMemberConnFailed($0) == nil }) { + showLinkStep = true + channelRelaysModel.reset() + } + } + } + + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { + m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + + private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + HStack { + Text(relayDisplayName(relay)) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + + // 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 + ) { + m.creatingChannelId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(gInfo.id) + } + } + } + .navigationBarTitle("Channel link") + } + + private func cancelChannelCreation(_ gInfo: GroupInfo) { + m.creatingChannelId = nil + 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.displayName.isEmpty { return relay.userChatRelay.displayName } + 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, connFailed: Bool = false) -> some View { + let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow) + let text: LocalizedStringKey = connFailed ? "failed" : status.text + return HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + .font(.caption) + } + } +} + +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..8e62923f3f 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 public channel") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Create public 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..63fb7f5221 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,47 @@ private func showPrepareContactAlert( private func showPrepareGroupAlert( connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? ) { + let isChannel = !(groupShortLinkInfo?.direct ?? true) + let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" } 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, + subtitle: isChannel ? subscriberCount : nil, 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?() } @@ -1138,6 +1180,7 @@ private func showOpenKnownGroupAlert( theme: AppTheme, dismiss: Bool ) { + let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" } showOpenChatAlert( profileName: groupInfo.groupProfile.displayName, profileFullName: groupInfo.groupProfile.fullName, @@ -1148,9 +1191,15 @@ private func showOpenKnownGroupAlert( size: alertProfileImageSize ), theme: theme, + subtitle: groupInfo.useRelays ? subscriberCount : nil, 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 +1223,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 +1389,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..4a5cbab184 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -0,0 +1,403 @@ +// +// 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 + } +} + +func addChatRelay( + _ relay: UserChatRelay, + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, + _ dismiss: DismissAction +) { + let nameEmpty = relay.displayName.trimmingCharacters(in: .whitespaces).isEmpty + let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty + if nameEmpty && addressEmpty { + dismiss() + } else if !validRelayName(relay.displayName) { + 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 + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? + + var body: some View { + let validName = validRelayName(relayToEdit.displayName) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + if relay.preset { + presetRelay() + } else { + customRelay(validName: validName, validAddress: validAddress) + } + if testing { + ProgressView().scaleEffect(2) + } + } + .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") + ) + } + }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + if relayToEdit.address == relay.address { + relayToEdit.tested = relay.tested + relayToEdit.displayName = relay.displayName + } else { + relayToEdit.tested = nil + } + } + } + + 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.displayName) } + } + } + } + + private func presetRelay() -> some View { + List { + Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.address) + .textSelection(.enabled) + } + Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.displayName) + } + useRelaySection() + } + } + + private func customRelay(validName: Bool, validAddress: Bool) -> some View { + List { + 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 { + TextField("Enter relay name…", text: $relayToEdit.displayName) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + relayNameHeader(validName: validName) + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } + 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") { + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!valid || testing) + 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 duplicateRelayAddresses: Set + 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 duplicateRelayAddresses.contains(relay.address) { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } else 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.displayName.isEmpty ? relay.displayName : 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 + ) + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? + + var body: some View { + let validName = validRelayName(relayToEdit.displayName) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + List { + 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 { + TextField("Enter relay name…", text: $relayToEdit.displayName) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } + } + } + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!validAddress || testing) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } + if testing { + ProgressView().scaleEffect(2) + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) + }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + relayToEdit.tested = nil + } + } +} + +func testRelayConnection(relay: Binding) async -> RelayTestFailure? { + do { + let (relayProfile, testFailure) = try await testChatRelay(address: relay.wrappedValue.address) + if let f = testFailure { + await MainActor.run { relay.wrappedValue.tested = false } + return f + } + await MainActor.run { + relay.wrappedValue.tested = true + if let relayProfile { + relay.wrappedValue.displayName = relayProfile.displayName + } + } + return nil + } catch { + logger.error("testRelayConnection \(responseError(error))") + await MainActor.run { relay.wrappedValue.tested = false } + return nil + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 64e3d15de0..74b7374654 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 { @@ -434,6 +483,14 @@ func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set { return Set(duplicateHostsList) } +func findDuplicateRelayAddresses(_ serverErrors: [UserServersError]) -> Set { + Set(serverErrors.compactMap { err in + if case let .duplicateChatRelayAddress(_, duplicateAddress) = err { return duplicateAddress } + else { return nil } + }) +} + + func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) { let userServersToSave = userServers.wrappedValue Task { 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..9d068d3b26 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 @@ -41,6 +42,7 @@ struct OperatorView: View { private func operatorView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return VStack { List { Section { @@ -52,6 +54,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 +73,37 @@ 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, + duplicateRelayAddresses: duplicateRelayAddresses, + 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 +123,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -128,6 +155,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -140,7 +168,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 +180,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 +200,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -203,6 +232,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -215,7 +245,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added media & file servers") @@ -227,6 +257,7 @@ struct OperatorView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) } @@ -246,6 +277,7 @@ struct OperatorView: View { currUserServers: $currUserServers, userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, operatorIndex: operatorIndex ) .modifier(ThemedBackground(grouped: true)) @@ -276,18 +308,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 +456,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 +559,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 +614,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..e57df4c5dc 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 @@ -41,7 +43,34 @@ struct YourServersView: View { private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(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, + duplicateRelayAddresses: duplicateRelayAddresses, + 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 +78,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -61,7 +91,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Message servers") @@ -84,6 +114,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -96,7 +127,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 +156,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) } } @@ -136,6 +180,7 @@ struct YourServersView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) howToButton() @@ -144,7 +189,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 +198,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 +213,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 +239,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 +253,7 @@ struct ProtocolServerViewLink: View { ProtocolServerView( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, server: $server, serverToEdit: server, backLabel: backLabel @@ -280,9 +331,27 @@ 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] + @Binding var chatRelays: [UserChatRelay] @Binding var testing: Bool var body: some View { @@ -291,20 +360,24 @@ struct TestServersButton: View { } private var allServersDisabled: Bool { - smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled } + smpServers.allSatisfy { !$0.enabled } && + xftpServers.allSatisfy { !$0.enabled } && + chatRelays.filter({ !$0.deleted }).allSatisfy { !$0.enabled } } private func testServers() { resetTestStatus() testing = true Task { - let fs = await runServersTest() + let rfs = await runRelaysTest() + let sfs = await runServersTest() await MainActor.run { testing = false - if !fs.isEmpty { - let msg = fs.map { (srv, f) in - "\(srv): \(f.localizedDescription)" - }.joined(separator: "\n") + var failures: [String] = [] + failures += rfs.map { (name, f) in "\(name): \(f.localizedDescription)" } + failures += sfs.map { (srv, f) in "\(srv): \(f.localizedDescription)" } + if !failures.isEmpty { + let msg = failures.joined(separator: "\n") showAlert( NSLocalizedString("Tests failed!", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg) @@ -315,6 +388,12 @@ struct TestServersButton: View { } private func resetTestStatus() { + for i in 0.. [String: RelayTestFailure] { + var fs: [String: RelayTestFailure] = [:] + for i in 0.. Грешка при свързване (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4195,6 +4199,10 @@ Error: %2$@ Ако въведете kодa за достъп за самоунищожение, докато отваряте приложението: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Ако трябва да използвате чата сега, докоснете **Отложи** отдолу (ще ви бъде предложено да мигрирате базата данни, когато рестартирате приложението). @@ -9587,6 +9595,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded препратено diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index db9c2e1910..b212f42592 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -1924,6 +1924,10 @@ Toto je váš vlastní jednorázový odkaz! Chyba spojení (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -3650,7 +3654,7 @@ snd error text Fingerprint in server address does not match certificate. - Je možné, že otisk certifikátu v adrese serveru je nesprávný + Otisk certifikátu v adrese serveru neodpovídá. server test error @@ -4038,6 +4042,10 @@ Error: %2$@ Pokud při otevření aplikace zadáte sebedestrukční heslo: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Pokud potřebujete chat používat nyní, klepněte na **Udělat později** níže (migrace databáze vám bude nabídnuta po restartování aplikace). @@ -5205,7 +5213,7 @@ This is your link for group %@! No user identifiers. - Bez uživatelských identifikátorů + Bez uživatelských identifikátorů. No comment provided by engineer. @@ -6815,12 +6823,12 @@ chat item action Server requires authorization to create queues, check password. - Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo + Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo. server test error Server requires authorization to upload, check password. - Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo + Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. server test error @@ -9276,6 +9284,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index cec79a1739..7ab0535158 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -794,6 +794,7 @@ swipe action All messages + Alle Nachrichten No comment provided by engineer. @@ -1148,6 +1149,7 @@ swipe action Audio call + Audioanruf No comment provided by engineer. @@ -2021,6 +2023,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungsfehler (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2589,10 +2595,12 @@ swipe action Delete member messages + Mitgliedsnachrichten löschen No comment provided by engineer. Delete member messages? + Mitgliedsnachrichten löschen? alert title @@ -3870,6 +3878,7 @@ snd error text Filter + Filter No comment provided by engineer. @@ -4336,6 +4345,10 @@ Fehler: %2$@ Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Tippen Sie unten auf **Später wiederholen**, wenn Sie den Chat jetzt benötigen (es wird Ihnen angeboten, die Datenbank bei einem Neustart der App zu migrieren). @@ -4358,6 +4371,7 @@ Fehler: %2$@ Images + Bilder No comment provided by engineer. @@ -4621,6 +4635,7 @@ Weitere Verbesserungen sind bald verfügbar! Invite member + Mitglied einladen No comment provided by engineer. @@ -4848,6 +4863,7 @@ Das ist Ihr Link für die Gruppe %@! Links + Links No comment provided by engineer. @@ -4977,6 +4993,7 @@ Das ist Ihr Link für die Gruppe %@! Member messages will be deleted - this cannot be undone! + Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! alert message @@ -6636,6 +6653,7 @@ swipe action Remove and delete messages + Mitglied entfernen und Nachrichten löschen alert action @@ -7081,14 +7099,17 @@ chat item action Search files + Dateien suchen No comment provided by engineer. Search images + Bilder suchen No comment provided by engineer. Search links + Links suchen No comment provided by engineer. @@ -7098,10 +7119,12 @@ chat item action Search videos + Videos suchen No comment provided by engineer. Search voice messages + Sprachnachrichten suchen No comment provided by engineer. @@ -8981,6 +9004,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Videos + Videos No comment provided by engineer. @@ -10124,6 +10148,10 @@ pref value Abgelaufen No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded weitergeleitet @@ -10988,7 +11016,7 @@ Zuletzt empfangene Nachricht: %2$@ Ok - OK + Ok No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 581cd791a5..fbc3b2dfa8 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2023,6 +2023,11 @@ This is your own one-time link! Connection error (AUTH) No comment provided by engineer. + + Connection failed + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4341,6 +4346,11 @@ Error: %2$@ If you enter your self-destruct passcode while opening the app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). @@ -10140,6 +10150,11 @@ pref value expired No comment provided by engineer. + + failed + failed + No comment provided by engineer. + forwarded forwarded diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index edacbd8e56..61734f2480 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -498,7 +498,7 @@ time interval <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> <p>¡Hola!</p> -<p><a href="%@"> Conecta conmigo a través de SimpleX Chat</a></p> +<p><a href="%@">Conecta conmigo a través de SimpleX Chat</a></p> email text @@ -794,6 +794,7 @@ swipe action All messages + Todos los mensajes No comment provided by engineer. @@ -1148,6 +1149,7 @@ swipe action Audio call + Llamada No comment provided by engineer. @@ -2021,6 +2023,10 @@ This is your own one-time link! Error de conexión (Autenticación) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2589,10 +2595,12 @@ swipe action Delete member messages + Eliminar mensajes del miembro No comment provided by engineer. Delete member messages? + ¿Eliminar mensajes del miembro? alert title @@ -3870,6 +3878,7 @@ snd error text Filter + Filtro No comment provided by engineer. @@ -4336,6 +4345,10 @@ Error: %2$@ Si al abrir la aplicación introduces el código de autodestrucción: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Si necesitas usar el chat ahora pulsa **Hacerlo más tarde** más abajo (se ofrecerá migrar la base de datos cuando se reinicie la aplicación). @@ -4358,6 +4371,7 @@ Error: %2$@ Images + Imágenes No comment provided by engineer. @@ -4621,6 +4635,7 @@ More improvements are coming soon! Invite member + Invitar miembro No comment provided by engineer. @@ -4848,6 +4863,7 @@ This is your link for group %@! Links + Enlaces No comment provided by engineer. @@ -4977,6 +4993,7 @@ This is your link for group %@! Member messages will be deleted - this cannot be undone! + Los mensajes del miembro serán eliminados. ¡No puede deshacerse! alert message @@ -5951,7 +5968,7 @@ Requiere activación de la VPN. Or show this code - O muestra el código QR + O muestra este código No comment provided by engineer. @@ -6636,6 +6653,7 @@ swipe action Remove and delete messages + Eliminar miembro y sus mensajes alert action @@ -7081,14 +7099,17 @@ chat item action Search files + Buscar archivos No comment provided by engineer. Search images + Buscar imágenes No comment provided by engineer. Search links + Buscar enlaces No comment provided by engineer. @@ -7098,10 +7119,12 @@ chat item action Search videos + Buscar vídeos No comment provided by engineer. Search voice messages + Buscar mensajes de voz No comment provided by engineer. @@ -8981,6 +9004,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Videos + Vídeos No comment provided by engineer. @@ -10124,6 +10148,10 @@ pref value expirados No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded reenviado @@ -10598,7 +10626,7 @@ last received msg: %2$@ unprotected - con IP desprotegida + desprotegida No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 00b4bca1d4..5a7813dfe5 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1814,6 +1814,10 @@ This is your own one-time link! Yhteysvirhe (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -3925,6 +3929,10 @@ Error: %2$@ Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen). @@ -9158,6 +9166,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 82912c5d44..7e386fe50c 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -2009,6 +2009,10 @@ Il s'agit de votre propre lien unique ! Erreur de connexion (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4297,6 +4301,10 @@ Erreur : %2$@ Si vous entrez votre code d'autodestruction à l'ouverture de l'application : No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Si vous avez besoin d'utiliser le chat maintenant appuyez sur **le faire plus tard** (vous pourrez migrer la base de données quand vous relancerez l'app). @@ -9930,6 +9938,10 @@ pref value expiré No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded transféré diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 99219c1f40..0ed5dc19ea 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -394,8 +394,8 @@ - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. - - kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! -- kézbesítési jelentések (legfeljebb 20 tag). + - kapcsolódás a [könyvtárszolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- kézbesítési jelentések (legfeljebb 20 tagig). - gyorsabb és stabilabb. No comment provided by engineer. @@ -794,6 +794,7 @@ swipe action All messages + Összes üzenet No comment provided by engineer. @@ -1148,6 +1149,7 @@ swipe action Audio call + Hanghívás No comment provided by engineer. @@ -2021,6 +2023,10 @@ Ez a saját egyszer használható meghívója! Kapcsolódási hiba (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2589,10 +2595,12 @@ swipe action Delete member messages + Tag üzeneteinek törlése No comment provided by engineer. Delete member messages? + Törli a tag üzeneteit? alert title @@ -3870,6 +3878,7 @@ snd error text Filter + Szűrő No comment provided by engineer. @@ -4336,6 +4345,10 @@ Hiba: %2$@ Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). @@ -4358,6 +4371,7 @@ Hiba: %2$@ Images + Képek No comment provided by engineer. @@ -4621,6 +4635,7 @@ További fejlesztések hamarosan! Invite member + Tag meghívása No comment provided by engineer. @@ -4848,6 +4863,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Links + Hivatkozások No comment provided by engineer. @@ -4977,6 +4993,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Member messages will be deleted - this cannot be undone! + A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza! alert message @@ -5246,7 +5263,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration complete - Átköltöztetés befejezve + Átköltöztetés kész No comment provided by engineer. @@ -5261,7 +5278,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration is completed - Az átköltöztetés befejeződött + Az átköltöztetés elkészült No comment provided by engineer. @@ -6162,7 +6179,7 @@ Hiba: %@ Please wait for token activation to complete. - Várjon, amíg a token aktiválása befejeződik. + Várjon, amíg a token aktiválása elkészül. token info @@ -6636,6 +6653,7 @@ swipe action Remove and delete messages + Eltávolítás és az üzeneteinek törlése alert action @@ -7081,14 +7099,17 @@ chat item action Search files + Fájlok keresése No comment provided by engineer. Search images + Képek keresése No comment provided by engineer. Search links + Hivatkozások keresése No comment provided by engineer. @@ -7098,10 +7119,12 @@ chat item action Search videos + Videók keresése No comment provided by engineer. Search voice messages + Hangüzenetek keresése No comment provided by engineer. @@ -8981,6 +9004,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Videos + Videók No comment provided by engineer. @@ -9870,7 +9894,7 @@ marked deleted chat item preview text complete - befejezett + kész No comment provided by engineer. @@ -10124,6 +10148,10 @@ pref value lejárt No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded továbbított diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index e2c826f334..97061054e8 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -794,6 +794,7 @@ swipe action All messages + Tutti i messaggi No comment provided by engineer. @@ -1148,6 +1149,7 @@ swipe action Audio call + Chiamata audio No comment provided by engineer. @@ -2021,6 +2023,10 @@ Questo è il tuo link una tantum! Errore di connessione (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2589,10 +2595,12 @@ swipe action Delete member messages + Elimina i messaggi del membro No comment provided by engineer. Delete member messages? + Eliminare i messaggi del membro? alert title @@ -3870,6 +3878,7 @@ snd error text Filter + Filtro No comment provided by engineer. @@ -4336,6 +4345,10 @@ Errore: %2$@ Se inserisci il tuo codice di autodistruzione mentre apri l'app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Se devi usare la chat adesso, tocca **Fallo più tardi** qui sotto (ti verrà offerto di migrare il database quando riavvii l'app). @@ -4358,6 +4371,7 @@ Errore: %2$@ Images + Immagini No comment provided by engineer. @@ -4621,6 +4635,7 @@ Altri miglioramenti sono in arrivo! Invite member + Invita membro No comment provided by engineer. @@ -4848,6 +4863,7 @@ Questo è il tuo link per il gruppo %@! Links + Link No comment provided by engineer. @@ -4977,6 +4993,7 @@ Questo è il tuo link per il gruppo %@! Member messages will be deleted - this cannot be undone! + I messaggi del membro verranno eliminati. Non è reversibile! alert message @@ -6636,6 +6653,7 @@ swipe action Remove and delete messages + Rimuovi ed elimina i messaggi alert action @@ -7081,14 +7099,17 @@ chat item action Search files + Cerca file No comment provided by engineer. Search images + Cerca immagini No comment provided by engineer. Search links + Cerca link No comment provided by engineer. @@ -7098,10 +7119,12 @@ chat item action Search videos + Cerca video No comment provided by engineer. Search voice messages + Cerca messaggi vocali No comment provided by engineer. @@ -8981,6 +9004,7 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Videos + Video No comment provided by engineer. @@ -10124,6 +10148,10 @@ pref value scaduto No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded inoltrato diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index efd47aa52d..ddec6c47f6 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -1908,6 +1908,10 @@ This is your own one-time link! 接続エラー (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4026,6 +4030,10 @@ Error: %2$@ アプリを開いているときに自己破壊パスコードを入力した場合: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). 今すぐチャットを使用する必要がある場合は、下の **後で実行する**をタップしてください (アプリを再起動すると、データベースを移行するよう求められます)。 @@ -9257,6 +9265,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 955607acfd..e12cb0a483 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2009,6 +2009,10 @@ Dit is uw eigen eenmalige link! Verbindingsfout (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4306,6 +4310,10 @@ Fout: %2$@ Als u uw zelfvernietigings wachtwoord invoert tijdens het openen van de app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Als u de chat nu wilt gebruiken, tikt u hieronder op **Doe het later** (u wordt aangeboden om de database te migreren wanneer u de app opnieuw start). @@ -10029,6 +10037,10 @@ pref value verlopen No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded doorgestuurd diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index f74e1e31b5..ee17f807ba 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -498,7 +498,7 @@ time interval <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> <p>Cześć!</p> -<p><a href="%@">Połącz się ze mną poprzez SimpleX Chat.</a></p> +<p><a href="%@">Połącz się ze mną poprzez SimpleX Chat</a></p> email text @@ -794,6 +794,7 @@ swipe action All messages + Wszystkie wiadomości No comment provided by engineer. @@ -1148,6 +1149,7 @@ swipe action Audio call + Połączenie audio No comment provided by engineer. @@ -1272,10 +1274,12 @@ swipe action Bio + Bio No comment provided by engineer. Bio too large + Bio jest za długie alert title @@ -1380,6 +1384,7 @@ swipe action Business connection + Kontakty biznesowe No comment provided by engineer. @@ -1396,6 +1401,9 @@ swipe action By using SimpleX Chat you agree to: - send only legal content in public groups. - respect other users – no spam. + Korzystając z SimpleX Chat, zgadzasz się: +- wysyłać tylko legalne treści w grupach publicznych. +- szanować innych użytkowników – nie spamować. No comment provided by engineer. @@ -1430,6 +1438,7 @@ swipe action Can't change profile + Nie można zmienić profilu alert title @@ -1491,6 +1500,7 @@ new chat action Change automatic message deletion? + Zmienić automatyczne usuwanie wiadomości? alert title @@ -1646,14 +1656,17 @@ set passcode view Chat with admins + Czatuj z administratorami chat toolbar Chat with member + Czatuj z członkiem No comment provided by engineer. Chat with members before they join. + Porozmawiaj z członkami, zanim dołączą. No comment provided by engineer. @@ -1663,6 +1676,7 @@ set passcode view Chats with members + Czaty z członkami No comment provided by engineer. @@ -1732,10 +1746,12 @@ set passcode view Clear group? + Wyczyścić grupę? No comment provided by engineer. Clear or delete group? + Wyczyścić lub usunąć grupę? No comment provided by engineer. @@ -1760,6 +1776,7 @@ set passcode view Community guidelines violation + Naruszenie zasad społeczności report reason @@ -1799,14 +1816,17 @@ set passcode view Conditions will be accepted for the operator(s): **%@**. + Warunki zostaną zaakceptowane dla operatora(-ów): **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Warunki zostaną zaakceptowane w dniu: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Warunki zostaną automatycznie zaakceptowane dla aktywnych operatorów w dniu: %@. No comment provided by engineer. @@ -1816,6 +1836,7 @@ set passcode view Configure server operators + Skonfiguruj operatorów serwerów No comment provided by engineer. @@ -1870,6 +1891,7 @@ set passcode view Confirmed + Potwierdzony token status text @@ -1884,6 +1906,7 @@ set passcode view Connect faster! 🚀 + Połącz się szybciej! 🚀 No comment provided by engineer. @@ -1987,6 +2010,7 @@ To jest twój jednorazowy link! Connection blocked + Połączenie zablokowane No comment provided by engineer. @@ -1999,13 +2023,20 @@ To jest twój jednorazowy link! Błąd połączenia (UWIERZYTELNIANIE) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ + Połączenie zostało zablokowane przez operatora serwera: +%@ No comment provided by engineer. Connection not ready. + Połączenie nie jest gotowe. No comment provided by engineer. @@ -2020,10 +2051,12 @@ To jest twój jednorazowy link! Connection requires encryption renegotiation. + Połączenie wymaga renegocjacji szyfrowania. No comment provided by engineer. Connection security + Bezpieczeństwo połączenia No comment provided by engineer. @@ -2088,6 +2121,7 @@ To jest twój jednorazowy link! Contact requests from groups + Prośby o kontakt od grup No comment provided by engineer. @@ -2107,6 +2141,7 @@ To jest twój jednorazowy link! Content violates conditions of use + Treść narusza warunki użytkowania blocking reason @@ -2151,6 +2186,7 @@ To jest twój jednorazowy link! Create 1-time link + Utwórz jednorazowy link No comment provided by engineer. @@ -2185,6 +2221,7 @@ To jest twój jednorazowy link! Create list + Utwórz listę No comment provided by engineer. @@ -2204,6 +2241,7 @@ To jest twój jednorazowy link! Create your address + Utwórz swój adres No comment provided by engineer. @@ -2243,6 +2281,7 @@ To jest twój jednorazowy link! Current conditions text couldn't be loaded, you can review conditions via this link: + Nie można załadować tekstu dotyczącego aktualnych warunków. Możesz zapoznać się z warunkami, klikając ten link: No comment provided by engineer. @@ -2267,6 +2306,7 @@ To jest twój jednorazowy link! Customizable message shape. + Konfigurowalny kształt wiadomości. No comment provided by engineer. @@ -2440,10 +2480,12 @@ swipe action Delete chat + Usuń czat No comment provided by engineer. Delete chat messages from your device. + Usuń wiadomości czatu ze swojego urządzenia. No comment provided by engineer. @@ -2458,10 +2500,12 @@ swipe action Delete chat with member? + Usunąć czat z członkiem? alert title Delete chat? + Usunąć czat? No comment provided by engineer. @@ -2541,6 +2585,7 @@ swipe action Delete list? + Usunąć listę? alert title @@ -2550,10 +2595,12 @@ swipe action Delete member messages + Usuń wiadomości członków No comment provided by engineer. Delete member messages? + Usunąć wiadomości członków? alert title @@ -2584,6 +2631,7 @@ alert button Delete or moderate up to 200 messages. + Usuń lub moderuj do 200 wiadomości. No comment provided by engineer. @@ -2603,6 +2651,7 @@ alert button Delete report + Usuń raport No comment provided by engineer. @@ -2642,6 +2691,7 @@ alert button Delivered even when Apple drops them. + Dostarczane nawet wtedy, gdy Apple je wycofa. No comment provided by engineer. @@ -2661,6 +2711,7 @@ alert button Deprecated options + Opcje wycofane No comment provided by engineer. @@ -2670,6 +2721,7 @@ alert button Description too large + Opis jest zbyt długi alert title @@ -2754,6 +2806,7 @@ alert button Direct messages between members are prohibited in this chat. + W tym czacie zabronione jest wysyłanie bezpośrednich wiadomości między członkami. No comment provided by engineer. @@ -2773,10 +2826,12 @@ alert button Disable automatic message deletion? + Wyłączyć automatyczne usuwanie wiadomości? alert title Disable delete messages + Wyłącz usuwanie wiadomości alert button @@ -2871,6 +2926,7 @@ alert button Documents: + Dokumenty: No comment provided by engineer. @@ -2885,6 +2941,7 @@ alert button Don't miss important messages. + Nie przegap ważnych wiadomości. No comment provided by engineer. @@ -2894,6 +2951,7 @@ alert button Done + Gotowe No comment provided by engineer. @@ -2959,6 +3017,7 @@ chat item action E2E encrypted notifications. + Powiadomienia szyfrowane E2E. No comment provided by engineer. @@ -2973,6 +3032,7 @@ chat item action Empty message! + Pusta wiadomość! No comment provided by engineer. @@ -2987,6 +3047,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Włącz opcję Flux w ustawieniach sieci i serwerów, aby zapewnić lepszą prywatność metadanych. No comment provided by engineer. @@ -3011,6 +3072,7 @@ chat item action Enable disappearing messages by default. + Włącz domyślnie znikające wiadomości. No comment provided by engineer. @@ -3135,6 +3197,7 @@ chat item action Encryption renegotiation in progress. + Trwa renegocjacja szyfrowania. No comment provided by engineer. @@ -3204,6 +3267,7 @@ chat item action Error accepting conditions + Błąd podczas akceptacji warunków alert title @@ -3213,6 +3277,7 @@ chat item action Error accepting member + Błąd podczas akceptacji członka alert title @@ -3222,10 +3287,12 @@ chat item action Error adding server + Błąd podczas dodawania serwera alert title Error adding short link + Błąd dodawania krótkiego linku No comment provided by engineer. @@ -3235,6 +3302,7 @@ chat item action Error changing chat profile + Błąd zmiany profilu czatu alert title @@ -3259,6 +3327,7 @@ chat item action Error checking token status + Błąd sprawdzania statusu tokenu No comment provided by engineer. @@ -3268,6 +3337,7 @@ chat item action Error connecting to the server used to receive messages from this connection: %@ + Błąd połączenia z serwerem używanym do odbierania wiadomości z tego połączenia: %@ subscription status explanation @@ -3287,6 +3357,7 @@ chat item action Error creating list + Błąd tworzenia listy alert title @@ -3306,6 +3377,7 @@ chat item action Error creating report + Błąd tworzenia raportu No comment provided by engineer. @@ -3315,6 +3387,7 @@ chat item action Error deleting chat + Błąd usuwania czatu alert title @@ -3394,6 +3467,7 @@ chat item action Error loading servers + Błąd ładowania serwerów alert title @@ -3408,6 +3482,7 @@ chat item action Error opening group + Błąd otwierania grupy No comment provided by engineer. @@ -3427,10 +3502,12 @@ chat item action Error registering for notifications + Błąd rejestracji powiadomień alert title Error rejecting contact request + Błąd odrzucenia prośby o kontakt alert title @@ -3440,6 +3517,7 @@ chat item action Error reordering lists + Błąd ponownego porządkowania list alert title @@ -3454,6 +3532,7 @@ chat item action Error saving chat list + Błąd zapisywania listy czatów alert title @@ -3473,6 +3552,7 @@ chat item action Error saving servers + Błąd zapisywania serwerów alert title @@ -3507,6 +3587,7 @@ chat item action Error setting auto-accept + Błąd ustawiania automatycznego akceptowania No comment provided by engineer. @@ -3541,6 +3622,7 @@ chat item action Error testing server connection + Błąd testowania połączenia z serwerem No comment provided by engineer. @@ -3555,6 +3637,7 @@ chat item action Error updating server + Błąd aktualizacji serwera alert title @@ -3591,6 +3674,7 @@ snd error text Error: %@. + Błąd: %@. server test error @@ -3610,6 +3694,7 @@ snd error text Errors in servers configuration. + Błędy w konfiguracji serwerów. servers error @@ -3629,6 +3714,7 @@ snd error text Expired + Wygasło token status text @@ -3673,6 +3759,7 @@ snd error text Faster deletion of groups. + Szybsze usuwanie grup. No comment provided by engineer. @@ -3682,6 +3769,7 @@ snd error text Faster sending messages. + Szybsze wysyłanie wiadomości. No comment provided by engineer. @@ -3691,6 +3779,7 @@ snd error text Favorites + Ulubione No comment provided by engineer. @@ -3708,6 +3797,8 @@ snd error text File is blocked by server operator: %@. + Plik jest zablokowany przez operatora serwera: +%@. file error text @@ -3767,6 +3858,7 @@ snd error text Files and media are prohibited in this chat. + W tym czacie nie wolno przesyłać plików ani multimediów. No comment provided by engineer. @@ -3786,6 +3878,7 @@ snd error text Filter + Filtr No comment provided by engineer. @@ -3815,19 +3908,22 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + Odcisk palca w adresie serwera docelowego nie zgadza się z certyfikatem: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + Odcisk palca w adresie serwera przekazującego nie zgadza się z certyfikatem: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy + Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy. server test error Fingerprint in server address does not match certificate: %@. + Odcisk palca w adresie serwera nie zgadza się z certyfikatem: %@. No comment provided by engineer. @@ -3862,10 +3958,12 @@ snd error text For all moderators + Dla wszystkich moderatorów No comment provided by engineer. For chat profile %@: + Dla profilu czatu %@: servers error @@ -3875,18 +3973,22 @@ snd error text For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Na przykład, jeśli Twój kontakt odbiera wiadomości za pośrednictwem serwera SimpleX Chat, Twoja aplikacja będzie je dostarczać za pośrednictwem serwera Flux. No comment provided by engineer. For me + Dla mnie No comment provided by engineer. For private routing + Dla prywatnego routingu No comment provided by engineer. For social media + Dla mediów społecznościowych No comment provided by engineer. @@ -3916,6 +4018,7 @@ snd error text Forward up to 20 messages at once. + Przekaż jednocześnie do 20 wiadomości. No comment provided by engineer. @@ -4004,6 +4107,7 @@ Błąd: %2$@ Get notified when mentioned. + Otrzymuj powiadomienia, gdy ktoś wspomni o Tobie. No comment provided by engineer. @@ -4098,6 +4202,7 @@ Błąd: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. + Profil grupy został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do członków grupy. alert message @@ -4117,6 +4222,7 @@ Błąd: %2$@ Groups + Grupy No comment provided by engineer. @@ -4126,6 +4232,7 @@ Błąd: %2$@ Help admins moderating their groups. + Pomóż administratorom moderować ich grupy. No comment provided by engineer. @@ -4180,14 +4287,17 @@ Błąd: %2$@ How it affects privacy + Jak to wpływa na prywatność No comment provided by engineer. How it helps privacy + Jak to pomaga chronić prywatność No comment provided by engineer. How it works + Jak to działa alert button @@ -4235,6 +4345,10 @@ Błąd: %2$@ Jeśli wpiszesz swój pin samodestrukcji podczas otwierania aplikacji: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Jeśli potrzebujesz użyć czatu teraz, dotknij **Zrób to później** poniżej (zostanie Ci zaproponowana migracja bazy danych po ponownym uruchomieniu aplikacji). @@ -4257,6 +4371,7 @@ Błąd: %2$@ Images + Zdjęcia No comment provided by engineer. @@ -4302,6 +4417,8 @@ Błąd: %2$@ Improved delivery, reduced traffic usage. More improvements are coming soon! + Ulepszona dostawa, mniejsze zużycie ruchu. +Wkrótce pojawią się kolejne ulepszenia! No comment provided by engineer. @@ -4336,10 +4453,12 @@ More improvements are coming soon! Inappropriate content + Nieodpowiednia treść report reason Inappropriate profile + Nieodpowiedni profil report reason @@ -4436,22 +4555,27 @@ More improvements are coming soon! Invalid + Nieprawidłowy token status text Invalid (bad token) + Nieprawidłowy (zły token) token status text Invalid (expired) + Nieważny (wygasły) token status text Invalid (unregistered) + Nieprawidłowy (niezarejestrowany) token status text Invalid (wrong topic) + Nieprawidłowy (niewłaściwy temat) token status text @@ -4511,6 +4635,7 @@ More improvements are coming soon! Invite member + Zaproś członka No comment provided by engineer. @@ -4520,6 +4645,7 @@ More improvements are coming soon! Invite to chat + Zaproś do czatu No comment provided by engineer. @@ -4642,6 +4768,7 @@ To jest twój link do grupy %@! Keep your chats clean + Utrzymuj czystość swoich czatów No comment provided by engineer. @@ -4681,10 +4808,12 @@ To jest twój link do grupy %@! Leave chat + Opuść czat No comment provided by engineer. Leave chat? + Opuścić czat? No comment provided by engineer. @@ -4699,6 +4828,7 @@ To jest twój link do grupy %@! Less traffic on mobile networks. + Mniejszy ruch w sieciach komórkowych. No comment provided by engineer. @@ -4733,18 +4863,22 @@ To jest twój link do grupy %@! Links + Linki No comment provided by engineer. List + Lista swipe action List name and emoji should be different for all lists. + Nazwa listy i emoji powinny być różne dla wszystkich list. No comment provided by engineer. List name... + Nazwa listy... No comment provided by engineer. @@ -4759,6 +4893,7 @@ To jest twój link do grupy %@! Loading profile… + Ładowanie profilu… in progress text @@ -4838,10 +4973,12 @@ To jest twój link do grupy %@! Member %@ + Członek %@ past/unknown group member Member admission + Przyjmowanie członków No comment provided by engineer. @@ -4851,18 +4988,22 @@ To jest twój link do grupy %@! Member is deleted - can't accept request + Członek został usunięty – nie można zaakceptować prośby No comment provided by engineer. Member messages will be deleted - this cannot be undone! + Wiadomości członków zostaną usunięte – nie można tego cofnąć! alert message Member reports + Raporty członków chat feature Member role will be changed to "%@". All chat members will be notified. + Rola członka zostanie zmieniona na "%@". Wszyscy członkowie czatu zostaną o tym poinformowani. No comment provided by engineer. @@ -4877,6 +5018,7 @@ To jest twój link do grupy %@! Member will be removed from chat - this cannot be undone! + Członek zostanie usunięty z czatu – nie można tego cofnąć! alert message @@ -4886,6 +5028,7 @@ To jest twój link do grupy %@! Member will join the group, accept member? + Członek dołączy do grupy, zaakceptować członka? alert message @@ -4900,6 +5043,7 @@ To jest twój link do grupy %@! Members can report messsages to moderators. + Członkowie mogą zgłaszać wiadomości moderatorom. No comment provided by engineer. @@ -4929,6 +5073,7 @@ To jest twój link do grupy %@! Mention members 👋 + Wspomnij członków 👋 No comment provided by engineer. @@ -4963,6 +5108,7 @@ To jest twój link do grupy %@! Message instantly once you tap Connect. + Wysyłaj wiadomości natychmiast po dotknięciu przycisku „Połącz”. No comment provided by engineer. @@ -5042,6 +5188,7 @@ To jest twój link do grupy %@! Messages are protected by **end-to-end encryption**. + Wiadomości są chronione przez **szyfrowanie typu end-to-end**. No comment provided by engineer. @@ -5051,6 +5198,7 @@ To jest twój link do grupy %@! Messages in this chat will never be deleted. + Wiadomości na tym czacie nigdy nie zostaną usunięte. alert message @@ -5155,6 +5303,7 @@ To jest twój link do grupy %@! More + Więcej swipe action @@ -5169,6 +5318,7 @@ To jest twój link do grupy %@! More reliable notifications + Bardziej niezawodne powiadomienia No comment provided by engineer. @@ -5188,6 +5338,7 @@ To jest twój link do grupy %@! Mute all + Wycisz wszystko notification label action @@ -5212,6 +5363,7 @@ To jest twój link do grupy %@! Network decentralization + Decentralizacja sieci No comment provided by engineer. @@ -5226,6 +5378,7 @@ To jest twój link do grupy %@! Network operator + Operator sieci No comment provided by engineer. @@ -5240,6 +5393,7 @@ To jest twój link do grupy %@! New + Nowy token status text @@ -5289,10 +5443,12 @@ To jest twój link do grupy %@! New events + Nowe wydarzenia notification New group role: Moderator + Nowa rola w grupie: Moderator No comment provided by engineer. @@ -5312,6 +5468,7 @@ To jest twój link do grupy %@! New member wants to join the group. + Nowy członek chce dołączyć do grupy. rcv group event chat item @@ -5326,6 +5483,7 @@ To jest twój link do grupy %@! New server + Nowy serwer No comment provided by engineer. @@ -5340,18 +5498,22 @@ To jest twój link do grupy %@! No chats + Żadnych czatów No comment provided by engineer. No chats found + Nie znaleziono żadnych czatów No comment provided by engineer. No chats in list %@ + Brak czatów na liście %@ No comment provided by engineer. No chats with members + Żadnych rozmów z członkami No comment provided by engineer. @@ -5401,14 +5563,17 @@ To jest twój link do grupy %@! No media & file servers. + Brak mediów i serwerów plików multimedialnych. servers error No message + Brak wiadomości No comment provided by engineer. No message servers. + Brak serwerów wiadomości. servers error @@ -5433,6 +5598,7 @@ To jest twój link do grupy %@! No private routing session + Brak prywatnej sesji routingu alert title @@ -5447,26 +5613,32 @@ To jest twój link do grupy %@! No servers for private message routing. + Brak serwerów prywatnej sesji routingu. servers error No servers to receive files. + Brak serwerów do otrzymania plików. servers error No servers to receive messages. + Brak serwerów aby otrzymać wiadomości. servers error No servers to send files. + Brak serwerów do wysyłania plików. servers error No token! + Brak tokenu! alert title No unread chats + Brak nieprzeczytanych czatów No comment provided by engineer. @@ -5481,6 +5653,7 @@ To jest twój link do grupy %@! Notes + Notatki No comment provided by engineer. @@ -5505,14 +5678,17 @@ To jest twój link do grupy %@! Notifications error + Błąd powiadomień alert title Notifications privacy + Prywatność powiadomień No comment provided by engineer. Notifications status + Stan powiadomień alert title @@ -5572,6 +5748,7 @@ Wymaga włączenia VPN. Only chat owners can change preferences. + Tylko właściciele czatu mogą zmieniać preferencje. No comment provided by engineer. @@ -5601,10 +5778,12 @@ Wymaga włączenia VPN. Only sender and moderators see it + Widzą to tylko nadawca i moderatorzy No comment provided by engineer. Only you and moderators see it + Widzisz to tylko Ty i moderatorzy No comment provided by engineer. @@ -5629,6 +5808,7 @@ Wymaga włączenia VPN. Only you can send files and media. + Tylko Ty możesz wysyłać pliki i multimedia. No comment provided by engineer. @@ -5658,6 +5838,7 @@ Wymaga włączenia VPN. Only your contact can send files and media. + Tylko Twój kontakt może wysyłać pliki i multimedia. No comment provided by engineer. @@ -5677,6 +5858,7 @@ Wymaga włączenia VPN. Open changes + Otwórz zmiany No comment provided by engineer. @@ -5691,14 +5873,17 @@ Wymaga włączenia VPN. Open clean link + Otwórz czysty link alert action Open conditions + Otwórz warunki No comment provided by engineer. Open full link + Otwórz pełny link alert action @@ -5708,6 +5893,7 @@ Wymaga włączenia VPN. Open link? + Otworzyć link? alert title @@ -5717,26 +5903,32 @@ Wymaga włączenia VPN. Open new chat + Otwórz nowy czat new chat action Open new group + Otwórz nową grupę new chat action Open to accept + Otwórz by zaakceptować No comment provided by engineer. Open to connect + Otwórz aby się połączyć No comment provided by engineer. Open to join + Otwórz aby dołączyć No comment provided by engineer. Open to use bot + Otwórz aby skorzystać z bota No comment provided by engineer. @@ -5746,14 +5938,17 @@ Wymaga włączenia VPN. Operator + Operator No comment provided by engineer. Operator server + Serwer Operatora alert title Or import archive file + Lub zaimportuj plik archiwalny No comment provided by engineer. @@ -5778,10 +5973,12 @@ Wymaga włączenia VPN. Or to share privately + Lub udostępnij prywatnie No comment provided by engineer. Organize chats into lists + Organizuj czaty jako listy No comment provided by engineer. @@ -5972,18 +6169,22 @@ Błąd: %@ Please try to disable and re-enable notfications. + Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia. token info Please wait for group moderators to review your request to join the group. + Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. snd group event chat item Please wait for token activation to complete. + Proszę poczekać na zakończenie aktywacji tokenu. token info Please wait for token to be registered. + Proszę poczekać na zarejestrowanie tokenu. token info @@ -6008,6 +6209,7 @@ Błąd: %@ Preset servers + Domyślne serwery No comment provided by engineer. @@ -6027,10 +6229,12 @@ Błąd: %@ Privacy for your customers. + Prywatność dla Twoich klientów. No comment provided by engineer. Privacy policy and conditions of use. + Polityka prywatności i warunki korzystania. No comment provided by engineer. @@ -6040,6 +6244,7 @@ Błąd: %@ Private chats, groups and your contacts are not accessible to server operators. + Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów. No comment provided by engineer. @@ -6049,6 +6254,7 @@ Błąd: %@ Private media file names. + Nazwy prywatnych plików multimedialnych. No comment provided by engineer. @@ -6078,6 +6284,7 @@ Błąd: %@ Private routing timeout + Limit czasu routingu prywatnego alert title @@ -6132,6 +6339,7 @@ Błąd: %@ Prohibit reporting messages to moderators. + Zabroń raportowania wiadomości moderatorom. No comment provided by engineer. @@ -6183,6 +6391,7 @@ Włącz w ustawianiach *Sieć i serwery* . Protocol background timeout + Limit czasu protokołu w tle No comment provided by engineer. @@ -6392,14 +6601,17 @@ Włącz w ustawianiach *Sieć i serwery* . Register + Zarejestruj No comment provided by engineer. Register notification token? + Zarejestrować token powiadomień? token info Registered + Zarejestrowany token status text @@ -6421,6 +6633,7 @@ swipe action Reject member? + Odrzucić członka? alert title @@ -6440,6 +6653,7 @@ swipe action Remove and delete messages + Usuń i skasuj wiadomości alert action @@ -6454,6 +6668,7 @@ swipe action Remove link tracking + Usuń śledzenie linków No comment provided by engineer. @@ -6473,6 +6688,7 @@ swipe action Removes messages and blocks members. + Usuwa wiadomości i blokuje członków. No comment provided by engineer. @@ -6512,46 +6728,57 @@ swipe action Report + Zgłoś chat item action Report content: only group moderators will see it. + Zgłoś treść: zobaczą ją tylko moderatorzy grupy. report reason Report member profile: only group moderators will see it. + Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. report reason Report other: only group moderators will see it. + Zgłoś inne: zobaczą to tylko moderatorzy grupy. report reason Report reason? + Jaki jest powód zgłoszenia? No comment provided by engineer. Report sent to moderators + Zgłoszenia wysłane do moderatorów alert title Report spam: only group moderators will see it. + Zgłoś spam: tylko moderatorzy grupy będą to widzieć. report reason Report violation: only group moderators will see it. + Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy. report reason Report: %@ + Zgłoszenie: %@ report in notification Reporting messages to moderators is prohibited. + Zgłaszanie wiadomości moderatorom jest zabronione. No comment provided by engineer. Reports + Zgłoszenia No comment provided by engineer. @@ -6641,18 +6868,22 @@ swipe action Review conditions + Przejrzyj warunki No comment provided by engineer. Review group members + Przejrzyj członków grupy No comment provided by engineer. Review members + Przejrzyj członków admission stage Review members before admitting ("knocking"). + Przejrzyj członków przed dopuszczeniem ("zapukaj"). admission stage description @@ -6713,10 +6944,12 @@ chat item action Save (and notify members) + Zapisz (i powiadom członków) alert button Save admission settings? + Zapisać ustawienia wstępu? alert title @@ -6746,10 +6979,12 @@ chat item action Save group profile? + Zapisać profil grupy? alert title Save list + Zapisz listę No comment provided by engineer. @@ -6864,14 +7099,17 @@ chat item action Search files + Szukaj plików No comment provided by engineer. Search images + Szukaj zdjęć No comment provided by engineer. Search links + Szukaj linków No comment provided by engineer. @@ -6881,10 +7119,12 @@ chat item action Search videos + Szukaj wideo No comment provided by engineer. Search voice messages + Szukaj wiadomości głosowych No comment provided by engineer. @@ -6964,6 +7204,7 @@ chat item action Send contact request? + Wysłać prośbę o kontakt? No comment provided by engineer. @@ -7018,6 +7259,7 @@ chat item action Send private reports + Wyślij prywatne zgłoszenia No comment provided by engineer. @@ -7032,10 +7274,12 @@ chat item action Send request + Wyślij prośbę No comment provided by engineer. Send request without message + Wyślij prośbę bez wiadomości No comment provided by engineer. @@ -7050,6 +7294,7 @@ chat item action Send your private feedback to groups. + Wyślij swoją prywatną opinię do grup. No comment provided by engineer. @@ -7154,6 +7399,7 @@ chat item action Server added to operator %@. + Serwer został dodany do operatora %@. alert message @@ -7173,24 +7419,27 @@ chat item action Server operator changed. + Operator serwera został zmieniony. alert title Server operators + Operatorzy serwera No comment provided by engineer. Server protocol changed. + Protokół serwera zmieniony. alert title Server requires authorization to create queues, check password. - Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło + Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło. server test error Server requires authorization to upload, check password. - Serwer wymaga autoryzacji do przesłania, sprawdź hasło + Serwer wymaga autoryzacji do przesłania, sprawdź hasło. server test error @@ -7240,6 +7489,7 @@ chat item action Set chat name… + Ustaw nazwę czatu… No comment provided by engineer. @@ -7264,10 +7514,12 @@ chat item action Set member admission + Ustaw przyjmowanie członków No comment provided by engineer. Set message expiration in chats. + Ustaw datę wygaśnięcia wiadomości na czatach. No comment provided by engineer. @@ -7287,6 +7539,7 @@ chat item action Set profile bio and welcome message. + Ustaw biografię profilu i wiadomość powitalną. No comment provided by engineer. @@ -7327,10 +7580,12 @@ chat item action Share 1-time link with a friend + Udostępnij jednorazowy link znajomemu No comment provided by engineer. Share SimpleX address on social media. + Udostępnij adres SimpleX w mediach społecznościowych. No comment provided by engineer. @@ -7340,6 +7595,7 @@ chat item action Share address publicly + Udostępnij adres publicznie No comment provided by engineer. @@ -7359,10 +7615,12 @@ chat item action Share old address + Udostępnij stary adres alert button Share old link + Udostępnij stary link alert button @@ -7387,18 +7645,22 @@ chat item action Share your address + Udostępnij swój adres No comment provided by engineer. Short SimpleX address + Krótki adres SimpleX No comment provided by engineer. Short description + Krótki opis No comment provided by engineer. Short link + Krótki link No comment provided by engineer. @@ -7458,6 +7720,7 @@ chat item action SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux. No comment provided by engineer. @@ -7492,10 +7755,12 @@ chat item action SimpleX address and 1-time links are safe to share via any messenger. + Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator. No comment provided by engineer. SimpleX address or 1-time link? + Adres SimpleX czy link jednorazowy? No comment provided by engineer. @@ -7505,6 +7770,7 @@ chat item action SimpleX channel link + Link do kanału na SimpleX simplex link type @@ -7544,10 +7810,12 @@ chat item action SimpleX protocols reviewed by Trail of Bits. + Protokoły SimpleX sprawdzone przez Trail of Bits. No comment provided by engineer. SimpleX relay link + łącze przekaźnikowe SimpleX simplex link type @@ -7603,6 +7871,8 @@ chat item action Some servers failed the test: %@ + Niektóre serwery nie przeszły testu: +%@ alert message @@ -7612,6 +7882,7 @@ chat item action Spam + Spam blocking reason report reason @@ -7702,6 +7973,7 @@ report reason Storage + Magazyn No comment provided by engineer. @@ -7736,10 +8008,12 @@ report reason Switch audio and video during the call. + Przełączanie audio i wideo podczas połączenia. No comment provided by engineer. Switch chat profile for 1-time invitations. + Przełącz profil czatu dla zaproszeń jednorazowych. No comment provided by engineer. @@ -7759,6 +8033,7 @@ report reason TCP connection bg timeout + Przekroczono limit czasu połączenia TCP No comment provided by engineer. @@ -7768,6 +8043,7 @@ report reason TCP port for messaging + Port TCP dla wiadomości No comment provided by engineer. @@ -7797,22 +8073,27 @@ report reason Tap Connect to chat + Dotknij Połącz aby rozpocząć czat No comment provided by engineer. Tap Connect to send request + Dotknij Połącz, aby wysłać prośbę No comment provided by engineer. Tap Connect to use bot + Dotknij Połącz aby użyć bota No comment provided by engineer. Tap Create SimpleX address in the menu to create it later. + Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. No comment provided by engineer. Tap Join group + Dotknij Dołącz do grupy No comment provided by engineer. @@ -7862,6 +8143,7 @@ report reason Test notifications + Powiadomienia testowe No comment provided by engineer. @@ -7903,6 +8185,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The address will be short, and your profile will be shared via the address. + Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu. alert message @@ -7912,6 +8195,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The app protects your privacy by using different operators in each conversation. + Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. No comment provided by engineer. @@ -7931,6 +8215,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The connection reached the limit of undelivered messages, your contact may be offline. + Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. No comment provided by engineer. @@ -7965,6 +8250,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The link will be short, and group profile will be shared via the link. + Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. alert message @@ -7994,10 +8280,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The same conditions will apply to operator **%@**. + Te same warunki będą miały zastosowanie do operatora **%@**. No comment provided by engineer. The second preset operator in the app! + Drugi predefiniowany operator w aplikacji! No comment provided by engineer. @@ -8017,6 +8305,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The servers for new files of your current chat profile **%@**. + Serwery dla nowych plików Twojego bieżącego profilu czatu **%@**. No comment provided by engineer. @@ -8036,6 +8325,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom These conditions will also apply for: **%@**. + Warunki te będą miały również zastosowanie w przypadku: **%@**. No comment provided by engineer. @@ -8060,6 +8350,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. alert message @@ -8099,6 +8390,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. No comment provided by engineer. @@ -8108,6 +8400,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This message was deleted or not received yet. + Ta wiadomość została usunięta lub jeszcze nie otrzymana. No comment provided by engineer. @@ -8117,10 +8410,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This setting is for your current profile **%@**. + To ustawienie jest dla Twojego obecnego profilu **%@**. No comment provided by engineer. Time to disappear is set only for new contacts. + Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. No comment provided by engineer. @@ -8150,6 +8445,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To protect against your link being replaced, you can compare contact security codes. + Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. No comment provided by engineer. @@ -8176,6 +8472,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To receive + Żeby odebrać No comment provided by engineer. @@ -8200,10 +8497,12 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To send + Żeby wysłać No comment provided by engineer. To send commands you must be connected. + Aby wysyłać polecenia, musisz być podłączony. alert message @@ -8213,10 +8512,12 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To use another profile after connection attempt, delete the chat and use the link again. + Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie. alert message To use the servers of **%@**, accept conditions of use. + Aby korzystać z serwerów **%@**, należy zaakceptować warunki użytkowania. No comment provided by engineer. @@ -8236,6 +8537,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Token status: %@. + Stan tokena: %@. token status @@ -8260,6 +8562,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Trying to connect to the server used to receive messages from this connection. + Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia. subscription status explanation @@ -8309,6 +8612,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Undelivered messages + Niedostarczone wiadomości No comment provided by engineer. @@ -8405,6 +8709,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unsupported connection link + Nieobsługiwane łącze połączenia No comment provided by engineer. @@ -8434,6 +8739,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Updated conditions + Zaktualizowane warunki No comment provided by engineer. @@ -8443,14 +8749,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Upgrade + Zaktualizuj alert button Upgrade address + Uaktualnij adres No comment provided by engineer. Upgrade address? + Uaktualnić adres? alert message @@ -8460,14 +8769,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Upgrade group link? + Uaktualnić link do grupy? alert message Upgrade link + Uaktualnij link No comment provided by engineer. Upgrade your address + Zaktualizuj swój adres No comment provided by engineer. @@ -8502,6 +8814,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use %@ + Użyj %@ No comment provided by engineer. @@ -8521,10 +8834,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use TCP port %@ when no port is specified. + Jeśli nie podano portu, należy użyć portu TCP %@. No comment provided by engineer. Use TCP port 443 for preset servers only. + Używaj portu TCP 443 tylko dla domyślnych serwerów. No comment provided by engineer. @@ -8539,10 +8854,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use for files + Użyj dla plików No comment provided by engineer. Use for messages + Użyj dla wiadomości No comment provided by engineer. @@ -8562,6 +8879,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use incognito profile + Użyj profilu incognito No comment provided by engineer. @@ -8591,6 +8909,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use servers + Użyj serwerów No comment provided by engineer. @@ -8605,6 +8924,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use web port + Użyj portu internetowego No comment provided by engineer. @@ -8684,6 +9004,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Videos + Wideo No comment provided by engineer. @@ -8693,6 +9014,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc View conditions + Zobacz warunki No comment provided by engineer. @@ -8702,6 +9024,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc View updated conditions + Zobacz zaktualizowane warunki No comment provided by engineer. @@ -8801,6 +9124,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Welcome your contacts 👋 + Powitaj swoje kontakty 👋 No comment provided by engineer. @@ -8820,6 +9144,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. No comment provided by engineer. @@ -8919,6 +9244,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc You are already connected with %@. + Zostałeś już połączony z %@. No comment provided by engineer. @@ -8955,6 +9281,7 @@ Powtórzyć prośbę dołączenia? You are connected to the server used to receive messages from this connection. + Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia. subscription status explanation @@ -8964,6 +9291,7 @@ Powtórzyć prośbę dołączenia? You are not connected to the server used to receive messages from this connection (no subscription). + Nie masz połączenia z serwerem służącym do odbierania wiadomości w ramach tego połączenia (brak subskrypcji). subscription status explanation @@ -8983,6 +9311,7 @@ Powtórzyć prośbę dołączenia? You can configure servers via settings. + Serwery można skonfigurować w ustawieniach. No comment provided by engineer. @@ -9027,6 +9356,7 @@ Powtórzyć prośbę dołączenia? You can set connection name, to remember who the link was shared with. + Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony. No comment provided by engineer. @@ -9071,6 +9401,7 @@ Powtórzyć prośbę dołączenia? You can view your reports in Chat with admins. + Możesz przeglądać swoje raporty w czacie z administratorami. alert message @@ -9152,10 +9483,12 @@ Powtórzyć prośbę połączenia? You should receive notifications. + Powinieneś otrzymywać powiadomienia. token info You will be able to send messages **only after your request is accepted**. + Będziesz mógł wysyłać wiadomości **dopiero po zaakceptowaniu Twojej prośby**. No comment provided by engineer. @@ -9190,6 +9523,7 @@ Powtórzyć prośbę połączenia? You will stop receiving messages from this chat. Chat history will be preserved. + Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. No comment provided by engineer. @@ -9224,6 +9558,7 @@ Powtórzyć prośbę połączenia? Your business contact + Twój kontakt biznesowy No comment provided by engineer. @@ -9253,6 +9588,7 @@ Powtórzyć prośbę połączenia? Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile. + Twoja rozmowa została przeniesiona do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd. alert message @@ -9262,6 +9598,7 @@ Powtórzyć prośbę połączenia? Your contact + Twój kontakt No comment provided by engineer. @@ -9296,6 +9633,7 @@ Powtórzyć prośbę połączenia? Your group + Twoja grupa No comment provided by engineer. @@ -9385,6 +9723,7 @@ Powtórzyć prośbę połączenia? accepted %@ + zaakceptowano %@ rcv group event chat item @@ -9394,10 +9733,12 @@ Powtórzyć prośbę połączenia? accepted invitation + zaproszenie zaakceptowane chat list item title accepted you + przyjął cię rcv group event chat item @@ -9422,6 +9763,7 @@ Powtórzyć prośbę połączenia? all + wszystkie member criteria value @@ -9441,6 +9783,7 @@ Powtórzyć prośbę połączenia? archived report + zarchiwizowany raport No comment provided by engineer. @@ -9511,6 +9854,7 @@ marked deleted chat item preview text can't send messages + nie można wysłać wiadomości No comment provided by engineer. @@ -9615,10 +9959,12 @@ marked deleted chat item preview text contact deleted + kontakt usunięty No comment provided by engineer. contact disabled + kontakt wyłączony No comment provided by engineer. @@ -9633,10 +9979,12 @@ marked deleted chat item preview text contact not ready + kontakt nie gotowy No comment provided by engineer. contact should accept… + kontakt powinien zaakceptować… No comment provided by engineer. @@ -9800,6 +10148,10 @@ pref value wygasły No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded przekazane dalej @@ -9807,6 +10159,7 @@ pref value group + grupa shown on group welcome message @@ -9816,6 +10169,7 @@ pref value group is deleted + grupa została usunięta No comment provided by engineer. @@ -9940,6 +10294,7 @@ pref value member has old version + członek posiada starą wersję No comment provided by engineer. @@ -9974,6 +10329,7 @@ pref value moderator + moderator member role @@ -10003,6 +10359,7 @@ pref value no subscription + brak subskrypcji No comment provided by engineer. @@ -10012,6 +10369,7 @@ pref value not synchronized + nie zsynchronizowano No comment provided by engineer. @@ -10069,14 +10427,17 @@ time to disappear pending + oczekuje No comment provided by engineer. pending approval + oczekuje na zatwierdzenie No comment provided by engineer. pending review + oczekuje na ocenę No comment provided by engineer. @@ -10096,6 +10457,7 @@ time to disappear rejected + odrzucono No comment provided by engineer. @@ -10120,6 +10482,7 @@ time to disappear removed from group + usunięty z grupy No comment provided by engineer. @@ -10134,30 +10497,37 @@ time to disappear request is sent + prośba została wysłana No comment provided by engineer. request to join rejected + prośba o dołączenie została odrzucona No comment provided by engineer. requested connection + prośba o połączenie rcv group event chat item requested connection from group %@ + prośba o połączenie od grupy %@ rcv direct event chat item requested to connect + poproszono o połączenie chat list item title review + ocena No comment provided by engineer. reviewed by admins + sprawdzone przez administratorów No comment provided by engineer. @@ -10346,6 +10716,7 @@ ostatnia otrzymana wiadomość: %2$@ you accepted this member + zaakceptowałeś tego członka snd group event chat item @@ -10481,22 +10852,27 @@ ostatnia otrzymana wiadomość: %2$@ %d new events + %d nowych wydarzeń notification body From %d chat(s) + Z %d czatu(ów) notification body From: %@ + Od: %@ notification body New events + Nowe wydarzenia notification New messages + Nowe wiadomości notification diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 64905cf68c..d9a5c48dda 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2021,6 +2021,10 @@ This is your own one-time link! Ошибка соединения (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4336,6 +4340,10 @@ Error: %2$@ Если Вы введёте код самоуничтожения при открытии приложения: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Если сейчас Вам нужно использовать чат, нажмите **Отложить** внизу (Вы сможете мигрировать данные чата при следующем запуске приложения). @@ -10123,6 +10131,10 @@ pref value истекло No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded переслано diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 4ff953c62e..13d3240daf 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1805,6 +1805,10 @@ This is your own one-time link! การเชื่อมต่อผิดพลาด (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -3910,6 +3914,10 @@ Error: %2$@ หากคุณใส่รหัสผ่านทำลายตัวเองขณะเปิดแอป: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). หากคุณจำเป็นต้องใช้แชทตอนนี้ ให้แตะ **ทำในภายหลัง** ด้านล่าง (ระบบจะเสนอให้คุณย้ายฐานข้อมูลเมื่อคุณรีสตาร์ทแอป) @@ -9125,6 +9133,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 346d9a2bdc..c97da9e0b5 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2021,6 +2021,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı hatası (DOĞRULAMA) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4331,6 +4335,10 @@ Hata: %2$@ Uygulamayı açarken kendi kendini imha eden şifrenizi girerseniz: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Sohbeti şimdi kullanmanız gerekiyorsa aşağıdaki **Daha sonra yap** seçeneğine dokunun (uygulamayı yeniden başlattığınızda veritabanını taşımanız önerilecektir). @@ -10116,6 +10124,10 @@ pref value süresi dolmuş No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded iletildi diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 6c103e17e1..9cc95a6085 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2017,6 +2017,10 @@ This is your own one-time link! Помилка підключення (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -4323,6 +4327,10 @@ Error: %2$@ Якщо ви введете пароль самознищення під час відкриття програми: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Якщо вам потрібно скористатися чатом зараз, натисніть **Зробити це пізніше** нижче (вам буде запропоновано перенести базу даних при перезапуску програми). @@ -10096,6 +10104,10 @@ pref value закінчився No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded переслано diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index ff7b4fa141..fbb118774a 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -794,6 +794,7 @@ swipe action All messages + 所有消息 No comment provided by engineer. @@ -1148,6 +1149,7 @@ swipe action Audio call + 语音通话 No comment provided by engineer. @@ -2021,6 +2023,10 @@ This is your own one-time link! 连接错误(AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2588,6 +2594,7 @@ swipe action Delete member messages + 删除成员消息 No comment provided by engineer. @@ -3868,6 +3875,7 @@ snd error text Filter + 过滤器 No comment provided by engineer. @@ -4334,6 +4342,10 @@ Error: %2$@ 如果您在打开应用程序时输入自毁密码: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). 如果您现在需要使用聊天,请点击下面的**稍后再做**(当您重新启动应用程序时,系统会提示您迁移数据库)。 @@ -4356,6 +4368,7 @@ Error: %2$@ Images + 图片 No comment provided by engineer. @@ -4619,6 +4632,7 @@ More improvements are coming soon! Invite member + 邀请成员 No comment provided by engineer. @@ -4846,6 +4860,7 @@ This is your link for group %@! Links + 链接 No comment provided by engineer. @@ -6633,6 +6648,7 @@ swipe action Remove and delete messages + 移除并删除消息 alert action @@ -7077,14 +7093,17 @@ chat item action Search files + 搜索文件 No comment provided by engineer. Search images + 搜索图片 No comment provided by engineer. Search links + 搜索链接 No comment provided by engineer. @@ -7094,10 +7113,12 @@ chat item action Search videos + 搜索视频 No comment provided by engineer. Search voice messages + 搜索语音消息 No comment provided by engineer. @@ -8973,6 +8994,7 @@ To connect, please ask your contact to create another connection link and check Videos + 视频 No comment provided by engineer. @@ -10112,6 +10134,10 @@ pref value 过期 No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded 已转发 diff --git a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings index 3a577620a0..3da1eb8e9b 100644 --- a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings @@ -1,3 +1,15 @@ /* notification body */ -"New messages in %d chats" = "Nowe wiadomości w %d czatach"; +"%d new events" = "%d nowych wydarzeń"; + +/* notification body */ +"From %d chat(s)" = "Z %d czatu(ów)"; + +/* notification body */ +"From: %@" = "Od: %@"; + +/* notification */ +"New events" = "Nowe wydarzenia"; + +/* notification */ +"New messages" = "Nowe wiadomości"; 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 SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index ed96f44a15..403fb3820a 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -65,7 +65,7 @@ "No active profile" = "Kein aktives Profil"; /* No comment provided by engineer. */ -"Ok" = "OK"; +"Ok" = "Ok"; /* No comment provided by engineer. */ "Open the app to downgrade the database." = "Öffnen Sie die App, um die Datenbank herunterzustufen."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9265138c53..32d74395ba 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 */; }; @@ -178,8 +182,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.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 = ""; }; @@ -545,8 +553,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +716,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +803,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-IckAKQLBKZZ3c4EBa1qhzo.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */, ); path = Libraries; 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 */, @@ -2003,7 +2019,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2069,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2095,7 +2111,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2115,7 +2131,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2140,7 +2156,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2177,7 +2193,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2214,7 +2230,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2265,7 +2281,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2316,7 +2332,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2350,7 +2366,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 324; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 85c84a6f45..6bf46fb0dd 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -369,6 +369,15 @@ public struct UpMigration: Decodable, Equatable { // public var withDown: Bool } +public func downMigrationWarnings(_ downMigrations: [String]) -> [String] { + let warnings: [(String, String)] = [ + ("20260222_chat_relays", NSLocalizedString("If you joined or created channels, they will stop working permanently.", comment: "down migration warning")) + ] + return warnings.compactMap { (key, message) in + downMigrations.contains(key) ? message : nil + } +} + public enum MTRError: Decodable, Equatable { case noDown(dbMigrations: [String]) case different(appMigration: String, dbMigration: String) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b31a799e68..5f1d8ef6c2 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) @@ -794,6 +795,7 @@ public enum ChatErrorType: Decodable, Hashable { case connectionIncognitoChangeProhibited case connectionUserChangeProhibited case peerChatVRangeIncompatible + case relayTestError(message: String) case internalError(message: String) case exception(message: String) } @@ -801,6 +803,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 +828,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 +863,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 d95e5233c1..b3d144b446 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 { @@ -2343,6 +2347,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? @@ -2354,6 +2360,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { var chatTs: Date? public var preparedGroup: PreparedGroup? public var uiThemes: ThemeModeOverrides? + public var groupSummary: GroupSummary public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } @@ -2386,15 +2393,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, @@ -2402,6 +2414,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + groupSummary: GroupSummary(currentMembers: 0), membersRequireAttention: 0, chatTags: [], localAlias: "" @@ -2419,6 +2432,34 @@ public struct GroupRef: Decodable, Hashable { var localDisplayName: GroupName } +public enum GroupType: Codable, Hashable { + case channel + case unknown(type: String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "channel": self = .channel + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .channel: try container.encode("channel") + case let .unknown(type): try container.encode(type) + } + } +} + +public struct PublicGroupProfile: Codable, Hashable { + public var groupType: GroupType + public var groupLink: String + public var publicGroupId: String +} + public struct GroupProfile: Codable, NamedChat, Hashable { public init( displayName: String, @@ -2426,6 +2467,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { shortDescr: String? = nil, description: String? = nil, image: String? = nil, + publicGroup: PublicGroupProfile? = nil, groupPreferences: GroupPreferences? = nil, memberAdmission: GroupMemberAdmission? = nil ) { @@ -2434,6 +2476,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { self.shortDescr = shortDescr self.description = description self.image = image + self.publicGroup = publicGroup self.groupPreferences = groupPreferences self.memberAdmission = memberAdmission } @@ -2443,6 +2486,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var shortDescr: String? public var description: String? public var image: String? + public var publicGroup: PublicGroupProfile? public var groupPreferences: GroupPreferences? public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } @@ -2492,8 +2536,104 @@ public struct ContactShortLinkData: Codable, Hashable { public var business: Bool } +public struct GroupSummary: Decodable, Hashable { + public var currentMembers: Int64 + public var publicMemberCount: Int64? + + public init(currentMembers: Int64 = 0, publicMemberCount: Int64? = nil) { + self.currentMembers = currentMembers + self.publicMemberCount = publicMemberCount + } +} + +public struct PublicGroupData: Codable, Hashable { + public var publicMemberCount: Int64 +} + public struct GroupShortLinkData: Codable, Hashable { public var groupProfile: GroupProfile + public var publicGroupData: PublicGroupData? +} + +public enum RelayStatus: String, Decodable, Equatable, Hashable { + case rsNew = "new" + case rsInvited = "invited" + case rsAccepted = "accepted" + case rsActive = "active" +} + +public struct RelayProfile: Codable, Equatable, Hashable { + public var displayName: String + public var fullName: String + public var shortDescr: String? + public var image: String? +} + +public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable { + public var chatRelayId: Int64? + public var address: String + public var relayProfile: RelayProfile + 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 var displayName: String { + get { relayProfile.displayName } + set { relayProfile.displayName = newValue } + } + + 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.relayProfile = RelayProfile(displayName: name, fullName: "", shortDescr: nil, image: nil) + 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.relayProfile == r.relayProfile && 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 relayProfile + 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 { @@ -2524,6 +2664,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 } } @@ -2649,14 +2790,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 } @@ -2727,6 +2868,7 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { + case relay case observer case author case member @@ -2740,6 +2882,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") @@ -2751,12 +2894,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 } } @@ -3224,6 +3368,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 } } @@ -3444,6 +3590,7 @@ public enum CIDirection: Decodable, Hashable { case directRcv case groupSnd case groupRcv(groupMember: GroupMember) + case channelRcv case localSnd case localRcv @@ -3454,6 +3601,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 } @@ -3463,6 +3611,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 } } @@ -4054,6 +4203,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 @@ -4697,7 +4847,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/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index c70ca5edd8..f93b090517 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -402,6 +402,11 @@ extension UIImage { } } +// Max image height/width ratio for chat item display, taller images are cropped +public func heightRatio(_ size: CGSize) -> CGFloat { + size.width > 0 ? min(size.height / size.width, 2.33) : 1 +} + public func imageFromBase64(_ base64Encoded: String?) -> UIImage? { if let base64Encoded { if let img = imageCache.object(forKey: base64Encoded as NSString) { diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 33ad97d821..9e1fe7139c 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1754,7 +1754,7 @@ snd error text */ "Find chats faster" = "Najděte chaty rychleji"; /* server test error */ -"Fingerprint in server address does not match certificate." = "Je možné, že otisk certifikátu v adrese serveru je nesprávný"; +"Fingerprint in server address does not match certificate." = "Otisk certifikátu v adrese serveru neodpovídá."; /* No comment provided by engineer. */ "Fix" = "Opravit"; @@ -2390,7 +2390,7 @@ snd error text */ "no text" = "žádný text"; /* No comment provided by engineer. */ -"No user identifiers." = "Bez uživatelských identifikátorů"; +"No user identifiers." = "Bez uživatelských identifikátorů."; /* No comment provided by engineer. */ "Notifications" = "Oznámení"; @@ -2980,10 +2980,10 @@ chat item action */ "Sent messages will be deleted after set time." = "Odeslané zprávy se po uplynutí nastavené doby odstraní."; /* server test error */ -"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo"; +"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo."; /* server test error */ -"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo"; +"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo."; /* No comment provided by engineer. */ "Server test failed!" = "Test serveru se nezdařil!"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index f305aca473..e3979abc37 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "Alle Mitglieder"; +/* No comment provided by engineer. */ +"All messages" = "Alle Nachrichten"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Alle Nachrichten und Dateien werden **Ende-zu-Ende verschlüsselt** versendet - in Direkt-Nachrichten mit Post-Quantum-Security."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Audio- und Videoanrufe"; +/* No comment provided by engineer. */ +"Audio call" = "Audioanruf"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "Audioanruf (nicht E2E verschlüsselt)"; @@ -1730,6 +1736,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Nachricht des Mitglieds löschen?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Mitgliedsnachrichten löschen"; + +/* alert title */ +"Delete member messages?" = "Mitgliedsnachrichten löschen?"; + /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; @@ -2565,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Dateien und Medien sind nicht erlaubt!"; +/* No comment provided by engineer. */ +"Filter" = "Filter"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Nach ungelesenen und favorisierten Chats filtern."; @@ -2868,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; +/* No comment provided by engineer. */ +"Images" = "Bilder"; + /* No comment provided by engineer. */ "Immediately" = "Sofort"; @@ -3051,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Freunde einladen"; +/* No comment provided by engineer. */ +"Invite member" = "Mitglied einladen"; + /* No comment provided by engineer. */ "Invite members" = "Mitglieder einladen"; @@ -3204,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Verknüpfte Desktops"; +/* No comment provided by engineer. */ +"Links" = "Links"; + /* swipe action */ "List" = "Liste"; @@ -3297,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Mitglied ist gelöscht - Anfrage kann nicht angenommen werden"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden!"; + /* chat feature */ "Member reports" = "Mitglieder-Meldungen"; @@ -4384,6 +4411,9 @@ swipe action */ /* alert action */ "Remove" = "Entfernen"; +/* alert action */ +"Remove and delete messages" = "Mitglied entfernen und Nachrichten löschen"; + /* No comment provided by engineer. */ "Remove archive?" = "Archiv entfernen?"; @@ -4691,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "In der Suchleiste werden nun auch Einladungslinks angenommen."; +/* No comment provided by engineer. */ +"Search files" = "Dateien suchen"; + +/* No comment provided by engineer. */ +"Search images" = "Bilder suchen"; + +/* No comment provided by engineer. */ +"Search links" = "Links suchen"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Suchen oder SimpleX-Link einfügen"; +/* No comment provided by engineer. */ +"Search videos" = "Videos suchen"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Sprachnachrichten suchen"; + /* network option */ "sec" = "sek"; @@ -5899,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; +/* No comment provided by engineer. */ +"Videos" = "Videos"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videos und Dateien bis zu 1GB"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 9ac7628abb..a05bc9f4b6 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -257,7 +257,7 @@ "`a + b`" = "\\`a + b`"; /* email text */ -"

Hi!

\n

Connect to me via SimpleX Chat

" = "

¡Hola!

\n

Conecta conmigo a través de SimpleX Chat

"; +"

Hi!

\n

Connect to me via SimpleX Chat

" = "

¡Hola!

\n

Conecta conmigo a través de SimpleX Chat

"; /* No comment provided by engineer. */ "~strike~" = "\\~strike~"; @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "todos los miembros"; +/* No comment provided by engineer. */ +"All messages" = "Todos los mensajes"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Llamadas y videollamadas"; +/* No comment provided by engineer. */ +"Audio call" = "Llamada"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "llamada (sin cifrar)"; @@ -1730,6 +1736,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "¿Eliminar el mensaje de miembro?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Eliminar mensajes del miembro"; + +/* alert title */ +"Delete member messages?" = "¿Eliminar mensajes del miembro?"; + /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; @@ -2565,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "¡Archivos y multimedia no permitidos!"; +/* No comment provided by engineer. */ +"Filter" = "Filtro"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtra chats no leídos y favoritos."; @@ -2868,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "La imagen se recibirá cuando el contacto esté en línea, ¡por favor espera o revisa más tarde!"; +/* No comment provided by engineer. */ +"Images" = "Imágenes"; + /* No comment provided by engineer. */ "Immediately" = "Inmediatamente"; @@ -3051,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Invitar amigos"; +/* No comment provided by engineer. */ +"Invite member" = "Invitar miembro"; + /* No comment provided by engineer. */ "Invite members" = "Invitar miembros"; @@ -3204,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Ordenadores enlazados"; +/* No comment provided by engineer. */ +"Links" = "Enlaces"; + /* swipe action */ "List" = "Lista"; @@ -3297,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Miembro eliminado, no puede aceptar solicitudes"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Los mensajes del miembro serán eliminados. ¡No puede deshacerse!"; + /* chat feature */ "Member reports" = "Informes de miembros"; @@ -3939,7 +3966,7 @@ new chat action */ "Or securely share this file link" = "O comparte de forma segura este enlace al archivo"; /* No comment provided by engineer. */ -"Or show this code" = "O muestra el código QR"; +"Or show this code" = "O muestra este código"; /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; @@ -4384,6 +4411,9 @@ swipe action */ /* alert action */ "Remove" = "Eliminar"; +/* alert action */ +"Remove and delete messages" = "Eliminar miembro y sus mensajes"; + /* No comment provided by engineer. */ "Remove archive?" = "¿Eliminar archivo?"; @@ -4691,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "La barra de búsqueda acepta enlaces de invitación."; +/* No comment provided by engineer. */ +"Search files" = "Buscar archivos"; + +/* No comment provided by engineer. */ +"Search images" = "Buscar imágenes"; + +/* No comment provided by engineer. */ +"Search links" = "Buscar enlaces"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Buscar o pegar enlace SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Buscar vídeos"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Buscar mensajes de voz"; + /* network option */ "sec" = "seg"; @@ -5687,7 +5732,7 @@ report reason */ "Unmute" = "Activar audio"; /* No comment provided by engineer. */ -"unprotected" = "con IP desprotegida"; +"unprotected" = "desprotegida"; /* swipe action */ "Unread" = "No leído"; @@ -5899,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "El vídeo se recibirá cuando el contacto esté en línea, por favor espera o revisa más tarde."; +/* No comment provided by engineer. */ +"Videos" = "Vídeos"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Vídeos y archivos de hasta 1Gb"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index fc2f796bfd..56277f4fd3 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -5,7 +5,7 @@ "_italic_" = "\\_dőlt_"; /* No comment provided by engineer. */ -"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tag).\n- gyorsabb és stabilabb."; +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtárszolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb."; /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más!"; @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "összes tag"; +/* No comment provided by engineer. */ +"All messages" = "Összes üzenet"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Hang- és videóhívások"; +/* No comment provided by engineer. */ +"Audio call" = "Hanghívás"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "hanghívás (végpontok között NEM titkosított)"; @@ -1177,7 +1183,7 @@ set passcode view */ "Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása a partnerekével."; /* No comment provided by engineer. */ -"complete" = "befejezett"; +"complete" = "kész"; /* No comment provided by engineer. */ "Completed" = "Elkészült"; @@ -1730,6 +1736,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Törli a tag üzenetét?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Tag üzeneteinek törlése"; + +/* alert title */ +"Delete member messages?" = "Törli a tag üzeneteit?"; + /* No comment provided by engineer. */ "Delete message?" = "Törli az üzenetet?"; @@ -2565,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "A fájlok és a médiatartalmak küldése le van tiltva!"; +/* No comment provided by engineer. */ +"Filter" = "Szűrő"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Olvasatlan és kedvenc csevegésekre való szűrés."; @@ -2868,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; +/* No comment provided by engineer. */ +"Images" = "Képek"; + /* No comment provided by engineer. */ "Immediately" = "Azonnal"; @@ -3051,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Barátok meghívása"; +/* No comment provided by engineer. */ +"Invite member" = "Tag meghívása"; + /* No comment provided by engineer. */ "Invite members" = "Tagok meghívása"; @@ -3204,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Társított számítógépek"; +/* No comment provided by engineer. */ +"Links" = "Hivatkozások"; + /* swipe action */ "List" = "Lista"; @@ -3297,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "A tag törölve lett – nem lehet elfogadni a kérést"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza!"; + /* chat feature */ "Member reports" = "Tagok jelentései"; @@ -3463,7 +3490,7 @@ snd error text */ "Migrating database archive…" = "Adatbázis-archívum átköltöztetése…"; /* No comment provided by engineer. */ -"Migration complete" = "Átköltöztetés befejezve"; +"Migration complete" = "Átköltöztetés kész"; /* No comment provided by engineer. */ "Migration error:" = "Átköltöztetési hiba:"; @@ -3472,7 +3499,7 @@ snd error text */ "Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; /* No comment provided by engineer. */ -"Migration is completed" = "Az átköltöztetés befejeződött"; +"Migration is completed" = "Az átköltöztetés elkészült"; /* No comment provided by engineer. */ "Migrations:" = "Átköltöztetések:"; @@ -4086,7 +4113,7 @@ new chat action */ "Please wait for group moderators to review your request to join the group." = "Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérését."; /* token info */ -"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása befejeződik."; +"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása elkészül."; /* token info */ "Please wait for token to be registered." = "Várjon a token regisztrálására."; @@ -4384,6 +4411,9 @@ swipe action */ /* alert action */ "Remove" = "Eltávolítás"; +/* alert action */ +"Remove and delete messages" = "Eltávolítás és az üzeneteinek törlése"; + /* No comment provided by engineer. */ "Remove archive?" = "Eltávolítja az archívumot?"; @@ -4691,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "A keresősáv elfogadja a meghívási hivatkozásokat."; +/* No comment provided by engineer. */ +"Search files" = "Fájlok keresése"; + +/* No comment provided by engineer. */ +"Search images" = "Képek keresése"; + +/* No comment provided by engineer. */ +"Search links" = "Hivatkozások keresése"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Keresés vagy SimpleX-hivatkozás beillesztése"; +/* No comment provided by engineer. */ +"Search videos" = "Videók keresése"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Hangüzenetek keresése"; + /* network option */ "sec" = "mp"; @@ -5899,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; +/* No comment provided by engineer. */ +"Videos" = "Videók"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videók és fájlok legfeljebb 1GB méretig"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 9e2a27e618..3955f267ce 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "tutti i membri"; +/* No comment provided by engineer. */ +"All messages" = "Tutti i messaggi"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tutti i messaggi e i file vengono inviati **crittografati end-to-end**, con sicurezza resistenti alla quantistica nei messaggi diretti."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Chiamate audio e video"; +/* No comment provided by engineer. */ +"Audio call" = "Chiamata audio"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "chiamata audio (non crittografata e2e)"; @@ -1730,6 +1736,12 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Eliminare il messaggio del membro?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Elimina i messaggi del membro"; + +/* alert title */ +"Delete member messages?" = "Eliminare i messaggi del membro?"; + /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; @@ -2565,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "File e contenuti multimediali vietati!"; +/* No comment provided by engineer. */ +"Filter" = "Filtro"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtra le chat non lette e preferite."; @@ -2868,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "L'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi!"; +/* No comment provided by engineer. */ +"Images" = "Immagini"; + /* No comment provided by engineer. */ "Immediately" = "Immediatamente"; @@ -3051,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Invita amici"; +/* No comment provided by engineer. */ +"Invite member" = "Invita membro"; + /* No comment provided by engineer. */ "Invite members" = "Invita membri"; @@ -3204,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Desktop collegati"; +/* No comment provided by engineer. */ +"Links" = "Link"; + /* swipe action */ "List" = "Elenco"; @@ -3297,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Il membro è eliminato - impossibile accettare la richiesta"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "I messaggi del membro verranno eliminati. Non è reversibile!"; + /* chat feature */ "Member reports" = "Segnalazioni dei membri"; @@ -4384,6 +4411,9 @@ swipe action */ /* alert action */ "Remove" = "Rimuovi"; +/* alert action */ +"Remove and delete messages" = "Rimuovi ed elimina i messaggi"; + /* No comment provided by engineer. */ "Remove archive?" = "Rimuovere l'archivio?"; @@ -4691,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "La barra di ricerca accetta i link di invito."; +/* No comment provided by engineer. */ +"Search files" = "Cerca file"; + +/* No comment provided by engineer. */ +"Search images" = "Cerca immagini"; + +/* No comment provided by engineer. */ +"Search links" = "Cerca link"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Cerca o incolla un link SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Cerca video"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Cerca messaggi vocali"; + /* network option */ "sec" = "sec"; @@ -5899,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi!"; +/* No comment provided by engineer. */ +"Videos" = "Video"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Video e file fino a 1 GB"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 9ef572364f..ed1f8850d8 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -257,7 +257,7 @@ "`a + b`" = "\\`a + b`"; /* email text */ -"

Hi!

\n

Connect to me via SimpleX Chat

" = "

Cześć!

\n

Połącz się ze mną poprzez SimpleX Chat.

"; +"

Hi!

\n

Connect to me via SimpleX Chat

" = "

Cześć!

\n

Połącz się ze mną poprzez SimpleX Chat

"; /* No comment provided by engineer. */ "~strike~" = "\\~strajk~"; @@ -371,12 +371,21 @@ swipe action */ /* alert title */ "Accept member" = "Zaakceptuj członka"; +/* rcv group event chat item */ +"accepted %@" = "zaakceptowano %@"; + /* call status */ "accepted call" = "zaakceptowane połączenie"; /* No comment provided by engineer. */ "Accepted conditions" = "Zaakceptowano warunki"; +/* chat list item title */ +"accepted invitation" = "zaproszenie zaakceptowane"; + +/* rcv group event chat item */ +"accepted you" = "przyjął cię"; + /* No comment provided by engineer. */ "Acknowledged" = "Potwierdzono"; @@ -476,6 +485,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "uzgadnianie szyfrowania…"; +/* member criteria value */ +"all" = "wszystkie"; + /* No comment provided by engineer. */ "All" = "Wszystko"; @@ -500,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "wszyscy członkowie"; +/* No comment provided by engineer. */ +"All messages" = "Wszystkie wiadomości"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich."; @@ -704,6 +719,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "Zarchiwizowane kontakty"; +/* No comment provided by engineer. */ +"archived report" = "zarchiwizowany raport"; + /* No comment provided by engineer. */ "Archiving database" = "Archiwizowanie bazy danych"; @@ -719,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Połączenia audio i wideo"; +/* No comment provided by engineer. */ +"Audio call" = "Połączenie audio"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "połączenie audio (nie szyfrowane e2e)"; @@ -803,6 +824,12 @@ swipe action */ /* No comment provided by engineer. */ "Better user experience" = "Lepszy interfejs użytkownika"; +/* No comment provided by engineer. */ +"Bio" = "Bio"; + +/* alert title */ +"Bio too large" = "Bio jest za długie"; + /* No comment provided by engineer. */ "Black" = "Czarny"; @@ -876,12 +903,18 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Business chats" = "Czaty biznesowe"; +/* No comment provided by engineer. */ +"Business connection" = "Kontakty biznesowe"; + /* No comment provided by engineer. */ "Businesses" = "Firmy"; /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Korzystając z SimpleX Chat, zgadzasz się:\n- wysyłać tylko legalne treści w grupach publicznych.\n- szanować innych użytkowników – nie spamować."; + /* No comment provided by engineer. */ "call" = "zadzwoń"; @@ -912,6 +945,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "Nie można zadzwonić do członka"; +/* alert title */ +"Can't change profile" = "Nie można zmienić profilu"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Nie można zaprosić kontaktu!"; @@ -921,6 +957,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Nie można wysłać wiadomości do członka"; +/* No comment provided by engineer. */ +"can't send messages" = "nie można wysłać wiadomości"; + /* alert action alert button new chat action */ @@ -950,6 +989,9 @@ new chat action */ /* No comment provided by engineer. */ "Change" = "Zmień"; +/* alert title */ +"Change automatic message deletion?" = "Zmienić automatyczne usuwanie wiadomości?"; + /* authentication reason */ "Change chat profiles" = "Zmień profil czatu"; @@ -1056,9 +1098,21 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć!"; +/* chat toolbar */ +"Chat with admins" = "Czatuj z administratorami"; + +/* No comment provided by engineer. */ +"Chat with member" = "Czatuj z członkiem"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "Porozmawiaj z członkami, zanim dołączą."; + /* No comment provided by engineer. */ "Chats" = "Czaty"; +/* No comment provided by engineer. */ +"Chats with members" = "Czaty z członkami"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Sprawdzaj wiadomości co 20 min."; @@ -1098,6 +1152,12 @@ set passcode view */ /* No comment provided by engineer. */ "Clear conversation?" = "Wyczyścić rozmowę?"; +/* No comment provided by engineer. */ +"Clear group?" = "Wyczyścić grupę?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Wyczyścić lub usunąć grupę?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Wyczyścić prywatne notatki?"; @@ -1113,6 +1173,9 @@ set passcode view */ /* No comment provided by engineer. */ "colored" = "kolorowy"; +/* report reason */ +"Community guidelines violation" = "Naruszenie zasad społeczności"; + /* server test step */ "Compare file" = "Porównaj plik"; @@ -1137,9 +1200,21 @@ set passcode view */ /* alert button */ "Conditions of use" = "Warunki użytkowania"; +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Warunki zostaną zaakceptowane dla operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Warunki zostaną zaakceptowane w dniu: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Warunki zostaną automatycznie zaakceptowane dla aktywnych operatorów w dniu: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Skonfiguruj operatorów serwerów"; + /* No comment provided by engineer. */ "Confirm" = "Potwierdź"; @@ -1170,12 +1245,18 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "Potwierdź wgranie"; +/* token status text */ +"Confirmed" = "Potwierdzony"; + /* server test step */ "Connect" = "Połącz"; /* No comment provided by engineer. */ "Connect automatically" = "Łącz automatycznie"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "Połącz się szybciej! 🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "Połącz do komputera"; @@ -1260,6 +1341,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connection and servers status." = "Stan połączenia i serwerów."; +/* No comment provided by engineer. */ +"Connection blocked" = "Połączenie zablokowane"; + /* alert title */ "Connection error" = "Błąd połączenia"; @@ -1269,12 +1353,24 @@ set passcode view */ /* chat list item title (it should not be shown */ "connection established" = "połączenie ustanowione"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Połączenie zostało zablokowane przez operatora serwera:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Połączenie nie jest gotowe."; + /* No comment provided by engineer. */ "Connection notifications" = "Powiadomienia o połączeniu"; /* No comment provided by engineer. */ "Connection request sent!" = "Prośba o połączenie wysłana!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Połączenie wymaga renegocjacji szyfrowania."; + +/* No comment provided by engineer. */ +"Connection security" = "Bezpieczeństwo połączenia"; + /* No comment provided by engineer. */ "Connection terminated" = "Połączenie zakończone"; @@ -1299,9 +1395,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt już istnieje"; +/* No comment provided by engineer. */ +"contact deleted" = "kontakt usunięty"; + /* No comment provided by engineer. */ "Contact deleted!" = "Kontakt usunięty!"; +/* No comment provided by engineer. */ +"contact disabled" = "kontakt wyłączony"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt posiada szyfrowanie e2e"; @@ -1320,9 +1422,18 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Nazwa kontaktu"; +/* No comment provided by engineer. */ +"contact not ready" = "kontakt nie gotowy"; + /* No comment provided by engineer. */ "Contact preferences" = "Preferencje kontaktu"; +/* No comment provided by engineer. */ +"Contact requests from groups" = "Prośby o kontakt od grup"; + +/* No comment provided by engineer. */ +"contact should accept…" = "kontakt powinien zaakceptować…"; + /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "Kontakt zostanie usunięty – nie można tego cofnąć!"; @@ -1332,6 +1443,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Kontakty mogą oznaczać wiadomości do usunięcia; będziesz mógł je zobaczyć."; +/* blocking reason */ +"Content violates conditions of use" = "Treść narusza warunki użytkowania"; + /* No comment provided by engineer. */ "Continue" = "Kontynuuj"; @@ -1356,6 +1470,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create" = "Utwórz"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Utwórz jednorazowy link"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Utwórz grupę używając losowego profilu."; @@ -1371,6 +1488,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create link" = "Utwórz link"; +/* No comment provided by engineer. */ +"Create list" = "Utwórz listę"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Utwórz nowy profil w [aplikacji desktopowej](https://simplex.chat/downloads/). 💻"; @@ -1383,6 +1503,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create SimpleX address" = "Utwórz adres SimpleX"; +/* No comment provided by engineer. */ +"Create your address" = "Utwórz swój adres"; + /* No comment provided by engineer. */ "Create your profile" = "Utwórz swój profil"; @@ -1404,6 +1527,9 @@ set passcode view */ /* No comment provided by engineer. */ "creator" = "twórca"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Nie można załadować tekstu dotyczącego aktualnych warunków. Możesz zapoznać się z warunkami, klikając ten link:"; + /* No comment provided by engineer. */ "Current Passcode" = "Aktualny Pin"; @@ -1422,6 +1548,9 @@ set passcode view */ /* No comment provided by engineer. */ "Custom time" = "Niestandardowy czas"; +/* No comment provided by engineer. */ +"Customizable message shape." = "Konfigurowalny kształt wiadomości."; + /* No comment provided by engineer. */ "Customize theme" = "Dostosuj motyw"; @@ -1538,12 +1667,24 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Usuń i powiadom kontakt"; +/* No comment provided by engineer. */ +"Delete chat" = "Usuń czat"; + +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Usuń wiadomości czatu ze swojego urządzenia."; + /* No comment provided by engineer. */ "Delete chat profile" = "Usuń profil czatu"; /* No comment provided by engineer. */ "Delete chat profile?" = "Usunąć profil czatu?"; +/* alert title */ +"Delete chat with member?" = "Usunąć czat z członkiem?"; + +/* No comment provided by engineer. */ +"Delete chat?" = "Usunąć czat?"; + /* No comment provided by engineer. */ "Delete connection" = "Usuń połączenie"; @@ -1589,9 +1730,18 @@ swipe action */ /* No comment provided by engineer. */ "Delete link?" = "Usunąć link?"; +/* alert title */ +"Delete list?" = "Usunąć listę?"; + /* No comment provided by engineer. */ "Delete member message?" = "Usunąć wiadomość członka?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Usuń wiadomości członków"; + +/* alert title */ +"Delete member messages?" = "Usunąć wiadomości członków?"; + /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; @@ -1608,6 +1758,9 @@ alert button */ /* No comment provided by engineer. */ "Delete old database?" = "Usunąć starą bazę danych?"; +/* No comment provided by engineer. */ +"Delete or moderate up to 200 messages." = "Usuń lub moderuj do 200 wiadomości."; + /* No comment provided by engineer. */ "Delete pending connection?" = "Usunąć oczekujące połączenie?"; @@ -1617,6 +1770,9 @@ alert button */ /* server test step */ "Delete queue" = "Usuń kolejkę"; +/* No comment provided by engineer. */ +"Delete report" = "Usuń raport"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Usuń do 20 wiadomości na raz."; @@ -1647,6 +1803,9 @@ alert button */ /* No comment provided by engineer. */ "Deletion errors" = "Błędy usuwania"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "Dostarczane nawet wtedy, gdy Apple je wycofa."; + /* No comment provided by engineer. */ "Delivery" = "Dostarczenie"; @@ -1656,9 +1815,15 @@ alert button */ /* No comment provided by engineer. */ "Delivery receipts!" = "Potwierdzenia dostawy!"; +/* No comment provided by engineer. */ +"Deprecated options" = "Opcje wycofane"; + /* No comment provided by engineer. */ "Description" = "Opis"; +/* alert title */ +"Description too large" = "Opis jest zbyt długi"; + /* No comment provided by engineer. */ "Desktop address" = "Adres komputera"; @@ -1713,12 +1878,21 @@ alert button */ /* chat feature */ "Direct messages" = "Bezpośrednie wiadomości"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "W tym czacie zabronione jest wysyłanie bezpośrednich wiadomości między członkami."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Bezpośrednie wiadomości między członkami są zabronione w tej grupie."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Wyłącz (zachowaj nadpisania)"; +/* alert title */ +"Disable automatic message deletion?" = "Wyłączyć automatyczne usuwanie wiadomości?"; + +/* alert button */ +"Disable delete messages" = "Wyłącz usuwanie wiadomości"; + /* No comment provided by engineer. */ "Disable for all" = "Wyłącz dla wszystkich"; @@ -1779,15 +1953,24 @@ alert button */ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NIE używaj SimpleX do połączeń alarmowych."; +/* No comment provided by engineer. */ +"Documents:" = "Dokumenty:"; + /* No comment provided by engineer. */ "Don't create address" = "Nie twórz adresu"; /* No comment provided by engineer. */ "Don't enable" = "Nie włączaj"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Nie przegap ważnych wiadomości."; + /* alert action */ "Don't show again" = "Nie pokazuj ponownie"; +/* No comment provided by engineer. */ +"Done" = "Gotowe"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Obniż wersję i otwórz czat"; @@ -1834,12 +2017,18 @@ chat item action */ /* No comment provided by engineer. */ "e2e encrypted" = "zaszyfrowany e2e"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Powiadomienia szyfrowane E2E."; + /* chat item action */ "Edit" = "Edytuj"; /* No comment provided by engineer. */ "Edit group profile" = "Edytuj profil grupy"; +/* No comment provided by engineer. */ +"Empty message!" = "Pusta wiadomość!"; + /* No comment provided by engineer. */ "Enable" = "Włącz"; @@ -1852,6 +2041,12 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Włącz dostęp do kamery"; +/* No comment provided by engineer. */ +"Enable disappearing messages by default." = "Włącz domyślnie znikające wiadomości."; + +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Włącz opcję Flux w ustawieniach sieci i serwerów, aby zapewnić lepszą prywatność metadanych."; + /* No comment provided by engineer. */ "Enable for all" = "Włącz dla wszystkich"; @@ -1963,6 +2158,9 @@ chat item action */ /* chat item text */ "encryption re-negotiation required for %@" = "renegocjacja szyfrowania wymagana dla %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Trwa renegocjacja szyfrowania."; + /* No comment provided by engineer. */ "ended" = "zakończona"; @@ -2011,15 +2209,30 @@ chat item action */ /* No comment provided by engineer. */ "Error aborting address change" = "Błąd przerwania zmiany adresu"; +/* alert title */ +"Error accepting conditions" = "Błąd podczas akceptacji warunków"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Błąd przyjmowania prośby o kontakt"; +/* alert title */ +"Error accepting member" = "Błąd podczas akceptacji członka"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Błąd dodawania członka(ów)"; +/* alert title */ +"Error adding server" = "Błąd podczas dodawania serwera"; + +/* No comment provided by engineer. */ +"Error adding short link" = "Błąd dodawania krótkiego linku"; + /* No comment provided by engineer. */ "Error changing address" = "Błąd zmiany adresu"; +/* alert title */ +"Error changing chat profile" = "Błąd zmiany profilu czatu"; + /* No comment provided by engineer. */ "Error changing connection profile" = "Błąd zmiany połączenia profilu"; @@ -2032,9 +2245,15 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "Błąd zmiany na incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Błąd sprawdzania statusu tokenu"; + /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Błąd połączenia z serwerem używanym do odbierania wiadomości z tego połączenia: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Błąd tworzenia adresu"; @@ -2044,6 +2263,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating group link" = "Błąd tworzenia linku grupy"; +/* alert title */ +"Error creating list" = "Błąd tworzenia listy"; + /* No comment provided by engineer. */ "Error creating member contact" = "Błąd tworzenia kontaktu członka"; @@ -2053,9 +2275,15 @@ chat item action */ /* No comment provided by engineer. */ "Error creating profile!" = "Błąd tworzenia profilu!"; +/* No comment provided by engineer. */ +"Error creating report" = "Błąd tworzenia raportu"; + /* No comment provided by engineer. */ "Error decrypting file" = "Błąd odszyfrowania pliku"; +/* alert title */ +"Error deleting chat" = "Błąd usuwania czatu"; + /* alert title */ "Error deleting chat database" = "Błąd usuwania bazy danych czatu"; @@ -2101,12 +2329,18 @@ chat item action */ /* No comment provided by engineer. */ "Error joining group" = "Błąd dołączenia do grupy"; +/* alert title */ +"Error loading servers" = "Błąd ładowania serwerów"; + /* No comment provided by engineer. */ "Error migrating settings" = "Błąd migracji ustawień"; /* No comment provided by engineer. */ "Error opening chat" = "Błąd otwierania czatu"; +/* No comment provided by engineer. */ +"Error opening group" = "Błąd otwierania grupy"; + /* alert title */ "Error receiving file" = "Błąd odbioru pliku"; @@ -2116,12 +2350,24 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Błąd ponownego łączenia serwerów"; +/* alert title */ +"Error registering for notifications" = "Błąd rejestracji powiadomień"; + +/* alert title */ +"Error rejecting contact request" = "Błąd odrzucenia prośby o kontakt"; + /* alert title */ "Error removing member" = "Błąd usuwania członka"; +/* alert title */ +"Error reordering lists" = "Błąd ponownego porządkowania list"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Błąd resetowania statystyk"; +/* alert title */ +"Error saving chat list" = "Błąd zapisywania listy czatów"; + /* No comment provided by engineer. */ "Error saving group profile" = "Błąd zapisu profilu grupy"; @@ -2134,6 +2380,9 @@ chat item action */ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Błąd zapisu hasła do pęku kluczy"; +/* alert title */ +"Error saving servers" = "Błąd zapisywania serwerów"; + /* when migrating */ "Error saving settings" = "Błąd zapisywania ustawień"; @@ -2152,6 +2401,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "Błąd wysyłania wiadomości"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "Błąd ustawiania automatycznego akceptowania"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Błąd ustawiania potwierdzeń dostawy!"; @@ -2170,12 +2422,18 @@ chat item action */ /* No comment provided by engineer. */ "Error synchronizing connection" = "Błąd synchronizacji połączenia"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Błąd testowania połączenia z serwerem"; + /* No comment provided by engineer. */ "Error updating group link" = "Błąd aktualizacji linku grupy"; /* No comment provided by engineer. */ "Error updating message" = "Błąd aktualizacji wiadomości"; +/* alert title */ +"Error updating server" = "Błąd aktualizacji serwera"; + /* No comment provided by engineer. */ "Error updating settings" = "Błąd aktualizacji ustawień"; @@ -2196,6 +2454,9 @@ file error text snd error text */ "Error: %@" = "Błąd: %@"; +/* server test error */ +"Error: %@." = "Błąd: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Błąd: brak pliku bazy danych"; @@ -2205,6 +2466,9 @@ snd error text */ /* No comment provided by engineer. */ "Errors" = "Błędy"; +/* servers error */ +"Errors in servers configuration." = "Błędy w konfiguracji serwerów."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; @@ -2217,6 +2481,9 @@ snd error text */ /* No comment provided by engineer. */ "expired" = "wygasły"; +/* token status text */ +"Expired" = "Wygasło"; + /* No comment provided by engineer. */ "Export database" = "Eksportuj bazę danych"; @@ -2241,18 +2508,30 @@ snd error text */ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Szybko i bez czekania aż nadawca będzie online!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Szybsze usuwanie grup."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Szybsze dołączenie i bardziej niezawodne wiadomości."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Szybsze wysyłanie wiadomości."; + /* swipe action */ "Favorite" = "Ulubione"; +/* No comment provided by engineer. */ +"Favorites" = "Ulubione"; + /* file error alert title */ "File error" = "Błąd pliku"; /* alert message */ "File errors:\n%@" = "Błędy pliku:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Plik jest zablokowany przez operatora serwera:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Nie odnaleziono pliku - najprawdopodobniej plik został usunięty lub anulowany."; @@ -2286,6 +2565,9 @@ snd error text */ /* chat feature */ "Files and media" = "Pliki i media"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "W tym czacie nie wolno przesyłać plików ani multimediów."; + /* No comment provided by engineer. */ "Files and media are prohibited." = "Pliki i media są zabronione w tej grupie."; @@ -2295,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Pliki i media zabronione!"; +/* No comment provided by engineer. */ +"Filter" = "Filtr"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtruj nieprzeczytane i ulubione czaty."; @@ -2310,8 +2595,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Szybciej znajduj czaty"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Odcisk palca w adresie serwera docelowego nie zgadza się z certyfikatem: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Odcisk palca w adresie serwera przekazującego nie zgadza się z certyfikatem: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Odcisk palca w adresie serwera nie zgadza się z certyfikatem: %@."; + /* server test error */ -"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy"; +"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy."; /* No comment provided by engineer. */ "Fix" = "Napraw"; @@ -2331,9 +2625,27 @@ snd error text */ /* No comment provided by engineer. */ "Fix not supported by group member" = "Naprawa nie jest obsługiwana przez członka grupy"; +/* No comment provided by engineer. */ +"For all moderators" = "Dla wszystkich moderatorów"; + +/* servers error */ +"For chat profile %@:" = "Dla profilu czatu %@:"; + /* No comment provided by engineer. */ "For console" = "Dla konsoli"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Na przykład, jeśli Twój kontakt odbiera wiadomości za pośrednictwem serwera SimpleX Chat, Twoja aplikacja będzie je dostarczać za pośrednictwem serwera Flux."; + +/* No comment provided by engineer. */ +"For me" = "Dla mnie"; + +/* No comment provided by engineer. */ +"For private routing" = "Dla prywatnego routingu"; + +/* No comment provided by engineer. */ +"For social media" = "Dla mediów społecznościowych"; + /* chat item action */ "Forward" = "Przekaż dalej"; @@ -2349,6 +2661,9 @@ snd error text */ /* alert message */ "Forward messages without files?" = "Przekazać wiadomości bez plików?"; +/* No comment provided by engineer. */ +"Forward up to 20 messages at once." = "Przekaż jednocześnie do 20 wiadomości."; + /* No comment provided by engineer. */ "forwarded" = "przekazane dalej"; @@ -2397,6 +2712,9 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Jeszcze mniejsze zużycie baterii"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Otrzymuj powiadomienia, gdy ktoś wspomni o Tobie."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF-y i naklejki"; @@ -2406,6 +2724,9 @@ snd error text */ /* message preview */ "Good morning!" = "Dzień dobry!"; +/* shown on group welcome message */ +"group" = "grupa"; + /* No comment provided by engineer. */ "Group" = "Grupa"; @@ -2436,6 +2757,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Zaproszenie do grupy jest już nieważne, zostało usunięte przez nadawcę."; +/* No comment provided by engineer. */ +"group is deleted" = "grupa została usunięta"; + /* No comment provided by engineer. */ "Group link" = "Link do grupy"; @@ -2460,6 +2784,9 @@ snd error text */ /* snd group event chat item */ "group profile updated" = "zaktualizowano profil grupy"; +/* alert message */ +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "Profil grupy został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do członków grupy."; + /* No comment provided by engineer. */ "Group welcome message" = "Wiadomość powitalna grupy"; @@ -2469,9 +2796,15 @@ snd error text */ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Grupa zostanie usunięta dla Ciebie - nie można tego cofnąć!"; +/* No comment provided by engineer. */ +"Groups" = "Grupy"; + /* No comment provided by engineer. */ "Help" = "Pomoc"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Pomóż administratorom moderować ich grupy."; + /* No comment provided by engineer. */ "Hidden" = "Ukryte"; @@ -2502,6 +2835,15 @@ snd error text */ /* time unit */ "hours" = "godziny"; +/* No comment provided by engineer. */ +"How it affects privacy" = "Jak to wpływa na prywatność"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Jak to pomaga chronić prywatność"; + +/* alert button */ +"How it works" = "Jak to działa"; + /* No comment provided by engineer. */ "How SimpleX works" = "Jak działa SimpleX"; @@ -2541,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "Obraz zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później!"; +/* No comment provided by engineer. */ +"Images" = "Zdjęcia"; + /* No comment provided by engineer. */ "Immediately" = "Natychmiast"; @@ -2565,6 +2910,9 @@ snd error text */ /* No comment provided by engineer. */ "Importing archive" = "Importowanie archiwum"; +/* No comment provided by engineer. */ +"Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" = "Ulepszona dostawa, mniejsze zużycie ruchu.\nWkrótce pojawią się kolejne ulepszenia!"; + /* No comment provided by engineer. */ "Improved message delivery" = "Ulepszona dostawa wiadomości"; @@ -2586,6 +2934,12 @@ snd error text */ /* No comment provided by engineer. */ "inactive" = "nieaktywny"; +/* report reason */ +"Inappropriate content" = "Nieodpowiednia treść"; + +/* report reason */ +"Inappropriate profile" = "Nieodpowiedni profil"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2652,6 +3006,21 @@ snd error text */ /* No comment provided by engineer. */ "Interface colors" = "Kolory interfejsu"; +/* token status text */ +"Invalid" = "Nieprawidłowy"; + +/* token status text */ +"Invalid (bad token)" = "Nieprawidłowy (zły token)"; + +/* token status text */ +"Invalid (expired)" = "Nieważny (wygasły)"; + +/* token status text */ +"Invalid (unregistered)" = "Nieprawidłowy (niezarejestrowany)"; + +/* token status text */ +"Invalid (wrong topic)" = "Nieprawidłowy (niewłaściwy temat)"; + /* invalid chat data */ "invalid chat" = "nieprawidłowy czat"; @@ -2700,9 +3069,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Zaproś znajomych"; +/* No comment provided by engineer. */ +"Invite member" = "Zaproś członka"; + /* No comment provided by engineer. */ "Invite members" = "Zaproś członków"; +/* No comment provided by engineer. */ +"Invite to chat" = "Zaproś do czatu"; + /* No comment provided by engineer. */ "Invite to group" = "Zaproś do grupy"; @@ -2793,6 +3168,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "Zachować nieużyte zaproszenie?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "Utrzymuj czystość swoich czatów"; + /* No comment provided by engineer. */ "Keep your connections" = "Zachowaj swoje połączenia"; @@ -2811,6 +3189,12 @@ snd error text */ /* swipe action */ "Leave" = "Opuść"; +/* No comment provided by engineer. */ +"Leave chat" = "Opuść czat"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Opuścić czat?"; + /* No comment provided by engineer. */ "Leave group" = "Opuść grupę"; @@ -2820,6 +3204,9 @@ snd error text */ /* rcv group event chat item */ "left" = "opuścił"; +/* No comment provided by engineer. */ +"Less traffic on mobile networks." = "Mniejszy ruch w sieciach komórkowych."; + /* email subject */ "Let's talk in SimpleX Chat" = "Porozmawiajmy w SimpleX Chat"; @@ -2838,6 +3225,18 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Połączone komputery"; +/* No comment provided by engineer. */ +"Links" = "Linki"; + +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Nazwa listy i emoji powinny być różne dla wszystkich list."; + +/* No comment provided by engineer. */ +"List name..." = "Nazwa listy..."; + /* No comment provided by engineer. */ "LIVE" = "NA ŻYWO"; @@ -2847,6 +3246,9 @@ snd error text */ /* No comment provided by engineer. */ "Live messages" = "Wiadomości na żywo"; +/* in progress text */ +"Loading profile…" = "Ładowanie profilu…"; + /* No comment provided by engineer. */ "Local name" = "Nazwa lokalna"; @@ -2898,30 +3300,60 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Członek"; +/* past/unknown group member */ +"Member %@" = "Członek %@"; + /* profile update event chat item */ "member %@ changed to %@" = "członek %1$@ zmieniony na %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "Przyjmowanie członków"; + /* rcv group event chat item */ "member connected" = "połączony"; +/* No comment provided by engineer. */ +"member has old version" = "członek posiada starą wersję"; + /* item status text */ "Member inactive" = "Członek nieaktywny"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "Członek został usunięty – nie można zaakceptować prośby"; + +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Wiadomości członków zostaną usunięte – nie można tego cofnąć!"; + +/* chat feature */ +"Member reports" = "Raporty członków"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Rola członka zostanie zmieniona na \"%@\". Wszyscy członkowie czatu zostaną o tym poinformowani."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Rola członka grupy zostanie zmieniona na \"%@\". Wszyscy członkowie grupy zostaną powiadomieni."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Rola członka zostanie zmieniona na \"%@\". Członek otrzyma nowe zaproszenie."; +/* alert message */ +"Member will be removed from chat - this cannot be undone!" = "Członek zostanie usunięty z czatu – nie można tego cofnąć!"; + /* alert message */ "Member will be removed from group - this cannot be undone!" = "Członek zostanie usunięty z grupy - nie można tego cofnąć!"; +/* alert message */ +"Member will join the group, accept member?" = "Członek dołączy do grupy, zaakceptować członka?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "Członkowie grupy mogą dodawać reakcje wiadomości."; /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Członkowie mogą zgłaszać wiadomości moderatorom."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Członkowie grupy mogą wysyłać bezpośrednie wiadomości."; @@ -2937,6 +3369,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can send voice messages." = "Członkowie grupy mogą wysyłać wiadomości głosowe."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Wspomnij członków 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu"; @@ -2958,6 +3393,9 @@ snd error text */ /* item status text */ "Message forwarded" = "Wiadomość przekazana"; +/* No comment provided by engineer. */ +"Message instantly once you tap Connect." = "Wysyłaj wiadomości natychmiast po dotknięciu przycisku „Połącz”."; + /* item status description */ "Message may be delivered later if member becomes active." = "Wiadomość może zostać dostarczona później jeśli członek stanie się aktywny."; @@ -3006,9 +3444,15 @@ snd error text */ /* No comment provided by engineer. */ "Messages & files" = "Wiadomości i pliki"; +/* No comment provided by engineer. */ +"Messages are protected by **end-to-end encryption**." = "Wiadomości są chronione przez **szyfrowanie typu end-to-end**."; + /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Wiadomości od %@ zostaną pokazane!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Wiadomości na tym czacie nigdy nie zostaną usunięte."; + /* No comment provided by engineer. */ "Messages received" = "Otrzymane wiadomości"; @@ -3081,15 +3525,24 @@ snd error text */ /* marked deleted chat item preview text */ "moderated by %@" = "moderowany przez %@"; +/* member role */ +"moderator" = "moderator"; + /* time unit */ "months" = "miesiące"; +/* swipe action */ +"More" = "Więcej"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Więcej ulepszeń już wkrótce!"; /* No comment provided by engineer. */ "More reliable network connection." = "Bardziej niezawodne połączenia sieciowe."; +/* No comment provided by engineer. */ +"More reliable notifications" = "Bardziej niezawodne powiadomienia"; + /* item status description */ "Most likely this connection is deleted." = "Najprawdopodobniej to połączenie jest usunięte."; @@ -3099,6 +3552,9 @@ snd error text */ /* notification label action */ "Mute" = "Wycisz"; +/* notification label action */ +"Mute all" = "Wycisz wszystko"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Wyciszony, gdy jest nieaktywny!"; @@ -3111,12 +3567,18 @@ snd error text */ /* No comment provided by engineer. */ "Network connection" = "Połączenie z siecią"; +/* No comment provided by engineer. */ +"Network decentralization" = "Decentralizacja sieci"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Błąd sieciowy - wiadomość wygasła po wielu próbach wysłania jej."; /* No comment provided by engineer. */ "Network management" = "Zarządzenie sieciowe"; +/* No comment provided by engineer. */ +"Network operator" = "Operator sieci"; + /* No comment provided by engineer. */ "Network settings" = "Ustawienia sieci"; @@ -3126,6 +3588,9 @@ snd error text */ /* delete after time */ "never" = "nigdy"; +/* token status text */ +"New" = "Nowy"; + /* No comment provided by engineer. */ "New chat" = "Nowy czat"; @@ -3144,6 +3609,12 @@ snd error text */ /* No comment provided by engineer. */ "New display name" = "Nowa wyświetlana nazwa"; +/* notification */ +"New events" = "Nowe wydarzenia"; + +/* No comment provided by engineer. */ +"New group role: Moderator" = "Nowa rola w grupie: Moderator"; + /* No comment provided by engineer. */ "New in %@" = "Nowość w %@"; @@ -3153,6 +3624,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "Nowa rola członka"; +/* rcv group event chat item */ +"New member wants to join the group." = "Nowy członek chce dołączyć do grupy."; + /* notification */ "new message" = "nowa wiadomość"; @@ -3165,6 +3639,9 @@ snd error text */ /* No comment provided by engineer. */ "New passphrase…" = "Nowe hasło…"; +/* No comment provided by engineer. */ +"New server" = "Nowy serwer"; + /* No comment provided by engineer. */ "New SOCKS credentials will be used every time you start the app." = "Nowe poświadczenia SOCKS będą używane przy każdym uruchomieniu aplikacji."; @@ -3180,6 +3657,18 @@ snd error text */ /* Authentication unavailable */ "No app password" = "Brak hasła aplikacji"; +/* No comment provided by engineer. */ +"No chats" = "Żadnych czatów"; + +/* No comment provided by engineer. */ +"No chats found" = "Nie znaleziono żadnych czatów"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Brak czatów na liście %@"; + +/* No comment provided by engineer. */ +"No chats with members" = "Żadnych rozmów z członkami"; + /* No comment provided by engineer. */ "No contacts selected" = "Nie wybrano kontaktów"; @@ -3210,6 +3699,15 @@ snd error text */ /* No comment provided by engineer. */ "No info, try to reload" = "Brak informacji, spróbuj przeładować"; +/* servers error */ +"No media & file servers." = "Brak mediów i serwerów plików multimedialnych."; + +/* No comment provided by engineer. */ +"No message" = "Brak wiadomości"; + +/* servers error */ +"No message servers." = "Brak serwerów wiadomości."; + /* No comment provided by engineer. */ "No network connection" = "Brak połączenia z siecią"; @@ -3222,21 +3720,51 @@ snd error text */ /* No comment provided by engineer. */ "No permission to record voice message" = "Brak uprawnień do nagrywania wiadomości głosowej"; +/* alert title */ +"No private routing session" = "Brak prywatnej sesji routingu"; + /* No comment provided by engineer. */ "No push server" = "Lokalnie"; /* No comment provided by engineer. */ "No received or sent files" = "Brak odebranych lub wysłanych plików"; +/* servers error */ +"No servers for private message routing." = "Brak serwerów prywatnej sesji routingu."; + +/* servers error */ +"No servers to receive files." = "Brak serwerów do otrzymania plików."; + +/* servers error */ +"No servers to receive messages." = "Brak serwerów aby otrzymać wiadomości."; + +/* servers error */ +"No servers to send files." = "Brak serwerów do wysyłania plików."; + +/* No comment provided by engineer. */ +"no subscription" = "brak subskrypcji"; + /* copied message info in history */ "no text" = "brak tekstu"; +/* alert title */ +"No token!" = "Brak tokenu!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Brak nieprzeczytanych czatów"; + /* No comment provided by engineer. */ "No user identifiers." = "Brak identyfikatorów użytkownika."; /* No comment provided by engineer. */ "Not compatible!" = "Nie kompatybilny!"; +/* No comment provided by engineer. */ +"not synchronized" = "nie zsynchronizowano"; + +/* No comment provided by engineer. */ +"Notes" = "Notatki"; + /* No comment provided by engineer. */ "Nothing selected" = "Nic nie jest zaznaczone"; @@ -3249,6 +3777,15 @@ snd error text */ /* No comment provided by engineer. */ "Notifications are disabled!" = "Powiadomienia są wyłączone!"; +/* alert title */ +"Notifications error" = "Błąd powiadomień"; + +/* No comment provided by engineer. */ +"Notifications privacy" = "Prywatność powiadomień"; + +/* alert title */ +"Notifications status" = "Stan powiadomień"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Teraz administratorzy mogą:\n- usuwać wiadomości członków.\n- wyłączyć członków (rola \"obserwatora\")"; @@ -3296,6 +3833,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Hosty onion nie będą używane."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Tylko właściciele czatu mogą zmieniać preferencje."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości wysyłane za pomocą **2-warstwowego szyfrowania end-to-end**."; @@ -3311,6 +3851,12 @@ new chat action */ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Tylko właściciele grup mogą włączyć wiadomości głosowe."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Widzą to tylko nadawca i moderatorzy"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Widzisz to tylko Ty i moderatorzy"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Tylko Ty możesz dodawać reakcje wiadomości."; @@ -3323,6 +3869,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "Tylko Ty możesz wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Only you can send files and media." = "Tylko Ty możesz wysyłać pliki i multimedia."; + /* No comment provided by engineer. */ "Only you can send voice messages." = "Tylko Ty możesz wysyłać wiadomości głosowe."; @@ -3338,30 +3887,75 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "Tylko Twój kontakt może wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "Tylko Twój kontakt może wysyłać pliki i multimedia."; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; /* alert action */ "Open" = "Otwórz"; +/* No comment provided by engineer. */ +"Open changes" = "Otwórz zmiany"; + /* new chat action */ "Open chat" = "Otwórz czat"; /* authentication reason */ "Open chat console" = "Otwórz konsolę czatu"; +/* alert action */ +"Open clean link" = "Otwórz czysty link"; + +/* No comment provided by engineer. */ +"Open conditions" = "Otwórz warunki"; + +/* alert action */ +"Open full link" = "Otwórz pełny link"; + /* new chat action */ "Open group" = "Grupa otwarta"; +/* alert title */ +"Open link?" = "Otworzyć link?"; + /* authentication reason */ "Open migration to another device" = "Otwórz migrację na innym urządzeniu"; +/* new chat action */ +"Open new chat" = "Otwórz nowy czat"; + +/* new chat action */ +"Open new group" = "Otwórz nową grupę"; + /* No comment provided by engineer. */ "Open Settings" = "Otwórz Ustawienia"; +/* No comment provided by engineer. */ +"Open to accept" = "Otwórz by zaakceptować"; + +/* No comment provided by engineer. */ +"Open to connect" = "Otwórz aby się połączyć"; + +/* No comment provided by engineer. */ +"Open to join" = "Otwórz aby dołączyć"; + +/* No comment provided by engineer. */ +"Open to use bot" = "Otwórz aby skorzystać z bota"; + /* No comment provided by engineer. */ "Opening app…" = "Otwieranie aplikacji…"; +/* No comment provided by engineer. */ +"Operator" = "Operator"; + +/* alert title */ +"Operator server" = "Serwer Operatora"; + +/* No comment provided by engineer. */ +"Or import archive file" = "Lub zaimportuj plik archiwalny"; + /* No comment provided by engineer. */ "Or paste archive link" = "Lub wklej link archiwum"; @@ -3374,6 +3968,12 @@ new chat action */ /* No comment provided by engineer. */ "Or show this code" = "Lub pokaż ten kod"; +/* No comment provided by engineer. */ +"Or to share privately" = "Lub udostępnij prywatnie"; + +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organizuj czaty jako listy"; + /* No comment provided by engineer. */ "other" = "inne"; @@ -3428,9 +4028,18 @@ new chat action */ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "oczekuje"; + /* No comment provided by engineer. */ "Pending" = "Oczekujące"; +/* No comment provided by engineer. */ +"pending approval" = "oczekuje na zatwierdzenie"; + +/* No comment provided by engineer. */ +"pending review" = "oczekuje na ocenę"; + /* No comment provided by engineer. */ "Periodic" = "Okresowo"; @@ -3497,6 +4106,18 @@ new chat action */ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Przechowuj kod dostępu w bezpieczny sposób, w przypadku jego utraty NIE będzie można go zmienić."; +/* token info */ +"Please try to disable and re-enable notfications." = "Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy."; + +/* token info */ +"Please wait for token activation to complete." = "Proszę poczekać na zakończenie aktywacji tokenu."; + +/* token info */ +"Please wait for token to be registered." = "Proszę poczekać na zarejestrowanie tokenu."; + /* No comment provided by engineer. */ "Polish interface" = "Polski interfejs"; @@ -3509,6 +4130,9 @@ new chat action */ /* No comment provided by engineer. */ "Preset server address" = "Wstępnie ustawiony adres serwera"; +/* No comment provided by engineer. */ +"Preset servers" = "Domyślne serwery"; + /* No comment provided by engineer. */ "Preview" = "Podgląd"; @@ -3518,12 +4142,24 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "Prywatność i bezpieczeństwo"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Prywatność dla Twoich klientów."; + +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Polityka prywatności i warunki korzystania."; + /* No comment provided by engineer. */ "Privacy redefined" = "Redefinicja prywatności"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów."; + /* No comment provided by engineer. */ "Private filenames" = "Prywatne nazwy plików"; +/* No comment provided by engineer. */ +"Private media file names." = "Nazwy prywatnych plików multimedialnych."; + /* No comment provided by engineer. */ "Private message routing" = "Trasowanie prywatnych wiadomości"; @@ -3539,6 +4175,9 @@ new chat action */ /* alert title */ "Private routing error" = "Błąd prywatnego trasowania"; +/* alert title */ +"Private routing timeout" = "Limit czasu routingu prywatnego"; + /* No comment provided by engineer. */ "Profile and server connections" = "Profil i połączenia z serwerem"; @@ -3569,6 +4208,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Zabroń reakcje wiadomości."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Zabroń raportowania wiadomości moderatorom."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Zabroń wysyłania bezpośrednich wiadomości do członków."; @@ -3596,6 +4238,9 @@ new chat action */ /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Chroni Twój adres IP przed przekaźnikami wiadomości wybranych przez Twoje kontakty.\nWłącz w ustawianiach *Sieć i serwery* ."; +/* No comment provided by engineer. */ +"Protocol background timeout" = "Limit czasu protokołu w tle"; + /* No comment provided by engineer. */ "Protocol timeout" = "Limit czasu protokołu"; @@ -3728,6 +4373,15 @@ new chat action */ /* No comment provided by engineer. */ "Reduced battery usage" = "Zmniejszone zużycie baterii"; +/* No comment provided by engineer. */ +"Register" = "Zarejestruj"; + +/* token info */ +"Register notification token?" = "Zarejestrować token powiadomień?"; + +/* token status text */ +"Registered" = "Zarejestrowany"; + /* alert action reject incoming call via notification swipe action */ @@ -3739,6 +4393,12 @@ swipe action */ /* alert title */ "Reject contact request" = "Odrzuć prośbę kontaktu"; +/* alert title */ +"Reject member?" = "Odrzucić członka?"; + +/* No comment provided by engineer. */ +"rejected" = "odrzucono"; + /* call status */ "rejected call" = "odrzucone połączenie"; @@ -3751,12 +4411,18 @@ swipe action */ /* alert action */ "Remove" = "Usuń"; +/* alert action */ +"Remove and delete messages" = "Usuń i skasuj wiadomości"; + /* No comment provided by engineer. */ "Remove archive?" = "Usunąć archiwum?"; /* No comment provided by engineer. */ "Remove image" = "Usuń obraz"; +/* No comment provided by engineer. */ +"Remove link tracking" = "Usuń śledzenie linków"; + /* No comment provided by engineer. */ "Remove member" = "Usuń członka"; @@ -3775,12 +4441,18 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "usunięto adres kontaktu"; +/* No comment provided by engineer. */ +"removed from group" = "usunięty z grupy"; + /* profile update event chat item */ "removed profile picture" = "usunięto zdjęcie profilu"; /* rcv group event chat item */ "removed you" = "usunął cię"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "Usuwa wiadomości i blokuje członków."; + /* No comment provided by engineer. */ "Renegotiate" = "Renegocjuj"; @@ -3802,6 +4474,54 @@ swipe action */ /* chat item action */ "Reply" = "Odpowiedz"; +/* chat item action */ +"Report" = "Zgłoś"; + +/* report reason */ +"Report content: only group moderators will see it." = "Zgłoś treść: zobaczą ją tylko moderatorzy grupy."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy."; + +/* report reason */ +"Report other: only group moderators will see it." = "Zgłoś inne: zobaczą to tylko moderatorzy grupy."; + +/* No comment provided by engineer. */ +"Report reason?" = "Jaki jest powód zgłoszenia?"; + +/* alert title */ +"Report sent to moderators" = "Zgłoszenia wysłane do moderatorów"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Zgłoś spam: tylko moderatorzy grupy będą to widzieć."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy."; + +/* report in notification */ +"Report: %@" = "Zgłoszenie: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Zgłaszanie wiadomości moderatorom jest zabronione."; + +/* No comment provided by engineer. */ +"Reports" = "Zgłoszenia"; + +/* No comment provided by engineer. */ +"request is sent" = "prośba została wysłana"; + +/* No comment provided by engineer. */ +"request to join rejected" = "prośba o dołączenie została odrzucona"; + +/* rcv group event chat item */ +"requested connection" = "prośba o połączenie"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "prośba o połączenie od grupy %@"; + +/* chat list item title */ +"requested to connect" = "poproszono o połączenie"; + /* No comment provided by engineer. */ "Required" = "Wymagane"; @@ -3853,6 +4573,24 @@ swipe action */ /* chat item action */ "Reveal" = "Ujawnij"; +/* No comment provided by engineer. */ +"review" = "ocena"; + +/* No comment provided by engineer. */ +"Review conditions" = "Przejrzyj warunki"; + +/* No comment provided by engineer. */ +"Review group members" = "Przejrzyj członków grupy"; + +/* admission stage */ +"Review members" = "Przejrzyj członków"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Przejrzyj członków przed dopuszczeniem (\"zapukaj\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "sprawdzone przez administratorów"; + /* No comment provided by engineer. */ "Revoke" = "Odwołaj"; @@ -3881,6 +4619,12 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Zapisz (i powiadom kontakty)"; +/* alert button */ +"Save (and notify members)" = "Zapisz (i powiadom członków)"; + +/* alert title */ +"Save admission settings?" = "Zapisać ustawienia wstępu?"; + /* alert button */ "Save and notify contact" = "Zapisz i powiadom kontakt"; @@ -3896,6 +4640,12 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "Zapisz profil grupy"; +/* alert title */ +"Save group profile?" = "Zapisać profil grupy?"; + +/* No comment provided by engineer. */ +"Save list" = "Zapisz listę"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Zapisz hasło i otwórz czat"; @@ -3971,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "Pasek wyszukiwania akceptuje linki zaproszenia."; +/* No comment provided by engineer. */ +"Search files" = "Szukaj plików"; + +/* No comment provided by engineer. */ +"Search images" = "Szukaj zdjęć"; + +/* No comment provided by engineer. */ +"Search links" = "Szukaj linków"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Wyszukaj lub wklej link SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Szukaj wideo"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Szukaj wiadomości głosowych"; + /* network option */ "sec" = "sek"; @@ -4031,6 +4796,9 @@ chat item action */ /* No comment provided by engineer. */ "Send a live message - it will update for the recipient(s) as you type it" = "Wysyłaj wiadomości na żywo - będą one aktualizowane dla odbiorcy(ów) w trakcie ich wpisywania"; +/* No comment provided by engineer. */ +"Send contact request?" = "Wysłać prośbę o kontakt?"; + /* No comment provided by engineer. */ "Send delivery receipts to" = "Wyślij potwierdzenia dostawy do"; @@ -4061,18 +4829,30 @@ chat item action */ /* No comment provided by engineer. */ "Send notifications" = "Wyślij powiadomienia"; +/* No comment provided by engineer. */ +"Send private reports" = "Wyślij prywatne zgłoszenia"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Wyślij pytania i pomysły"; /* No comment provided by engineer. */ "Send receipts" = "Wyślij potwierdzenia"; +/* No comment provided by engineer. */ +"Send request" = "Wyślij prośbę"; + +/* No comment provided by engineer. */ +"Send request without message" = "Wyślij prośbę bez wiadomości"; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Wyślij je z galerii lub niestandardowych klawiatur."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Wysyłaj do 100 ostatnich wiadomości do nowych członków."; +/* No comment provided by engineer. */ +"Send your private feedback to groups." = "Wyślij swoją prywatną opinię do grup."; + /* alert message */ "Sender cancelled file transfer." = "Nadawca anulował transfer pliku."; @@ -4133,6 +4913,9 @@ chat item action */ /* No comment provided by engineer. */ "Server" = "Serwer"; +/* alert message */ +"Server added to operator %@." = "Serwer został dodany do operatora %@."; + /* No comment provided by engineer. */ "Server address" = "Adres serwera"; @@ -4142,14 +4925,23 @@ chat item action */ /* srv error text. */ "Server address is incompatible with network settings." = "Adres serwera jest niekompatybilny z ustawieniami sieciowymi."; +/* alert title */ +"Server operator changed." = "Operator serwera został zmieniony."; + +/* No comment provided by engineer. */ +"Server operators" = "Operatorzy serwera"; + +/* alert title */ +"Server protocol changed." = "Protokół serwera zmieniony."; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "Informacje kolejki serwera: %1$@\n\nostatnia otrzymana wiadomość: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło"; +"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło."; /* server test error */ -"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło"; +"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło."; /* No comment provided by engineer. */ "Server test failed!" = "Test serwera nie powiódł się!"; @@ -4178,6 +4970,9 @@ chat item action */ /* No comment provided by engineer. */ "Set 1 day" = "Ustaw 1 dzień"; +/* No comment provided by engineer. */ +"Set chat name…" = "Ustaw nazwę czatu…"; + /* No comment provided by engineer. */ "Set contact name…" = "Ustaw nazwę kontaktu…"; @@ -4190,6 +4985,12 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Ustaw go zamiast uwierzytelniania systemowego."; +/* No comment provided by engineer. */ +"Set member admission" = "Ustaw przyjmowanie członków"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Ustaw datę wygaśnięcia wiadomości na czatach."; + /* profile update event chat item */ "set new contact address" = "ustaw nowy adres kontaktu"; @@ -4205,6 +5006,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "Ustaw hasło do eksportu"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "Ustaw biografię profilu i wiadomość powitalną."; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "Ustaw wiadomość wyświetlaną nowym członkom!"; @@ -4227,9 +5031,15 @@ chat item action */ /* No comment provided by engineer. */ "Share 1-time link" = "Udostępnij 1-razowy link"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "Udostępnij jednorazowy link znajomemu"; + /* No comment provided by engineer. */ "Share address" = "Udostępnij adres"; +/* No comment provided by engineer. */ +"Share address publicly" = "Udostępnij adres publicznie"; + /* alert title */ "Share address with contacts?" = "Udostępnić adres kontaktom?"; @@ -4239,9 +5049,18 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "Udostępnij link"; +/* alert button */ +"Share old address" = "Udostępnij stary adres"; + +/* alert button */ +"Share old link" = "Udostępnij stary link"; + /* No comment provided by engineer. */ "Share profile" = "Udostępnij profil"; +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "Udostępnij adres SimpleX w mediach społecznościowych."; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "Udostępnij ten jednorazowy link"; @@ -4251,6 +5070,18 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Udostępnij kontaktom"; +/* No comment provided by engineer. */ +"Share your address" = "Udostępnij swój adres"; + +/* No comment provided by engineer. */ +"Short description" = "Krótki opis"; + +/* No comment provided by engineer. */ +"Short link" = "Krótki link"; + +/* No comment provided by engineer. */ +"Short SimpleX address" = "Krótki adres SimpleX"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Pokaż → na wiadomościach wysłanych przez prywatne trasowanie."; @@ -4287,9 +5118,21 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX Address" = "Adres SimpleX"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator."; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "Adres SimpleX czy link jednorazowy?"; + /* alert title */ "SimpleX address settings" = "Ustawienia automatycznej akceptacji"; +/* simplex link type */ +"SimpleX channel link" = "Link do kanału na SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "Bezpieczeństwo SimpleX Chat zostało zaudytowane przez Trail of Bits."; @@ -4326,6 +5169,12 @@ chat item action */ /* simplex link type */ "SimpleX one-time invitation" = "Zaproszenie jednorazowe SimpleX"; +/* No comment provided by engineer. */ +"SimpleX protocols reviewed by Trail of Bits." = "Protokoły SimpleX sprawdzone przez Trail of Bits."; + +/* simplex link type */ +"SimpleX relay link" = "łącze przekaźnikowe SimpleX"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "Uproszczony tryb incognito"; @@ -4362,9 +5211,16 @@ chat item action */ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "Podczas importu wystąpiły niekrytyczne błędy:"; +/* alert message */ +"Some servers failed the test:\n%@" = "Niektóre serwery nie przeszły testu:\n%@"; + /* notification title */ "Somebody" = "Ktoś"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Kwadrat, okrąg lub cokolwiek pomiędzy."; @@ -4422,6 +5278,9 @@ chat item action */ /* No comment provided by engineer. */ "Stopping chat" = "Zatrzymywanie czatu"; +/* No comment provided by engineer. */ +"Storage" = "Magazyn"; + /* No comment provided by engineer. */ "strike" = "strajk"; @@ -4443,6 +5302,12 @@ chat item action */ /* No comment provided by engineer. */ "Support SimpleX Chat" = "Wspieraj SimpleX Chat"; +/* No comment provided by engineer. */ +"Switch audio and video during the call." = "Przełączanie audio i wideo podczas połączenia."; + +/* No comment provided by engineer. */ +"Switch chat profile for 1-time invitations." = "Przełącz profil czatu dla zaproszeń jednorazowych."; + /* No comment provided by engineer. */ "System" = "System"; @@ -4458,6 +5323,21 @@ chat item action */ /* No comment provided by engineer. */ "Tap button " = "Naciśnij przycisk "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "Dotknij Połącz aby rozpocząć czat"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "Dotknij Połącz, aby wysłać prośbę"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "Dotknij Połącz aby użyć bota"; + +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Dotknij Stwórz adres SimpleX w menu aby utworzyć go później."; + +/* No comment provided by engineer. */ +"Tap Join group" = "Dotknij Dołącz do grupy"; + /* No comment provided by engineer. */ "Tap to activate profile." = "Dotknij, aby aktywować profil."; @@ -4479,9 +5359,15 @@ chat item action */ /* No comment provided by engineer. */ "TCP connection" = "Połączenie TCP"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "Przekroczono limit czasu połączenia TCP"; + /* No comment provided by engineer. */ "TCP connection timeout" = "Limit czasu połączenia TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Port TCP dla wiadomości"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4497,6 +5383,9 @@ chat item action */ /* server test failure */ "Test failed at step %@." = "Test nie powiódł się na etapie %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Powiadomienia testowe"; + /* No comment provided by engineer. */ "Test server" = "Przetestuj serwer"; @@ -4515,9 +5404,15 @@ chat item action */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "Podziękowania dla użytkowników - wkład za pośrednictwem Weblate!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Aplikacja może powiadamiać Cię, gdy otrzymujesz wiadomości lub prośby o kontakt — otwórz ustawienia, aby włączyć."; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Aplikacja zapyta o potwierdzenie pobierania od nieznanych serwerów plików (poza .onion)."; @@ -4527,6 +5422,9 @@ chat item action */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Kod, który zeskanowałeś nie jest kodem QR linku SimpleX."; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline."; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Zaakceptowane przez Ciebie połączenie zostanie anulowane!"; @@ -4548,6 +5446,9 @@ chat item action */ /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Identyfikator następnej wiadomości jest nieprawidłowy (mniejszy lub równy poprzedniej).\nMoże się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skompromitowane."; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link."; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "Wiadomość zostanie usunięta dla wszystkich członków."; @@ -4563,6 +5464,12 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stara baza danych nie została usunięta podczas migracji, można ją usunąć."; +/* No comment provided by engineer. */ +"The same conditions will apply to operator **%@**." = "Te same warunki będą miały zastosowanie do operatora **%@**."; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "Drugi predefiniowany operator w aplikacji!"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Drugi tik, który przegapiliśmy! ✅"; @@ -4572,6 +5479,9 @@ chat item action */ /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "Serwery dla nowych połączeń bieżącego profilu czatu **%@**."; +/* No comment provided by engineer. */ +"The servers for new files of your current chat profile **%@**." = "Serwery dla nowych plików Twojego bieżącego profilu czatu **%@**."; + /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Tekst, który wkleiłeś nie jest linkiem SimpleX."; @@ -4581,6 +5491,9 @@ chat item action */ /* No comment provided by engineer. */ "Themes" = "Motywy"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "Warunki te będą miały również zastosowanie w przypadku: **%@**."; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Te ustawienia dotyczą Twojego bieżącego profilu **%@**."; @@ -4593,6 +5506,9 @@ chat item action */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Tego działania nie można cofnąć - wiadomości wysłane i odebrane wcześniej niż wybrane zostaną usunięte. Może to potrwać kilka minut."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone."; @@ -4617,12 +5533,24 @@ chat item action */ /* No comment provided by engineer. */ "This group no longer exists." = "Ta grupa już nie istnieje."; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Ta wiadomość została usunięta lub jeszcze nie otrzymana."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**."; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "To ustawienie jest dla Twojego obecnego profilu **%@**."; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "Czas zniknięcia jest ustawiony tylko dla nowych kontaktów."; + /* No comment provided by engineer. */ "Title" = "Tytuł"; @@ -4638,6 +5566,9 @@ chat item action */ /* No comment provided by engineer. */ "To make a new connection" = "Aby nawiązać nowe połączenie"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu."; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Aby chronić strefę czasową, pliki obrazów/głosów używają UTC."; @@ -4650,6 +5581,9 @@ chat item action */ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Aby chronić prywatność, zamiast identyfikatorów użytkowników używanych przez wszystkie inne platformy, SimpleX ma identyfikatory dla kolejek wiadomości, oddzielne dla każdego z Twoich kontaktów."; +/* No comment provided by engineer. */ +"To receive" = "Żeby odebrać"; + /* No comment provided by engineer. */ "To record speech please grant permission to use Microphone." = "Aby nagrać rozmowę, proszę zezwolić na użycie Mikrofonu."; @@ -4662,9 +5596,21 @@ chat item action */ /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Aby ujawnić Twój ukryty profil, wprowadź pełne hasło w pole wyszukiwania na stronie **Twoich profili czatu**."; +/* No comment provided by engineer. */ +"To send" = "Żeby wysłać"; + +/* alert message */ +"To send commands you must be connected." = "Aby wysyłać polecenia, musisz być podłączony."; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Aby obsługiwać natychmiastowe powiadomienia push, należy zmigrować bazę danych czatu."; +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie."; + +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "Aby korzystać z serwerów **%@**, należy zaakceptować warunki użytkowania."; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach."; @@ -4674,6 +5620,9 @@ chat item action */ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Przełącz incognito przy połączeniu."; +/* token status */ +"Token status: %@." = "Stan tokena: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Nieprzezroczystość paska narzędzi"; @@ -4686,6 +5635,9 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sesje transportowe"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia."; + /* No comment provided by engineer. */ "Turkish interface" = "Turecki interfejs"; @@ -4716,6 +5668,9 @@ chat item action */ /* rcv group event chat item */ "unblocked %@" = "odblokowano %@"; +/* No comment provided by engineer. */ +"Undelivered messages" = "Niedostarczone wiadomości"; + /* No comment provided by engineer. */ "Unexpected migration state" = "Nieoczekiwany stan migracji"; @@ -4782,6 +5737,9 @@ chat item action */ /* swipe action */ "Unread" = "Nieprzeczytane"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Nieobsługiwane łącze połączenia"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Do nowych członków wysyłanych jest do 100 ostatnich wiadomości."; @@ -4797,6 +5755,9 @@ chat item action */ /* No comment provided by engineer. */ "Update settings?" = "Zaktualizować ustawienia?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Zaktualizowane warunki"; + /* rcv group event chat item */ "updated group profile" = "zaktualizowano profil grupy"; @@ -4806,9 +5767,27 @@ chat item action */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "Aktualizacja ustawień spowoduje ponowne połączenie klienta ze wszystkimi serwerami."; +/* alert button */ +"Upgrade" = "Zaktualizuj"; + +/* No comment provided by engineer. */ +"Upgrade address" = "Uaktualnij adres"; + +/* alert message */ +"Upgrade address?" = "Uaktualnić adres?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "Zaktualizuj i otwórz czat"; +/* alert message */ +"Upgrade group link?" = "Uaktualnić link do grupy?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "Uaktualnij link"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "Zaktualizuj swój adres"; + /* No comment provided by engineer. */ "Upload errors" = "Błędy przesłania"; @@ -4830,18 +5809,30 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "Użyj hostów .onion"; +/* No comment provided by engineer. */ +"Use %@" = "Użyj %@"; + /* No comment provided by engineer. */ "Use chat" = "Użyj czatu"; /* new chat action */ "Use current profile" = "Użyj obecnego profilu"; +/* No comment provided by engineer. */ +"Use for files" = "Użyj dla plików"; + +/* No comment provided by engineer. */ +"Use for messages" = "Użyj dla wiadomości"; + /* No comment provided by engineer. */ "Use for new connections" = "Użyj dla nowych połączeń"; /* No comment provided by engineer. */ "Use from desktop" = "Użyj z komputera"; +/* No comment provided by engineer. */ +"Use incognito profile" = "Użyj profilu incognito"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Użyj interfejsu połączeń iOS"; @@ -4860,18 +5851,30 @@ chat item action */ /* No comment provided by engineer. */ "Use server" = "Użyj serwera"; +/* No comment provided by engineer. */ +"Use servers" = "Użyj serwerów"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Użyć serwerów SimpleX Chat?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "Użyj proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Jeśli nie podano portu, należy użyć portu TCP %@."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Używaj portu TCP 443 tylko dla domyślnych serwerów."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Używaj aplikacji podczas połączenia."; /* No comment provided by engineer. */ "Use the app with one hand." = "Korzystaj z aplikacji jedną ręką."; +/* No comment provided by engineer. */ +"Use web port" = "Użyj portu internetowego"; + /* No comment provided by engineer. */ "User selection" = "Wybór użytkownika"; @@ -4941,12 +5944,21 @@ chat item action */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Film zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później!"; +/* No comment provided by engineer. */ +"Videos" = "Wideo"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Filmy i pliki do 1gb"; +/* No comment provided by engineer. */ +"View conditions" = "Zobacz warunki"; + /* No comment provided by engineer. */ "View security code" = "Pokaż kod bezpieczeństwa"; +/* No comment provided by engineer. */ +"View updated conditions" = "Zobacz zaktualizowane warunki"; + /* chat feature */ "Visible history" = "Widoczna historia"; @@ -5016,6 +6028,9 @@ chat item action */ /* No comment provided by engineer. */ "Welcome message is too long" = "Wiadomość powitalna jest zbyt długa"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "Powitaj swoje kontakty 👋"; + /* No comment provided by engineer. */ "What's new" = "Co nowego"; @@ -5028,6 +6043,9 @@ chat item action */ /* No comment provided by engineer. */ "when IP hidden" = "gdy IP ukryty"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje."; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Gdy udostępnisz komuś profil incognito, będzie on używany w grupach, do których Cię zaprosi."; @@ -5082,6 +6100,9 @@ chat item action */ /* No comment provided by engineer. */ "You accepted connection" = "Zaakceptowałeś połączenie"; +/* snd group event chat item */ +"you accepted this member" = "zaakceptowałeś tego członka"; + /* No comment provided by engineer. */ "You allow" = "Pozwalasz"; @@ -5091,6 +6112,9 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Jesteś już połączony z %@."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Zostałeś już połączony z %@."; + /* new chat sheet message */ "You are already connecting to %@." = "Już się łączysz z %@."; @@ -5109,9 +6133,15 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Już dołączasz do grupy!\nPowtórzyć prośbę dołączenia?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia."; + /* No comment provided by engineer. */ "You are invited to group" = "Jesteś zaproszony do grupy"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Nie masz połączenia z serwerem służącym do odbierania wiadomości w ramach tego połączenia (brak subskrypcji)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości."; @@ -5127,6 +6157,9 @@ chat item action */ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Możesz to zmienić w ustawieniach wyglądu."; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "Serwery można skonfigurować w ustawieniach."; + /* No comment provided by engineer. */ "You can create it later" = "Możesz go utworzyć później"; @@ -5151,6 +6184,9 @@ chat item action */ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Możesz wysyłać wiadomości do %@ ze zarchiwizowanych kontaktów."; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony."; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Podgląd powiadomień na ekranie blokady można ustawić w ustawieniach."; @@ -5175,6 +6211,9 @@ chat item action */ /* alert message */ "You can view invitation link again in connection details." = "Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia."; +/* alert message */ +"You can view your reports in Chat with admins." = "Możesz przeglądać swoje raporty w czacie z administratorami."; + /* alert title */ "You can't send messages!" = "Nie możesz wysyłać wiadomości!"; @@ -5244,9 +6283,15 @@ chat item action */ /* chat list item description */ "you shared one-time link incognito" = "udostępniłeś jednorazowy link incognito"; +/* token info */ +"You should receive notifications." = "Powinieneś otrzymywać powiadomienia."; + /* snd group event chat item */ "you unblocked %@" = "odblokowałeś %@"; +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "Będziesz mógł wysyłać wiadomości **dopiero po zaakceptowaniu Twojej prośby**."; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później!"; @@ -5265,6 +6310,9 @@ chat item action */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Nadal będziesz otrzymywać połączenia i powiadomienia z wyciszonych profili, gdy są one aktywne."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Przestaniesz otrzymywać wiadomości od tej grupy. Historia czatu zostanie zachowana."; @@ -5280,6 +6328,9 @@ chat item action */ /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Używasz profilu incognito dla tej grupy - aby zapobiec udostępnianiu głównego profilu zapraszanie kontaktów jest zabronione"; +/* No comment provided by engineer. */ +"Your business contact" = "Twój kontakt biznesowy"; + /* No comment provided by engineer. */ "Your calls" = "Twoje połączenia"; @@ -5295,9 +6346,15 @@ chat item action */ /* No comment provided by engineer. */ "Your chat profiles" = "Twoje profile czatu"; +/* alert message */ +"Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "Twoja rozmowa została przeniesiona do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd."; + /* No comment provided by engineer. */ "Your connection was moved to %@ but an error happened when switching profile." = "Twoje połączenie zostało przeniesione do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd."; +/* No comment provided by engineer. */ +"Your contact" = "Twój kontakt"; + /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Twój kontakt wysłał plik, który jest większy niż obecnie obsługiwany maksymalny rozmiar (%@)."; @@ -5316,6 +6373,9 @@ chat item action */ /* No comment provided by engineer. */ "Your current profile" = "Twój obecny profil"; +/* No comment provided by engineer. */ +"Your group" = "Twoja grupa"; + /* No comment provided by engineer. */ "Your ICE servers" = "Twoje serwery ICE"; 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..c621dc5124 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]`, `publicGroupId: 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..ee0c449c68 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.publicGroup?.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..182e7b7ce9 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.publicGroup?.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.publicGroup?.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..6dda4ba275 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` | `publicGroup` | `PublicGroupProfile?` | Channel-specific profile data (type, link, ID) | [L2472](../SimpleXChat/ChatTypes.swift#L2472) | + +#### 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/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index ff80559fb1..d5afea745d 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -509,6 +509,9 @@ swipe action */ /* feature role */ "all members" = "所有成员"; +/* No comment provided by engineer. */ +"All messages" = "所有消息"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。"; @@ -731,6 +734,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "语音和视频通话"; +/* No comment provided by engineer. */ +"Audio call" = "语音通话"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "语音通话(非端到端加密)"; @@ -1727,6 +1733,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "删除成员消息?"; +/* No comment provided by engineer. */ +"Delete member messages" = "删除成员消息"; + /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; @@ -2559,6 +2568,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "禁止文件和媒体!"; +/* No comment provided by engineer. */ +"Filter" = "过滤器"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "过滤未读和收藏的聊天记录。"; @@ -2862,6 +2874,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "图片将在您的联系人在线时收到,请稍等或稍后查看!"; +/* No comment provided by engineer. */ +"Images" = "图片"; + /* No comment provided by engineer. */ "Immediately" = "立即"; @@ -3045,6 +3060,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "邀请朋友"; +/* No comment provided by engineer. */ +"Invite member" = "邀请成员"; + /* No comment provided by engineer. */ "Invite members" = "邀请成员"; @@ -3198,6 +3216,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "已链接桌面"; +/* No comment provided by engineer. */ +"Links" = "链接"; + /* swipe action */ "List" = "列表"; @@ -4372,6 +4393,9 @@ swipe action */ /* alert action */ "Remove" = "移除"; +/* alert action */ +"Remove and delete messages" = "移除并删除消息"; + /* No comment provided by engineer. */ "Remove archive?" = "删除存档?"; @@ -4676,9 +4700,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "搜索栏接受邀请链接。"; +/* No comment provided by engineer. */ +"Search files" = "搜索文件"; + +/* No comment provided by engineer. */ +"Search images" = "搜索图片"; + +/* No comment provided by engineer. */ +"Search links" = "搜索链接"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "搜索或粘贴 SimpleX 链接"; +/* No comment provided by engineer. */ +"Search videos" = "搜索视频"; + +/* No comment provided by engineer. */ +"Search voice messages" = "搜索语音消息"; + /* network option */ "sec" = "秒"; @@ -5872,6 +5911,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "视频将在您的联系人在线时收到,请稍等或稍后查看!"; +/* No comment provided by engineer. */ +"Videos" = "视频"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "最大 1gb 的视频和文件"; diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 4f47fda130..1a3703822d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -21,12 +21,19 @@ import java.net.URI import kotlin.math.min import kotlin.math.sqrt +private const val MAX_IMAGE_DIMENSION = 4320 + actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") return try { val imageBytes = Base64.decode(imageString, Base64.NO_WRAP) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) + if (options.outWidth <= 0 || options.outHeight <= 0 || options.outWidth > MAX_IMAGE_DIMENSION || options.outHeight > MAX_IMAGE_DIMENSION || options.outHeight > options.outWidth * 256) { + return errorBitmap.asImageBitmap() + } BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap() } catch (e: Exception) { Log.e(TAG, "base64ToBitmap error: $e") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 3d6b227df7..0ac7a1b973 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -78,6 +78,29 @@ object ConnectProgressManager { val connectProgressManager = ConnectProgressManager +object ChannelRelaysModel { + val groupId = mutableStateOf(null) + val groupRelays = mutableStateListOf() + + fun set(groupId: Long, groupRelays: List) { + this.groupId.value = groupId + this.groupRelays.clear() + this.groupRelays.addAll(groupRelays) + } + + fun updateRelay(groupInfo: GroupInfo, relay: GroupRelay) { + if (groupId.value == groupInfo.groupId) { + val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId } + if (i >= 0) groupRelays[i] = relay + } + } + + fun reset() { + groupId.value = null + groupRelays.clear() + } +} + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -110,9 +133,13 @@ object ChatModel { val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) + val creatingChannelId = mutableStateOf(null) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) val membersLoaded = mutableStateOf(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. + val channelRelayHostnames = mutableStateMapOf>() // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -847,12 +874,22 @@ object ChatModel { } fun removeChat(rhId: Long?, id: String) { + var groupId: Long? = null val i = getChatIndex(rhId, id) if (i != -1) { val chat = chats.removeAt(i) + groupId = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.groupId removePresetChatTags(chat.chatInfo, chat.chatStats) removeWallpaperFilesFromChat(chat) } + if (chatId.value == id) { + groupMembers.value = emptyList() + groupMembersIndexes.value = emptyMap() + if (groupId != null) { + channelRelayHostnames.remove(groupId) + } + membersLoaded.value = false + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -861,8 +898,8 @@ object ChatModel { updateGroup(rhId, groupInfo) return false } - // update current chat - return if (chatId.value == groupInfo.id) { + // update current chat or channel being created + return if (chatId.value == groupInfo.id || creatingChannelId.value == groupInfo.id) { if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) { // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn groupMembers.value = emptyList() @@ -1220,6 +1257,7 @@ data class User( val autoAcceptMemberContacts: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, + val userChatRelay: Boolean, ): NamedChat, UserLike { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName @@ -1250,6 +1288,7 @@ data class User( autoAcceptMemberContacts = false, viewPwdHash = null, uiThemes = null, + userChatRelay = false, ) } } @@ -1583,7 +1622,11 @@ sealed class ChatInfo: SomeChat, NamedChat { return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) } if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { - return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + return if (groupInfo.useRelays) { + generalGetString(MR.strings.you_are_subscriber) to null + } else { + generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } } return null } @@ -2009,6 +2052,8 @@ sealed class ForwardConfirmation { @Serializable data class GroupInfo ( val groupId: Long, + val useRelays: Boolean, + val relayOwnStatus: RelayStatus? = null, override val localDisplayName: String, val groupProfile: GroupProfile, val businessChat: BusinessChatInfo? = null, @@ -2020,6 +2065,7 @@ data class GroupInfo ( val chatTs: Instant?, val preparedGroup: PreparedGroup?, val uiThemes: ThemeModeOverrides? = null, + val groupSummary: GroupSummary, val membersRequireAttention: Int, val chatTags: List, val chatItemTTL: Long?, @@ -2061,7 +2107,9 @@ data class GroupInfo ( get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive val chatIconName: ImageResource - get() = when (businessChat?.chatType) { + get() = if (useRelays) { + MR.images.ic_bigtop_updates_padded + } else when (businessChat?.chatType) { null -> MR.images.ic_supervised_user_circle_filled BusinessChatType.Business -> MR.images.ic_work_filled_padded BusinessChatType.Customer -> MR.images.ic_account_circle_filled @@ -2085,6 +2133,7 @@ data class GroupInfo ( companion object { val sampleData = GroupInfo( groupId = 1, + useRelays = false, localDisplayName = "team", groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, @@ -2095,6 +2144,7 @@ data class GroupInfo ( chatTs = Clock.System.now(), preparedGroup = null, uiThemes = null, + groupSummary = GroupSummary(currentMembers = 0), membersRequireAttention = 0, chatTags = emptyList(), localAlias = "", @@ -2113,6 +2163,39 @@ data class PreparedGroup ( @Serializable data class GroupRef(val groupId: Long, val localDisplayName: String) +@Serializable(with = GroupTypeSerializer::class) +sealed class GroupType { + @Serializable @SerialName("channel") object Channel: GroupType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): GroupType() +} + +object GroupTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("GroupType", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): GroupType { + return when (val value = decoder.decodeString()) { + "channel" -> GroupType.Channel + else -> GroupType.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: GroupType) { + val stringValue = when (value) { + is GroupType.Channel -> "channel" + is GroupType.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + +@Serializable +data class PublicGroupProfile( + val groupType: GroupType, + val groupLink: String, + val publicGroupId: String +) + @Serializable data class GroupProfile ( override val displayName: String, @@ -2120,6 +2203,7 @@ data class GroupProfile ( override val shortDescr: String?, val description: String? = null, override val image: String? = null, + val publicGroup: PublicGroupProfile? = null, override val localAlias: String = "", val groupPreferences: GroupPreferences? = null, val memberAdmission: GroupMemberAdmission? = null @@ -2162,10 +2246,76 @@ data class ContactShortLinkData ( ) @Serializable -data class GroupShortLinkData ( - val groupProfile: GroupProfile +data class GroupSummary ( + val currentMembers: Long, + val publicMemberCount: Long? = null ) +@Serializable +data class PublicGroupData ( + val publicMemberCount: Long +) + +@Serializable +data class GroupShortLinkData ( + val groupProfile: GroupProfile, + val publicGroupData: PublicGroupData? = null +) + +@Serializable +enum class RelayStatus { + @SerialName("new") RsNew, + @SerialName("invited") RsInvited, + @SerialName("accepted") RsAccepted, + @SerialName("active") RsActive; + + val text: String get() = when (this) { + RsNew -> generalGetString(MR.strings.relay_status_new) + RsInvited -> generalGetString(MR.strings.relay_status_invited) + RsAccepted -> generalGetString(MR.strings.relay_status_accepted) + RsActive -> generalGetString(MR.strings.relay_status_active) + } +} + +@Serializable +data class RelayProfile( + val displayName: String, + val fullName: String, + val shortDescr: String? = null, + val image: String? = null +) + +@Serializable +data class UserChatRelay( + val chatRelayId: Long?, + val address: String, + val relayProfile: RelayProfile, + val domains: List, + val preset: Boolean, + val tested: Boolean? = null, + val enabled: Boolean, + val deleted: Boolean, +) { + @Transient + private val createdAt: Date = Date() + val id: String get() = "$address $createdAt" + + val displayName: String get() = relayProfile.displayName + + fun copyWithName(name: String): UserChatRelay = copy(relayProfile = relayProfile.copy(displayName = name)) +} + +@Serializable +data class GroupRelay( + val groupRelayId: Long, + val groupMemberId: Long, + val userChatRelay: UserChatRelay, + val relayStatus: RelayStatus, + val relayLink: String? = null +) { + val id: Long get() = groupRelayId +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -2196,7 +2346,8 @@ data class GroupMember ( val memberContactProfileId: Long, var activeConn: Connection? = null, val supportChat: GroupSupportChat? = null, - val memberChatVRange: VersionRange + val memberChatVRange: VersionRange, + val relayLink: String? = null ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -2305,14 +2456,14 @@ data class GroupMember ( } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null + if (memberRole == GroupMemberRole.Relay || !canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null else groupInfo.membership.memberRole.let { userRole -> GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberRole < GroupMemberRole.Moderator + return memberRole != GroupMemberRole.Relay && memberRole < GroupMemberRole.Moderator && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2373,7 +2524,8 @@ data class GroupMemberIds( @Serializable enum class GroupMemberRole(val memberRole: String) { - @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("relay") Relay("relay"), // order matters in comparisons + @SerialName("observer") Observer("observer"), @SerialName("author") Author("author"), @SerialName("member") Member("member"), @SerialName("moderator") Moderator("moderator"), @@ -2385,6 +2537,7 @@ enum class GroupMemberRole(val memberRole: String) { } val text: String get() = when (this) { + Relay -> generalGetString(MR.strings.group_member_role_relay) Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) @@ -2844,6 +2997,8 @@ data class ChatItem ( } else { null } + } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.ChannelRcv) { + null } else { null } @@ -3185,6 +3340,7 @@ sealed class CIDirection { @Serializable @SerialName("directRcv") class DirectRcv: CIDirection() @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection() @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection() + @Serializable @SerialName("channelRcv") class ChannelRcv: CIDirection() @Serializable @SerialName("localSnd") class LocalSnd: CIDirection() @Serializable @SerialName("localRcv") class LocalRcv: CIDirection() @@ -3193,6 +3349,7 @@ sealed class CIDirection { is DirectRcv -> false is GroupSnd -> true is GroupRcv -> false + is ChannelRcv -> false is LocalSnd -> true is LocalRcv -> false } @@ -3733,6 +3890,7 @@ class CIQuote ( is CIDirection.DirectRcv -> null is CIDirection.GroupSnd -> membership?.displayName ?: generalGetString(MR.strings.sender_you_pronoun) is CIDirection.GroupRcv -> chatDir.groupMember.displayName + is CIDirection.ChannelRcv -> null is CIDirection.LocalSnd -> generalGetString(MR.strings.sender_you_pronoun) is CIDirection.LocalRcv -> null null -> null @@ -3790,7 +3948,7 @@ object MsgReactionSerializer : KSerializer { when(val t = json["type"]?.jsonPrimitive?.content ?: "") { "emoji" -> { val msgReaction = try { - val emoji = Json.decodeFromString(json["emoji"].toString()) + val emoji = decoder.json.decodeFromString(json["emoji"].toString()) MsgReaction.Emoji(emoji) } catch (e: Throwable) { MsgReaction.Unknown(t, json) @@ -4232,7 +4390,7 @@ object MsgContentSerializer : KSerializer { when (t) { "text" -> MsgContent.MCText(text) "link" -> { - val preview = Json.decodeFromString(json["preview"].toString()) + val preview = decoder.json.decodeFromString(json["preview"].toString()) MsgContent.MCLink(text, preview) } "image" -> { @@ -4250,11 +4408,11 @@ object MsgContentSerializer : KSerializer { } "file" -> MsgContent.MCFile(text) "report" -> { - val reason = Json.decodeFromString(json["reason"].toString()) + val reason = decoder.json.decodeFromString(json["reason"].toString()) MsgContent.MCReport(text, reason) } "chat" -> { - val chatLink = Json.decodeFromString(json["chatLink"].toString()) + val chatLink = decoder.json.decodeFromString(json["chatLink"].toString()) MsgContent.MCChat(text, chatLink) } else -> MsgContent.MCUnknown(t, text, json) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 388a8064c4..cb42ee2aba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1071,8 +1071,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -1130,8 +1130,8 @@ object ChatController { return null } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, sendAsGroup: Boolean = false, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } @@ -1216,6 +1216,14 @@ object ChatController { throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") } + suspend fun testChatRelay(rh: Long?, address: String): Pair { + val userId = currentUserId("testChatRelay") + val r = sendCmd(rh, CC.APITestChatRelay(userId, address)) + if (r is API.Result && r.res is CR.ChatRelayTestResult) return r.res.relayProfile to r.res.relayTestFailure + Log.e(TAG, "testChatRelay bad response: ${r.responseType} ${r.details}") + throw Exception("testChatRelay bad response: ${r.responseType} ${r.details}") + } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiGetServerOperators()) if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions @@ -1250,10 +1258,10 @@ object ChatController { return false } - suspend fun validateServers(rh: Long?, userServers: List): List? { + suspend fun validateServers(rh: Long?, userServers: List): Pair, List>? { val userId = currentUserId("validateServers") val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) - if (r is API.Result && r.res is CR.UserServersValidation) return r.res.serverErrors + if (r is API.Result && r.res is CR.UserServersValidation) return Pair(r.res.serverErrors, r.res.serverWarnings) Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") return null } @@ -1343,6 +1351,12 @@ object ChatController { suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiGetUpdatedGroupLinkData(rh: Long?, groupId: Long): GroupInfo? { + val r = sendCmd(rh, CC.ApiGetUpdatedGroupLinkData(groupId)) + if (r is API.Result && r.res is CR.CRGroupInfo) return r.res.groupInfo + return null + } + suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactInfo(contactId)) if (r is API.Result && r.res is CR.ContactInfo) return r.res.connectionStats_ to r.res.customUserProfile @@ -1552,9 +1566,9 @@ object ChatController { return null } - suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData): Chat? { + suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, groupShortLinkData)) + val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData)) if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}") @@ -1587,9 +1601,9 @@ object ChatController { return null } - suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): GroupInfo? { + suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): Pair>? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) - if (r is API.Result && r.res is CR.StartedConnectionToGroup) return r.res.groupInfo + if (r is API.Result && r.res is CR.StartedConnectionToGroup) return Pair(r.res.groupInfo, r.res.relayResults) if (r != null) { Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") apiConnectResponseAlert(r) @@ -2097,6 +2111,20 @@ object ChatController { return null } + suspend fun apiNewPublicGroup(rh: Long?, incognito: Boolean, relayIds: List, groupProfile: GroupProfile): Triple>? { + val userId = kotlin.runCatching { currentUserId("apiNewPublicGroup") }.getOrElse { return null } + val r = sendCmdWithRetry(rh, CC.ApiNewPublicGroup(userId, incognito, relayIds, groupProfile)) + if (r is API.Result && r.res is CR.PublicGroupCreated) return Triple(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r != null) throw Exception("${r.responseType}: ${r.details}") + return null + } + + suspend fun apiGetGroupRelays(groupId: Long): List { + val r = sendCmd(null, CC.ApiGetGroupRelays(groupId)) + if (r is API.Result && r.res is CR.GroupRelays) return r.res.groupRelays + return emptyList() + } + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member @@ -2812,6 +2840,7 @@ object ChatController { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") @@ -2926,6 +2955,7 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) } if ( chatModel.chatId.value == r.groupInfo.id @@ -2961,6 +2991,23 @@ object ChatController { chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } + is CR.GroupLinkDataUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + val relaysModel = ChannelRelaysModel + if (relaysModel.groupId.value == r.groupInfo.groupId) { + relaysModel.set(r.groupInfo.groupId, r.groupRelays) + } + } + } + is CR.GroupRelayUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + ChannelRelaysModel.updateRelay(r.groupInfo, r.groupRelay) + } + } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -3559,7 +3606,7 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3575,8 +3622,10 @@ sealed class CC { class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() + class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List, val groupProfile: GroupProfile): CC() + class ApiGetGroupRelays(val groupId: Long): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() @@ -3596,6 +3645,7 @@ sealed class CC { class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APIAcceptMemberContact(val contactId: Long): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class APITestChatRelay(val userId: Long, val address: String): CC() class ApiGetServerOperators(): CC() class ApiSetServerOperators(val operators: List): CC() class ApiGetUserServers(val userId: Long): CC() @@ -3614,6 +3664,7 @@ sealed class CC { class ReconnectAllServers: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() + class ApiGetUpdatedGroupLinkData(val groupId: Long): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APIContactQueueInfo(val contactId: Long): CC() @@ -3633,7 +3684,7 @@ sealed class CC { class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() class APIConnectPlan(val userId: Long, val connLink: String): CC() class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC() - class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val groupShortLinkData: GroupShortLinkData): CC() + class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC() class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC() @@ -3747,7 +3798,7 @@ sealed class CC { is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" @@ -3768,12 +3819,14 @@ sealed class CC { is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) " as_group=on" else ""} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } is ApiPlanForwardChatItems -> { "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" + is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}" + is ApiGetGroupRelays -> "/_get relays #$groupId" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" @@ -3793,6 +3846,7 @@ sealed class CC { is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APIAcceptMemberContact -> "/_accept member contact @$contactId" is APITestProtoServer -> "/_server test $userId $server" + is APITestChatRelay -> "/_relay test $userId $address" is ApiGetServerOperators -> "/_operators" is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" is ApiGetUserServers -> "/_servers $userId" @@ -3811,6 +3865,7 @@ sealed class CC { is ReconnectAllServers -> "/reconnect" is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" + is ApiGetUpdatedGroupLinkData -> "/_get group link data #$groupId" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APIContactQueueInfo -> "/_queue info @$contactId" @@ -3830,7 +3885,7 @@ sealed class CC { is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" is APIConnectPlan -> "/_connect plan $userId $connLink" is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}" - is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(groupShortLinkData)}" + is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}" @@ -3949,6 +4004,8 @@ sealed class CC { is ApiForwardChatItems -> "apiForwardChatItems" is ApiPlanForwardChatItems -> "apiPlanForwardChatItems" is ApiNewGroup -> "apiNewGroup" + is ApiNewPublicGroup -> "apiNewPublicGroup" + is ApiGetGroupRelays -> "apiGetGroupRelays" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiAcceptMember -> "apiAcceptMember" @@ -3968,6 +4025,7 @@ sealed class CC { is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APIAcceptMemberContact -> "apiAcceptMemberContact" is APITestProtoServer -> "testProtoServer" + is APITestChatRelay -> "apiTestChatRelay" is ApiGetServerOperators -> "apiGetServerOperators" is ApiSetServerOperators -> "apiSetServerOperators" is ApiGetUserServers -> "apiGetUserServers" @@ -3986,6 +4044,7 @@ sealed class CC { is ReconnectAllServers -> "reconnectAllServers" is APISetChatSettings -> "apiSetChatSettings" is ApiSetMemberSettings -> "apiSetMemberSettings" + is ApiGetUpdatedGroupLinkData -> "apiGetUpdatedGroupLinkData" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is APIContactQueueInfo -> "apiContactQueueInfo" @@ -4120,7 +4179,8 @@ fun onOff(b: Boolean): String = if (b) "on" else "off" @Serializable data class NewUser( val profile: Profile?, - val pastTimestamp: Boolean + val pastTimestamp: Boolean, + val userChatRelay: Boolean = false ) sealed class ChatPagination { @@ -4373,7 +4433,8 @@ data class ServerRoles( data class UserOperatorServers( val operator: ServerOperator?, val smpServers: List, - val xftpServers: List + val xftpServers: List, + val chatRelays: List = emptyList() ) { val id: String get() = operator?.operatorId?.toString() ?: "nil operator" @@ -4412,19 +4473,22 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? get() = when (this.protocol_) { ServerProtocol.SMP -> globalSMPError ServerProtocol.XFTP -> globalXFTPError + null -> null } - private val protocol_: ServerProtocol + private val protocol_: ServerProtocol? get() = when (this) { is NoServers -> this.protocol is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is DuplicateChatRelayAddress -> null } val globalSMPError: String? @@ -4468,6 +4532,34 @@ sealed class UserServersError { } } +@Serializable +sealed class UserServersWarning { + @Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning() + + val globalWarning: String? + get() = when (this) { + is NoChatRelays -> { + val text = generalGetString(MR.strings.no_chat_relays_enabled) + if (user != null) { + String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text + } else text + } + } +} + +@Serializable +data class RelayConnectionResult( + val relayMember: GroupMember, + val relayError: ChatError? = null +) + +@Serializable +data class GroupShortLinkInfo( + val direct: Boolean, + val groupRelays: List, + val publicGroupId: String? = null +) + @Serializable data class UserServer( val remoteHostId: Long?, @@ -4596,6 +4688,44 @@ data class ProtocolTestFailure( } } +@Serializable +enum class RelayTestStep { + @SerialName("getLink") GetLink, + @SerialName("decodeLink") DecodeLink, + @SerialName("connect") Connect, + @SerialName("waitResponse") WaitResponse, + @SerialName("verify") Verify; + + val text: String get() = when (this) { + GetLink -> generalGetString(MR.strings.relay_test_step_get_link) + DecodeLink -> generalGetString(MR.strings.relay_test_step_decode_link) + Connect -> generalGetString(MR.strings.relay_test_step_connect) + WaitResponse -> generalGetString(MR.strings.relay_test_step_wait_response) + Verify -> generalGetString(MR.strings.relay_test_step_verify) + } +} + +@Serializable +data class RelayTestFailure( + val rtfStep: RelayTestStep, + val rtfError: ChatError +) { + val localizedDescription: String get() { + val err = String.format(generalGetString(MR.strings.error_relay_test_failed_at_step), rtfStep.text) + return when { + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.SMP && rtfError.agentError.smpErr is SMPErrorType.AUTH -> + err + " " + generalGetString(MR.strings.error_relay_test_server_auth) + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.BROKER && rtfError.agentError.brokerErr is BrokerErrorType.NETWORK && + rtfError.agentError.brokerErr.networkError is NetworkError.UnknownCAError -> + err + " " + generalGetString(MR.strings.error_smp_test_certificate) + else -> + err + " " + String.format(generalGetString(MR.strings.error_with_info), rtfError.string) + } + } +} + @Serializable data class ServerAddress( val serverProtocol: ServerProtocol, @@ -6123,13 +6253,15 @@ sealed class CR { @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() + @Serializable @SerialName("chatRelayTestResult") class ChatRelayTestResult(val user: UserRef, val relayProfile: RelayProfile? = null, val relayTestFailure: RelayTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() - @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List, val serverWarnings: List = emptyList()): CR() @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR() + @Serializable @SerialName("groupInfo") class CRGroupInfo(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): CR() @Serializable @SerialName("queueInfo") class QueueInfoR(val user: UserRef, val rcvMsgInfo: RcvMsgInfo?, val queueInfo: ServerQueueInfo): CR() @Serializable @SerialName("contactSwitchStarted") class ContactSwitchStarted(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats): CR() @@ -6156,7 +6288,7 @@ sealed class CR { @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("startedConnectionToContact") class StartedConnectionToContact(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo, val relayResults: List = emptyList()): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @@ -6195,6 +6327,8 @@ sealed class CR { @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @@ -6217,10 +6351,12 @@ sealed class CR { @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() + @Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List, val relaysChanged: Boolean): CR() + @Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @Serializable @SerialName("groupLink") class CRGroupLink(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR() @@ -6305,6 +6441,7 @@ sealed class CR { is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" + is ChatRelayTestResult -> "chatRelayTestResult" is ServerOperatorConditions -> "serverOperatorConditions" is UserServers -> "userServers" is UserServersValidation -> "userServersValidation" @@ -6312,6 +6449,7 @@ sealed class CR { is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" + is CRGroupInfo -> "groupInfo" is GroupMemberInfo -> "groupMemberInfo" is QueueInfoR -> "queueInfo" is ContactSwitchStarted -> "contactSwitchStarted" @@ -6376,6 +6514,8 @@ sealed class CR { is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" + is PublicGroupCreated -> "publicGroupCreated" + is GroupRelays -> "groupRelays" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" @@ -6402,6 +6542,8 @@ sealed class CR { is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" is GroupUpdated -> "groupUpdated" + is GroupLinkDataUpdated -> "groupLinkDataUpdated" + is GroupRelayUpdated -> "groupRelayUpdated" is GroupLinkCreated -> "groupLinkCreated" is CRGroupLink -> "groupLink" is GroupLinkDeleted -> "groupLinkDeleted" @@ -6479,6 +6621,7 @@ sealed class CR { is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ChatRelayTestResult -> withUser(user, "relayProfile: $relayProfile\ntestFailure: $relayTestFailure") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") @@ -6486,6 +6629,7 @@ sealed class CR { is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") + is CRGroupInfo -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}") is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") is QueueInfoR -> withUser(user, "rcvMsgInfo: ${json.encodeToString(rcvMsgInfo)}\nqueueInfo: ${json.encodeToString(queueInfo)}\n") is ContactSwitchStarted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") @@ -6550,6 +6694,8 @@ sealed class CR { is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) + is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") @@ -6576,6 +6722,8 @@ sealed class CR { is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) + is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged") + is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay") is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is CRGroupLink -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) @@ -6719,7 +6867,7 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() @@ -7011,6 +7159,7 @@ sealed class ChatErrorType { is UserUnknown -> "userUnknown" is ActiveUserExists -> "activeUserExists" is UserExists -> "userExists" + is ChatRelayExists -> "chatRelayExists" is DifferentActiveUser -> "differentActiveUser" is CantDeleteActiveUser -> "cantDeleteActiveUser" is CantDeleteLastUser -> "cantDeleteLastUser" @@ -7080,6 +7229,7 @@ sealed class ChatErrorType { is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited" is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible" + is RelayTestError -> "relayTestError $message" is InternalError -> "internalError" is CEException -> "exception $message" } @@ -7091,6 +7241,7 @@ sealed class ChatErrorType { @Serializable @SerialName("userUnknown") object UserUnknown: ChatErrorType() @Serializable @SerialName("activeUserExists") object ActiveUserExists: ChatErrorType() @Serializable @SerialName("userExists") class UserExists(val contactName: String): ChatErrorType() + @Serializable @SerialName("chatRelayExists") object ChatRelayExists: ChatErrorType() @Serializable @SerialName("differentActiveUser") class DifferentActiveUser(val commandUserId: Long, val activeUserId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteActiveUser") class CantDeleteActiveUser(val userId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteLastUser") class CantDeleteLastUser(val userId: Long): ChatErrorType() @@ -7160,6 +7311,7 @@ sealed class ChatErrorType { @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() @Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType() @Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType() + @Serializable @SerialName("relayTestError") class RelayTestError(val message: String): ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() } @@ -7170,6 +7322,7 @@ sealed class StoreError { get() = when (this) { is DuplicateName -> "duplicateName" is UserNotFound -> "userNotFound $userId" + is RelayUserNotFound -> "relayUserNotFound" is UserNotFoundByName -> "userNotFoundByName $contactName" is UserNotFoundByContactId -> "userNotFoundByContactId $contactId" is UserNotFoundByGroupId -> "userNotFoundByGroupId $groupId" @@ -7193,6 +7346,7 @@ sealed class StoreError { is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound $contactId" is GroupWithoutUser -> "groupWithoutUser" is DuplicateGroupMember -> "duplicateGroupMember" + is DuplicateMemberId -> "duplicateMemberId" is GroupAlreadyJoined -> "groupAlreadyJoined" is GroupInvitationNotFound -> "groupInvitationNotFound" is NoteFolderAlreadyExists -> "noteFolderAlreadyExists $noteFolderId" @@ -7233,6 +7387,9 @@ sealed class StoreError { is HostMemberIdNotFound -> "hostMemberIdNotFound $groupId" is ContactNotFoundByFileId -> "contactNotFoundByFileId $fileId" is NoGroupSndStatus -> "noGroupSndStatus $itemId $groupMemberId" + is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" + is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" + is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" @@ -7248,6 +7405,7 @@ sealed class StoreError { @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @Serializable @SerialName("userNotFound") class UserNotFound(val userId: Long): StoreError() + @Serializable @SerialName("relayUserNotFound") object RelayUserNotFound: StoreError() @Serializable @SerialName("userNotFoundByName") class UserNotFoundByName(val contactName: String): StoreError() @Serializable @SerialName("userNotFoundByContactId") class UserNotFoundByContactId(val contactId: Long): StoreError() @Serializable @SerialName("userNotFoundByGroupId") class UserNotFoundByGroupId(val groupId: Long): StoreError() @@ -7271,6 +7429,7 @@ sealed class StoreError { @Serializable @SerialName("memberContactGroupMemberNotFound") class MemberContactGroupMemberNotFound(val contactId: Long): StoreError() @Serializable @SerialName("groupWithoutUser") object GroupWithoutUser: StoreError() @Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError() + @Serializable @SerialName("duplicateMemberId") object DuplicateMemberId: StoreError() @Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError() @Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError() @Serializable @SerialName("noteFolderAlreadyExists") class NoteFolderAlreadyExists(val noteFolderId: Long): StoreError() @@ -7311,6 +7470,9 @@ sealed class StoreError { @Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): StoreError() @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() + @Serializable @SerialName("userChatRelayNotFound") class UserChatRelayNotFound(val chatRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFound") class GroupRelayNotFound(val groupRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFoundByMemberId") class GroupRelayNotFoundByMemberId(val groupMemberId: Long): StoreError() @Serializable @SerialName("duplicateGroupMessage") class DuplicateGroupMessage(val groupId: Long, val sharedMsgId: String, val authorGroupMemberId: Long?, val forwardedByGroupMemberId: Long?): StoreError() @Serializable @SerialName("remoteHostNotFound") class RemoteHostNotFound(val remoteHostId: Long): StoreError() @Serializable @SerialName("remoteHostUnknown") object RemoteHostUnknown: StoreError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 107d427556..6562d40cec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -88,7 +88,8 @@ suspend fun processLoadedChat( val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed, + selectionActive = chatState.selectionActive ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) @@ -177,13 +178,14 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination( newItems: SnapshotStateList, newIds: Set, splits: StateFlow>, - visibleItemIndexesNonReversed: () -> IntRange + visibleItemIndexesNonReversed: () -> IntRange, + selectionActive: Boolean = false ): ModifiedSplits { var oldUnreadSplitIndex: Int = -1 var newUnreadSplitIndex: Int = -1 val visibleItemIndexes = visibleItemIndexesNonReversed() var lastSplitIndexTrimmed = -1 - var allowedTrimming = true + var allowedTrimming = !selectionActive var index = 0 /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index d98c041478..f9d32892ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -202,7 +202,8 @@ data class ActiveChatState ( // exclusive val unreadAfter: MutableStateFlow = MutableStateFlow(0), // exclusive - val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0), + @Volatile var selectionActive: Boolean = false ) { fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List) { toItemId ?: return diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index c518b156d9..117b8955a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -148,6 +148,7 @@ fun ChatView( val showCommandsMenu = rememberSaveable { mutableStateOf(false) } val contentFilter = rememberSaveable { mutableStateOf(null) } val availableContent = remember { mutableStateOf>(ContentFilter.initialList) } + val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null if (appPlatform.isAndroid) { DisposableEffect(Unit) { @@ -178,6 +179,7 @@ fun ChatView( contentFilter.value = null availableContent.value = ContentFilter.initialList selectedChatItems.value = null + selectionManager?.clearSelection() val cInfo = activeChat.value?.chatInfo if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) { updateAvailableContent(chatRh, activeChat, availableContent) @@ -201,6 +203,24 @@ fun ChatView( chatModel.chatSubStatus.value = null } } + if (cInfo is ChatInfo.Group && cInfo.groupInfo.useRelays) { + withBGApi { + setGroupMembers(chatRh, cInfo.groupInfo, chatModel) + if (cInfo.groupInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = chatModel.controller.apiGetGroupRelays(cInfo.groupInfo.groupId) + withContext(Dispatchers.Main) { + ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays) + } + } else { + val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId) + if (gInfo != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(chatRh, gInfo) + } + } + } + } + } } } } @@ -227,6 +247,7 @@ fun ChatView( val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalSelectionManager provides selectionManager, ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -358,6 +379,7 @@ fun ChatView( chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() chatModel.membersLoaded.value = false + ChannelRelaysModel.reset() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -488,7 +510,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) } } } @@ -753,7 +775,9 @@ fun ChatView( changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, closeSearch = { - onSearchValueChanged("") + if (chatModel.openAroundItemId.value == null) { + onSearchValueChanged("") + } showSearch.value = false searchText.value = "" contentFilter.value = null @@ -808,16 +832,14 @@ fun ChatView( fun updateAvailableContent(chatRh: Long?, activeChat: State, availableContent: MutableState>) { withBGApi { - Log.e(TAG, "updateAvailableContent") val chatInfo = activeChat.value?.chatInfo - if (chatInfo == null) return@withBGApi + if (chatInfo == null || chatInfo !is ChatInfo.Direct && chatInfo !is ChatInfo.Group && chatInfo !is ChatInfo.Local) return@withBGApi val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null) if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi if (types == null) { availableContent.value = ContentFilter.entries } else { val typeSet: Set = types.union(ContentFilter.alwaysShow) - Log.e(TAG, "updateAvailableContent $typeSet") availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) } } } @@ -842,10 +864,14 @@ private fun connectingText(chatInfo: ChatInfo): String? { } is ChatInfo.Group -> - when (chatInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null - GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) - else -> null + if (chatInfo.groupInfo.useRelays) { + null + } else { + when (chatInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null + GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) + else -> null + } } else -> null @@ -960,17 +986,26 @@ fun ChatLayout( val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { + val selectionManager = LocalSelectionManager.current + if (selectionManager != null) { + LaunchedEffect(selectionManager) { + snapshotFlow { selectionManager.selectionState != SelectionState.Idle } + .collect { chatsCtx.chatState.selectionActive = it } + } + } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble - CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { - override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f - }) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f + } + ) { ChatItemsList( chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, + setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, contentFilter, ) } if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { @@ -993,6 +1028,13 @@ fun ChatLayout( CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu) } } + // Copy button inside TopStart-aligned wrapper — above messages, + // behind compose (ABPL paints compose after) and toolbars (outer Box paints after ABPL) + if (appPlatform.isDesktop) { + Box(Modifier.matchParentSize()) { + SelectionCopyButton() + } + } } } if (chatsCtx.contentTag == MsgContentTag.Report) { @@ -1145,6 +1187,7 @@ fun BoxScope.ChatInfoToolbar( val scope = rememberCoroutineScope() val showMenu = rememberSaveable { mutableStateOf(false) } val showContentFilterMenu = rememberSaveable { mutableStateOf(false) } + val showCallMenu = rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch.value) { @@ -1163,37 +1206,11 @@ fun BoxScope.ChatInfoToolbar( val activeCall by remember { chatModel.activeCall } val showContentFilterButton = availableContent.value.isNotEmpty() - val activeCallInChat = chatInfo is ChatInfo.Direct && activeCall?.contact?.id == chatInfo.id - - // Content filter button - shown in bar, or moved to menu during active call - if (showContentFilterButton) { - val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready - if (activeCallInChat) { - menuItems.add { - ItemAction( - stringResource(MR.strings.content_filter_menu_item), - painterResource(MR.images.ic_photo_library), - onClick = { - showMenu.value = false - showContentFilterMenu.value = true - } - ) - } - } else { - barButtons.add { - IconButton( - { showContentFilterMenu.value = true }, - enabled = enabled - ) { - Icon( - painterResource(MR.images.ic_photo_library), - null, - tint = MaterialTheme.colors.primary - ) - } - } - } - } + val canStartCall = chatInfo is ChatInfo.Direct && + chatInfo.contact.mergedPreferences.calls.enabled.forUser && + chatInfo.contact.ready && + chatInfo.contact.active && + activeCall == null // Chat-type specific buttons when (chatInfo) { @@ -1245,19 +1262,12 @@ fun BoxScope.ChatInfoToolbar( } } } - // Call buttons moved to menu - if (chatInfo.contact.mergedPreferences.calls.enabled.forUser && chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) { - menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { - showMenu.value = false - startCall(CallMediaType.Audio) - }) - } - menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { - showMenu.value = false - startCall(CallMediaType.Video) - }) + // Call button always in toolbar; tap opens Audio/Video call submenu + if (canStartCall) { + barButtons.add(0) { + IconButton({ showCallMenu.value = true }) { + Icon(painterResource(MR.images.ic_call_500), null, tint = MaterialTheme.colors.primary) + } } } menuItems.add { @@ -1270,7 +1280,7 @@ fun BoxScope.ChatInfoToolbar( is ChatInfo.Group -> { // Add members / group link moved to menu if (chatInfo.groupInfo.canAddMembers) { - if (!chatInfo.incognito) { + if (!chatInfo.incognito && !chatInfo.groupInfo.useRelays) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = { showMenu.value = false @@ -1279,10 +1289,14 @@ fun BoxScope.ChatInfoToolbar( } } else { menuItems.add { - ItemAction(stringResource(MR.strings.group_link), painterResource(MR.images.ic_add_link), onClick = { - showMenu.value = false - openGroupLink(chatInfo.groupInfo) - }) + ItemAction( + stringResource(if (chatInfo.groupInfo.useRelays) MR.strings.channel_link else MR.strings.group_link), + painterResource(if (chatInfo.groupInfo.useRelays) MR.images.ic_link else MR.images.ic_add_link), + onClick = { + showMenu.value = false + openGroupLink(chatInfo.groupInfo) + } + ) } } } @@ -1296,6 +1310,26 @@ fun BoxScope.ChatInfoToolbar( else -> {} } + // Content filter button: always in bar on desktop and for groups; on Android for direct chats it + // goes into the three-dots menu UNLESS calls are unavailable, in which case it appears in the bar. + // Must be after chat-type buttons so call buttons appear before filter during active call. + if (showContentFilterButton && (appPlatform.isDesktop || chatInfo is ChatInfo.Group || + (appPlatform.isAndroid && chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null))) { + val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready + barButtons.add { + IconButton( + { showContentFilterMenu.value = true }, + enabled = enabled + ) { + Icon( + painterResource(MR.images.ic_photo_library), + null, + tint = MaterialTheme.colors.primary + ) + } + } + } + val enableNtfs = chatInfo.chatSettings?.enableNtfs if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { val ntfMode = remember { mutableStateOf(enableNtfs) } @@ -1316,6 +1350,53 @@ fun BoxScope.ChatInfoToolbar( } } + // Android only: for direct/local chats where the filter bar button is NOT shown, filter options go in the three-dots menu separated by a divider + if (appPlatform.isAndroid && chatInfo !is ChatInfo.Group && showContentFilterButton && + !(chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null)) { + menuItems.add { Divider() } + availableContent.value.forEach { filter -> + menuItems.add { + val isSelected = contentFilter.value == filter + ItemAction( + stringResource(filter.label), + painterResource(if (isSelected) filter.iconFilled else filter.icon), + color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + showMenu.value = false + if (contentFilter.value == filter) return@ItemAction + contentFilter.value = filter + showSearch.value = true + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, filter.contentTag, "") + } + } + } + ) + } + } + if (showSearch.value) { + menuItems.add { + ItemAction( + stringResource(MR.strings.content_filter_all_messages), + painterResource(MR.images.ic_forum), + onClick = { + showMenu.value = false + contentFilter.value = null + showSearch.value = false + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, null, "") + } + } + } + ) + } + } + } + if (menuItems.isNotEmpty()) { barButtons.add { IconButton({ showMenu.value = true }) { @@ -1425,9 +1506,45 @@ fun BoxScope.ChatInfoToolbar( contentFilterMenuItems.forEach { it() } } } + val callMenuWidth = remember { mutableStateOf(250.dp) } + val callMenuHeight = remember { mutableStateOf(0.dp) } + DefaultDropdownMenu( + showCallMenu, + modifier = Modifier.onSizeChanged { with(density) { + callMenuWidth.value = it.width.toDp().coerceAtLeast(250.dp) + if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) callMenuHeight.value = it.height.toDp() + } }, + offset = DpOffset(-callMenuWidth.value, if (oneHandUI.value && chatBottomBar.value) -callMenuHeight.value else AppBarHeight) + ) { + if (chatInfo is ChatInfo.Direct) { + val callMenuItems: List<@Composable () -> Unit> = buildList { + add { + ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Audio) + }) + } + add { + ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Video) + }) + } + } + if (oneHandUI.value && chatBottomBar.value) { + callMenuItems.asReversed().forEach { it() } + } else { + callMenuItems.forEach { it() } + } + } + } } } +fun subscriberCountStr(count: Long): String = + if (count == 1L) String.format(generalGetString(MR.strings.channel_subscriber_count_singular), count) + else String.format(generalGetString(MR.strings.channel_subscriber_count_plural), count) + @Composable fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { Row( @@ -1457,6 +1574,17 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo maxLines = 1, overflow = TextOverflow.Ellipsis ) } + val channelSubscriberCount = (cInfo as? ChatInfo.Group)?.let { g -> + if (g.groupInfo.useRelays) g.groupInfo.groupSummary.publicMemberCount?.takeIf { it > 0 } else null + } + if (channelSubscriberCount != null) { + Text( + subscriberCountStr(channelSubscriberCount), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } } val chatSubStatus = chatModel.chatSubStatus.value if ( @@ -1629,7 +1757,8 @@ fun BoxScope.ChatItemsList( closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, - showViaProxy: Boolean + showViaProxy: Boolean, + contentFilter: State = remember { mutableStateOf(null) } ) { val chatInfo = chat.chatInfo val loadingTopItems = remember { mutableStateOf(false) } @@ -1649,7 +1778,7 @@ fun BoxScope.ChatItemsList( } } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } - val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } + val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() || contentFilter.value != null } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state val mergedItems = remember { @@ -1684,7 +1813,17 @@ fun BoxScope.ChatItemsList( val hoveredItemId = remember { mutableStateOf(null as Long?) } val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { val openAroundItemId = chatModel.openAroundItemId.value - val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: run { + // scroll to first unread after last viewed item (items reversed: 0 = newest) + val viewedIdx = mergedItems.value.items.indexOfFirst { !it.hasUnread() } + if (viewedIdx > 0) { + viewedIdx - 1 + } else if (viewedIdx < 0) { + mergedItems.value.items.indexOfLast { it.hasUnread() } + } else { + 0 // viewed is bottom item, scroll to bottom + } + } val reportsState = reportsListState if (openAroundItemId != null) { highlightedItems.value += openAroundItemId @@ -1727,7 +1866,7 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + scrollToItem(chatsCtx, remoteHostIdUpdated, searchValue, contentFilter, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, scrollToItemId) } if (chatsCtx.secondaryContextFilter == null) { @@ -1780,7 +1919,7 @@ fun BoxScope.ChatItemsList( } @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) { tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { @@ -1794,7 +1933,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp, swipeOffset = swipeOffset) } } @@ -1814,7 +1953,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = SwipeToDismissModifier( + val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, @@ -1915,7 +2054,7 @@ fun BoxScope.ChatItemsList( MemberImage(member) } Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { - ChatItemViewShortHand(cItem, itemSeparation, range, false) + ChatItemViewShortHand(cItem, itemSeparation, range, false, dismissState.offset.value) } } } @@ -1929,6 +2068,89 @@ fun BoxScope.ChatItemsList( Item() } } + } else { + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Row( + Modifier + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(swipeableOrSelectionModifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) + } + } + } + } else if (cItem.chatDir is CIDirection.ChannelRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun ChannelNameAndRole() { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + Text( + chatInfo.groupInfo.chatViewName, + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + Text( + generalGetString(MR.strings.channel_role_label), + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + Box(Modifier.clickable { showChatInfo() }) { + ProfileImage( + MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, + chatInfo.groupInfo.image, + chatInfo.groupInfo.chatIconName, + backgroundColor = MaterialTheme.colors.background + ) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } + } + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + ChannelNameAndRole() + Item() + } + } else { + Item() + } + } } else { ChatItemBox { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { @@ -1955,7 +2177,7 @@ fun BoxScope.ChatItemsList( .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(if (selectionVisible) Modifier else swipeableModifier) ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) } } } @@ -1973,7 +2195,7 @@ fun BoxScope.ChatItemsList( .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) } } } @@ -2017,13 +2239,14 @@ fun BoxScope.ChatItemsList( val groupInfo = chatInfo.groupInfo when (groupInfo.businessChat?.chatType) { null -> { + val isChannel = groupInfo.useRelays if (groupInfo.nextConnectPrepared) { - generalGetString(MR.strings.chat_banner_join_group) + generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) } else { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> generalGetString(MR.strings.chat_banner_join_group) - GroupMemberStatus.MemCreator -> generalGetString(MR.strings.chat_banner_your_group) - else -> generalGetString(MR.strings.chat_banner_group) + GroupMemberStatus.MemInvited -> generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) + GroupMemberStatus.MemCreator -> generalGetString(if (isChannel) MR.strings.chat_banner_your_channel else MR.strings.chat_banner_your_group) + else -> generalGetString(if (isChannel) MR.strings.chat_banner_channel else MR.strings.chat_banner_group) } } } @@ -2115,8 +2338,11 @@ fun BoxScope.ChatItemsList( } } + val manager = LocalSelectionManager.current + val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, linkMode) else Modifier + LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), + modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -2170,8 +2396,10 @@ fun BoxScope.ChatItemsList( itemSeparation = getItemSeparation(item, null) prevItemSeparationLargeGap = false } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } } if (last != null) { @@ -2867,7 +3095,10 @@ private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, de } private fun scrollToItem( + chatsCtx: ChatModel.ChatsContext, + remoteHostId: State, searchValue: State, + contentFilter: State, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, highlightedItems: MutableState>, @@ -2882,8 +3113,13 @@ private fun scrollToItem( withApi { try { var index = mergedItems.value.indexInParentItems[itemId] ?: -1 - // Don't try to load messages while in search - if (index == -1 && searchValue.value.isNotBlank()) return@withApi + if (index == -1 && (searchValue.value.isNotBlank() || contentFilter.value != null)) { + val ci = chatInfo.value + apiLoadMessages(chatsCtx, remoteHostId.value, ci.chatType, ci.apiId, + ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2), + openAroundItemId = itemId) + return@withApi + } // setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to // it will trigger loading more items and will scroll to incorrect position (because of trimming) loadingMoreItems.value = true @@ -2968,7 +3204,7 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: ( val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) close?.invoke() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null) + GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays) } } } @@ -3477,6 +3713,8 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa val sameMemberAndDirection = if (prevItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == prevItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && prevItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == prevItem.chatDir.sent val largeGap = !sameMemberAndDirection || (abs(prevItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) @@ -3494,12 +3732,26 @@ private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): val sameMemberAndDirection = if (nextItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == nextItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && nextItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == nextItem.chatDir.sent return !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) } -private fun shouldShowAvatar(current: ChatItem, older: ChatItem?) = - current.chatDir is CIDirection.GroupRcv && (older == null || (older.chatDir !is CIDirection.GroupRcv || older.chatDir.groupMember.memberId != current.chatDir.groupMember.memberId)) +private fun shouldShowAvatar(current: ChatItem, older: ChatItem?): Boolean { + val oldIsGroupRcv = older?.chatDir is CIDirection.GroupRcv || older?.chatDir is CIDirection.ChannelRcv + val sameMember = when { + older?.chatDir is CIDirection.GroupRcv && current.chatDir is CIDirection.GroupRcv -> + older.chatDir.groupMember.memberId == current.chatDir.groupMember.memberId + older?.chatDir is CIDirection.ChannelRcv && current.chatDir is CIDirection.ChannelRcv -> true + else -> false + } + return when { + current.chatDir is CIDirection.GroupRcv -> older == null || !oldIsGroupRcv || !sameMember + current.chatDir is CIDirection.ChannelRcv -> older == null || !oldIsGroupRcv || !sameMember + else -> false + } +} @Preview/*( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index eebf4a7bf8..10f426b152 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -30,8 +30,13 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.common.views.chat.group.relayConnStatus import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.RelayProgressIndicator +import chat.simplex.common.views.newchat.RelayStatusIndicator +import chat.simplex.common.views.newchat.relayDisplayName import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* @@ -490,6 +495,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, scope = cInfo.groupChatScope(), + sendAsGroup = (cInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -588,15 +594,19 @@ fun ComposeView( val mc = checkLinkPreview() sending() val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() - val groupInfo = chatModel.controller.apiConnectPreparedGroup( + val result = chatModel.controller.apiConnectPreparedGroup( rh = chat.remoteHostId, groupId = chat.chatInfo.apiId, incognito = incognito, msg = mc ) - if (groupInfo != null) { + if (result != null) { + val (groupInfo, relayResults) = result withContext(Dispatchers.Main) { chatsCtx.updateGroup(chat.remoteHostId, groupInfo) + chatModel.channelRelayHostnames.remove(groupInfo.groupId) + chatModel.groupMembers.value = relayResults.map { it.relayMember } + chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -616,6 +626,7 @@ fun ComposeView( toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, toScope = chat.chatInfo.groupChatScope(), + sendAsGroup = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, fromScope = fromChatInfo.groupChatScope(), @@ -1353,7 +1364,7 @@ fun ComposeView( icon: ImageResource, connect: () -> Unit ) { - var modifier = Modifier.height(60.dp).fillMaxWidth() + var modifier = Modifier.height(57.dp).fillMaxWidth() modifier = if (composeState.value.inProgress) modifier else modifier.clickable(onClick = { connect() }) Box( modifier, @@ -1374,7 +1385,7 @@ fun ComposeView( color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - if (composeState.value.progressByTimeout) { + if (composeState.value.progressByTimeout && chat.chatInfo.groupInfo_?.useRelays != true) { Box( Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING_HALF), contentAlignment = Alignment.CenterEnd @@ -1442,9 +1453,11 @@ fun ComposeView( composeState.value = composeState.value.copy(progressByTimeout = newProgressByTimeout) } + val relayListExpanded = remember { mutableStateOf(false) } + Column { val currentUser = chatModel.currentUser.value - if (chat.chatInfo.nextConnectPrepared && currentUser != null) { + if (chat.chatInfo.nextConnectPrepared && !composeState.value.inProgress && currentUser != null) { ComposeContextProfilePickerView( rhId = rhId, chat = chat, @@ -1452,6 +1465,35 @@ fun ComposeView( ) } + val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo + if (gInfo != null && gInfo.useRelays + && gInfo.membership.memberStatus !in listOf(GroupMemberStatus.MemRejected, GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) + ) { + if (gInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList() + val failedCount = relays.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = relays.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + if (relays.isNotEmpty() && activeCount < relays.size) { + OwnerChannelRelayBar(chatModel, relays, activeCount, failedCount, relayListExpanded) + } + } else { + val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted() + val relayMembers = chatModel.groupMembers.value + .filter { it.memberRole == GroupMemberRole.Relay } + .sortedBy { hostFromRelayLink(it.relayLink ?: "") } + val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress + val connectedCount = relayMembers.count { it.activeConn?.connStatus == ConnStatus.Ready } + val deletedCount = relayMembers.count { it.activeConn?.connStatus == ConnStatus.Deleted } + val failedCount = relayMembers.count { it.activeConn?.connFailedErr != null } + val errorCount = deletedCount + failedCount + val resolvedCount = connectedCount + deletedCount + val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size + if (total > 0 && (!showProgress || resolvedCount < total)) { + SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, errorCount, total, showProgress, relayListExpanded) + } + } + } + if ( chat.chatInfo is ChatInfo.Group && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext @@ -1506,9 +1548,10 @@ fun ComposeView( Divider() if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { if (chat.chatInfo.groupInfo.businessChat == null) { + val isChannel = chat.chatInfo.groupInfo.useRelays ConnectButtonView( - text = stringResource(MR.strings.compose_view_join_group), - icon = MR.images.ic_group_filled, + text = stringResource(if (isChannel) MR.strings.compose_view_join_channel else MR.strings.compose_view_join_group), + icon = if (isChannel) MR.images.ic_bigtop_updates else MR.images.ic_group_filled, connect = { withApi { connectPreparedGroup() } } ) } else { @@ -1579,9 +1622,187 @@ fun ComposeView( } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { AttachmentAndCommandsButtons() - SendMsgView_(disableSendButton = disableSendButton) + val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> + if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner) generalGetString(MR.strings.compose_view_broadcast) + else null + } + SendMsgView_(disableSendButton = disableSendButton, placeholder = broadcastPlaceholder) } } } } } + +@Composable +private fun OwnerChannelRelayBar( + chatModel: ChatModel, + relays: List, + activeCount: Int, + failedCount: Int, + relayListExpanded: MutableState +) { + val total = relays.size + val sorted = relays.sortedBy { relayDisplayName(it) } + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f), color = MaterialTheme.colors.secondary) + } + if (relayListExpanded.value) { + sorted.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + relayDisplayName(relay), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + RelayStatusIndicator(relay.relayStatus, connFailed = failedErr != null) + } + } + } + } +} + +@Composable +private fun SubscriberChannelRelayBar( + hostnames: List, + relayMembers: List, + connectedCount: Int, + errorCount: Int, + total: Int, + showProgress: Boolean, + relayListExpanded: MutableState +) { + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (showProgress && connectedCount + errorCount < total) { + RelayProgressIndicator(active = connectedCount, total = total) + } + val statusText = if (showProgress) { + if (errorCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_connected_with_errors), connectedCount, total, errorCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_connected), connectedCount, total) + } + } else { + String.format(generalGetString(MR.strings.relay_bar_count), total) + } + Text(statusText, modifier = Modifier.weight(1f), color = MaterialTheme.colors.secondary) + } + if (relayListExpanded.value) { + if (relayMembers.isEmpty()) { + hostnames.forEach { relay -> + RelayBarDetailRow { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relay)), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + } + } + } else { + relayMembers.forEach { m -> + val host = m.relayLink?.let { hostFromRelayLink(it) } + val failedErr = m.activeConn?.connFailedErr + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), host ?: m.chatViewName), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + val (statusText, statusColor) = relayConnStatus(m) + androidx.compose.foundation.Canvas(Modifier.size(8.dp)) { + drawCircle(color = statusColor) + } + Spacer(Modifier.width(4.dp)) + Text(statusText, color = MaterialTheme.colors.secondary, fontSize = 12.sp) + if (failedErr != null) { + Spacer(Modifier.width(4.dp)) + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } +} + +@Composable +private fun RelayBarHeader( + expanded: MutableState, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded.value = !expanded.value } + .padding(start = 12.dp, end = DEFAULT_PADDING_HALF, top = 8.dp, bottom = if (expanded.value) 4.dp else 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + content() + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_down else MR.images.ic_chevron_up), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +private fun RelayBarDetailRow( + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit +) { + val modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + Row( + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 4de0175457..9184071c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,7 +24,6 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource @@ -313,21 +312,19 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex LockToCurrentOrientationUntilDispose() StopRecordButton(stopRecordingAndAddAudio) } else { - val startRecording: () -> Unit = out@ { - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() + val startRecording: () -> Unit = { + val filePath = rec.start { progress: Int?, finished: Boolean -> + val state = recState.value + if (state is RecordingState.Started && progress != null) { + recState.value = if (!finished) + RecordingState.Started(state.filePath, progress) + else + RecordingState.Finished(state.filePath, progress) + } + } + if (filePath.isNotEmpty()) { + recState.value = RecordingState.Started(filePath = filePath) } - recState.value = RecordingState.Started( - filePath = rec.start { progress: Int?, finished: Boolean -> - val state = recState.value - if (state is RecordingState.Started && progress != null) { - recState.value = if (!finished) - RecordingState.Started(state.filePath, progress) - else - RecordingState.Finished(state.filePath, progress) - } - }, - ) } val interactionSource = interactionSourceWithTapDetection( onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt new file mode 100644 index 0000000000..4447bf9da2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -0,0 +1,520 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +val SelectionHighlightColor = Color(0x4D0066FF) + +data class ItemContext( + val selectionIndex: Int = -1 +) + +val LocalItemContext = compositionLocalOf { ItemContext() } + +data class SelectionRange( + val startIndex: Int, + val startOffset: Int, + val endIndex: Int, + val endOffset: Int +) + +enum class SelectionState { Idle, Selecting, Selected } + +class SelectionManager { + var selectionState by mutableStateOf(SelectionState.Idle) + private set + + var range by mutableStateOf(null) + private set + + var anchorWindowY by mutableStateOf(0f) + private set + var anchorWindowX by mutableStateOf(0f) + private set + var focusWindowY by mutableStateOf(0f) + var focusWindowX by mutableStateOf(0f) + var viewportWidth by mutableStateOf(0f) + var viewportHeight by mutableStateOf(0f) + var viewportTop by mutableStateOf(0f) + var viewportBottom by mutableStateOf(0f) + var viewportPosition by mutableStateOf(Offset.Zero) + var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item + var listState: State? = null + var onCopySelection: (() -> Unit)? = null + private var autoScrollJob: Job? = null + + fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) { + range = SelectionRange(startIndex, -1, startIndex, -1) + selectionState = SelectionState.Selecting + anchorWindowY = anchorY + anchorWindowX = anchorX + } + + fun setAnchorOffset(offset: Int) { + val r = range ?: return + range = r.copy(startOffset = offset) + } + + fun updateFocusIndex(index: Int) { + val r = range ?: return + range = r.copy(endIndex = index) + } + + fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) { + val r = range ?: return + range = r.copy(endOffset = offset) + focusCharRect = charRect + } + + fun endSelection() { + autoScrollJob?.cancel() + autoScrollJob = null + selectionState = SelectionState.Selected + } + + // Snaps boundary offsets to include full transformed segments (mentions, links with showText). + fun snapSelection(items: List, linkMode: SimplexLinkMode) { + val r = range ?: return + val startCi = items.getOrNull(r.startIndex)?.newest()?.item + val endCi = items.getOrNull(r.endIndex)?.newest()?.item + // expandRight: snap in the direction that grows the selection + val startExpandRight = if (r.startIndex == r.endIndex) r.startOffset > r.endOffset else r.startIndex < r.endIndex + val endExpandRight = if (r.startIndex == r.endIndex) r.endOffset > r.startOffset else r.endIndex < r.startIndex + val snappedStart = if (startCi != null && r.startOffset >= 0) + snapOffset(startCi, r.startOffset, linkMode, expandRight = startExpandRight) + else r.startOffset + val snappedEnd = if (endCi != null && r.endOffset >= 0) + snapOffset(endCi, r.endOffset, linkMode, expandRight = endExpandRight) + else r.endOffset + if (snappedStart != r.startOffset || snappedEnd != r.endOffset) { + range = r.copy(startOffset = snappedStart, endOffset = snappedEnd) + } + } + + fun clearSelection() { + range = null + selectionState = SelectionState.Idle + } + + // Computes copy button position relative to the viewport (called during layout phase). + // Dragging down: button below focus char (top-left at char's bottom-right corner). + // Dragging up: button above focus char (bottom-right at char's top-left corner). + // focusCharRect X is absolute window coords, Y is relative to item. + fun copyButtonOffset(draggingDown: Boolean, gap: Float, buttonSize: IntSize): IntOffset { + val r = range ?: return IntOffset.Zero + val ls = listState?.value ?: return IntOffset.Zero + val itemInfo = ls.layoutInfo.visibleItemsInfo.find { it.index == r.endIndex } + ?: return IntOffset(-10000, -10000) // focus item scrolled off screen + // Item top in viewport coords (reversed layout: viewportEnd - offset - size) + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + val cr = focusCharRect + val vp = viewportPosition + // Convert from window coords to viewport-relative + val charX = (if (draggingDown) cr.right else cr.left) - vp.x + val charY = itemWindowY + (if (draggingDown) cr.bottom else cr.top) - vp.y + // Anchor button corner at char corner with gap + val x = if (draggingDown) charX else (charX - buttonSize.width).coerceAtLeast(0f) + val y = if (draggingDown) charY + gap else charY - buttonSize.height - gap + val clampedX = x.coerceIn(0f, (viewportWidth - buttonSize.width).coerceAtLeast(0f)) + return IntOffset(clampedX.toInt(), y.toInt()) + } + + fun startDragSelection(localStart: Offset, windowStart: Offset, focusRequester: FocusRequester) { + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localStart.y) ?: return + startSelection(idx, windowStart.y, windowStart.x) + focusWindowY = windowStart.y + focusWindowX = windowStart.x + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + + fun updateDragFocus(windowPos: Offset, localY: Float) { + focusWindowY = windowPos.y + focusWindowX = windowPos.x + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localY) ?: return + updateFocusIndex(idx) + } + + fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) { + val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop + if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) { + autoScrollJob?.cancel() + autoScrollJob = null + return + } + if (autoScrollJob?.isActive == true) return + val ls = listState ?: return + autoScrollJob = scope.launch { + while (isActive && selectionState == SelectionState.Selecting) { + val curEdge = if (draggingDown) viewportBottom - focusWindowY else focusWindowY - viewportTop + if (curEdge >= AUTO_SCROLL_ZONE_PX) break + val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) + val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction + ls.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } + } + + fun getSelectedCopiedText(items: List, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + val sel = selectedRange(range, idx) ?: return@mapNotNull null + selectedItemCopiedText(ci, sel, linkMode) + }.reversed().joinToString("\n") + } +} + +// Returns the character range selected within a given item. +// Offsets are cursor positions (between characters), so the selected characters +// are those between min and max cursors: range is min..(max - 1). +// In reversed layout: higher index = higher on screen. +// startIndex/startOffset = anchor, endIndex/endOffset = focus. +fun selectedRange(range: SelectionRange?, index: Int): IntRange? { + val r = range ?: return null + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + if (index < lo || index > hi) return null + return when { + // Single-item selection: characters between the two cursor positions + index == r.startIndex && index == r.endIndex -> + if (r.startOffset < 0 || r.endOffset < 0 || r.startOffset == r.endOffset) null + else minOf(r.startOffset, r.endOffset) .. (maxOf(r.startOffset, r.endOffset) - 1) + // Anchor item in multi-item selection: from cursor to end, or from start to cursor + index == r.startIndex -> + if (r.startOffset < 0) null + else if (r.startIndex > r.endIndex) r.startOffset until Int.MAX_VALUE + else 0 until r.startOffset + // Focus item in multi-item selection: symmetric to anchor + index == r.endIndex -> + if (r.endOffset < 0) null + else if (r.endIndex < r.startIndex) 0 until r.endOffset + else r.endOffset until Int.MAX_VALUE + // Interior items: fully selected + else -> 0 until Int.MAX_VALUE + } +} + +// Extracts source text for the selected range within one item. +// Selection offsets are in display-text space. For transformed segments (mentions, links with showText), +// the full source is emitted if any part is selected. For untransformed segments, partial substring works. +private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text.substring( + sel.first.coerceAtMost(ci.text.length), + (sel.last + 1).coerceAtMost(ci.text.length) + ) + val sb = StringBuilder() + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + val overlapStart = maxOf(displayOffset, sel.first) + val overlapEnd = minOf(displayEnd, sel.last + 1) + if (overlapStart < overlapEnd) { + if (ft.text.length == segDisplay.length) { + sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + } else { + sb.append(ft.text) + } + } + displayOffset = displayEnd + } + return sb.toString() +} + +// Snaps a boundary offset to include full transformed segments. +private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { + val formattedText = ci.formattedText ?: return offset + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + return if (expandRight) displayEnd else displayOffset + } + displayOffset = displayEnd + } + return offset +} + +val LocalSelectionManager = staticCompositionLocalOf { null } + +private const val AUTO_SCROLL_ZONE_PX = 40f +private const val MIN_SCROLL_SPEED = 2f +private const val MAX_SCROLL_SPEED = 20f + +@Composable +fun BoxScope.SelectionHandler( + manager: SelectionManager, + listState: State, + mergedItems: State, + linkMode: SimplexLinkMode +): Modifier { + val touchSlop = LocalViewConfiguration.current.touchSlop + val clipboard = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + // Re-evaluate focus index on scroll during active drag + LaunchedEffect(manager) { + snapshotFlow { listState.value.firstVisibleItemScrollOffset } + .collect { + if (manager.selectionState == SelectionState.Selecting) { + val idx = resolveIndexAtY(listState.value, manager.focusWindowY - manager.viewportPosition.y) + if (idx != null) manager.updateFocusIndex(idx) + } + } + } + + manager.listState = listState + manager.onCopySelection = { + clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode))) + manager.clearSelection() + showToast(generalGetString(MR.strings.copied)) + } + + return Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + if (manager.selectionState == SelectionState.Selected + && (event.isCtrlPressed || event.isMetaPressed) + && event.key == Key.C + && event.type == KeyEventType.KeyDown + ) { + manager.onCopySelection?.invoke() + true + } else false + } + .onGloballyPositioned { + val pos = it.positionInWindow() + val bounds = it.boundsInWindow() + manager.viewportTop = bounds.top + manager.viewportBottom = bounds.bottom + manager.viewportWidth = bounds.right - bounds.left + manager.viewportHeight = bounds.bottom - bounds.top + manager.viewportPosition = pos + } + .pointerInput(manager) { + awaitEachGesture { + var initialEvent: PointerInputChange + // Wait for press, skip hovers + do { initialEvent = awaitPointerEvent(PointerEventPass.Initial).changes.first() } while (!initialEvent.pressed) + val localStart = initialEvent.position + val windowStart = localStart + manager.viewportPosition + if (manager.selectionState == SelectionState.Selected) initialEvent.consume() + var totalDrag = Offset.Zero + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial).changes.first() + when (manager.selectionState) { + SelectionState.Idle -> { + if (!event.pressed) return@awaitEachGesture + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + event.consume() + } + } + SelectionState.Selected -> { + if (!event.pressed) { + manager.clearSelection() + return@awaitEachGesture + } + event.consume() + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + } + } + SelectionState.Selecting -> { + if (!event.pressed) { + manager.endSelection() + manager.snapSelection(mergedItems.value.items, linkMode) + return@awaitEachGesture + } + val windowPos = event.position + manager.viewportPosition + manager.updateDragFocus(windowPos, event.position.y) + event.consume() + manager.updateAutoScroll(windowPos.y > windowStart.y, windowPos.y, scope) + } + } + } + } + } +} + +private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { + val reversedY = listState.layoutInfo.viewportEndOffset - localY + val idx = listState.layoutInfo.visibleItemsInfo.find { item -> + reversedY >= item.offset && reversedY < item.offset + item.size + }?.index + return idx +} + +class ItemSelection( + val highlightRange: IntRange?, + val positionModifier: Modifier, + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? +) + +// Sets up selection tracking for a text item: anchor/focus offset resolution, +// highlight range computation, and position/layout result capture. +@Composable +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { + val boundsState = remember { mutableStateOf(null) } + val layoutResultState = remember { mutableStateOf(null) } + + if (selectionManager != null && selectionIndex >= 0 && !isLive) { + val isAnchor = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + val bounds = boundsState.value ?: return@LaunchedEffect + val layout = layoutResultState.value ?: return@LaunchedEffect + val offset = layout.getOffsetForPosition( + Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) + ) + selectionManager.setAnchorOffset(offset) + } + + val isFocus = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } + .collect { (py, px) -> + val bounds = boundsState.value ?: return@collect + val layout = layoutResultState.value ?: return@collect + val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) + val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val ls = selectionManager.listState?.value + val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } + val charRect = if (ls != null && itemInfo != null) { + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + Rect( + left = bounds.left + charBox.left, + top = bounds.top + charBox.top - itemWindowY, + right = bounds.left + charBox.right, + bottom = bounds.top + charBox.bottom - itemWindowY + ) + } else Rect.Zero + selectionManager.updateFocusOffset(offset, charRect) + } + } + } + } + + val highlightRange = if (selectionManager != null && selectionIndex >= 0) { + remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value + } else null + + val positionModifier = if (selectionManager != null) { + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + } + } else Modifier + + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { + { layoutResultState.value = it } + } else null + + return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) +} + +// Sets up full-item selection for emoji items (no character-level tracking). +@Composable +fun setupEmojiSelection(selectionManager: SelectionManager?, selectionIndex: Int, textLength: Int): Boolean { + if (selectionManager == null || selectionIndex < 0) return false + + val isAnchor = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + selectionManager.setAnchorOffset(0) + } + + val isFocus = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { selectionManager.focusWindowY } + .collect { selectionManager.updateFocusOffset(textLength) } + } + } + + return remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) != null } }.value +} + +@Composable +fun SelectionCopyButton() { + val manager = LocalSelectionManager.current ?: return + val range = manager.range ?: return + if (manager.selectionState != SelectionState.Selected || manager.focusCharRect == Rect.Zero) return + val draggingDown = range.startIndex > range.endIndex || (range.startIndex == range.endIndex && range.startOffset < range.endOffset) + val gap = with(LocalDensity.current) { 4.dp.toPx() } + var buttonSize by remember { mutableStateOf(IntSize.Zero) } + Row( + Modifier + .offset { manager.copyButtonOffset(draggingDown, gap, buttonSize) } + .onSizeChanged { buttonSize = it } + .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .clickable { manager.onCopySelection?.invoke() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(6.dp)) + Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt new file mode 100644 index 0000000000..0cf3a3c96f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -0,0 +1,119 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.subscriberCountStr +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelMembersView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember) -> Unit +) { + BackHandler(onBack = close) + val members = remember { chatModel.groupMembers }.value + .filter { m -> + m.memberStatus != GroupMemberStatus.MemLeft + && m.memberStatus != GroupMemberStatus.MemRemoved + && m.memberRole != GroupMemberRole.Relay + } + + ColumnWithScrollBar { + val title = if (groupInfo.isOwner) { + generalGetString(MR.strings.channel_members_title_subscribers) + } else { + generalGetString(MR.strings.channel_members_section_owners) + } + AppBarTitle(title) + + if (groupInfo.isOwner) { + val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() + SectionView(title = subscriberCountStr(subscriberCount).uppercase()) { + SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + ChannelMemberRow(groupInfo.membership, user = true, showRole = true) + } + members.forEachIndexed { index, member -> + Divider() + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner) + } + } + } + } else { + val owners = members.filter { it.memberRole >= GroupMemberRole.Owner } + SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) { + owners.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member, user = false, showRole = false) + } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + if (user) { + Text( + generalGetString(MR.strings.channel_member_you), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } + } + if (showRole) { + Text( + member.memberRole.text, + color = MaterialTheme.colors.secondary + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt new file mode 100644 index 0000000000..e8f2a36fff --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -0,0 +1,167 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelRelaysView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + BackHandler(onBack = close) + var groupRelays by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + setGroupMembers(rhId, groupInfo, chatModel) + if (groupInfo.isOwner) { + groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + } + } + + ChannelRelaysLayout( + groupInfo = groupInfo, + chatModel = chatModel, + groupRelays = groupRelays, + showMemberInfo = showMemberInfo + ) +} + +@Composable +private fun ChannelRelaysLayout( + groupInfo: GroupInfo, + chatModel: ChatModel, + groupRelays: List, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + val relayMembers = remember { chatModel.groupMembers }.value + .filter { it.memberRole == GroupMemberRole.Relay } + + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.channel_relays_title)) + + if (relayMembers.isEmpty()) { + SectionView { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + generalGetString(MR.strings.no_chat_relays), + color = MaterialTheme.colors.secondary + ) + } + } + } else { + SectionView { + relayMembers.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + val statusText = if (groupInfo.isOwner) { + ownerRelayStatusText(member, groupRelays) + } else { + subscriberRelayStatusText(member) + } + RelayMemberRow(member, statusText) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages)) + } + SectionBottomSpacer() + } +} + +@Composable +private fun RelayMemberRow(member: GroupMember, statusText: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onBackground + ) + Text( + statusText, + maxLines = 1, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } +} + +private fun subscriberRelayStatusText(member: GroupMember): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + relayConnStatus(member).first + } +} + +private fun ownerRelayStatusText(member: GroupMember, groupRelays: List): String { + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.relay_conn_status_failed) + } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus?.text + ?: relayConnStatus(member).first + } +} + +fun relayConnStatus(member: GroupMember): Pair { + return when (member.activeConn?.connStatus) { + is ConnStatus.Ready -> generalGetString(MR.strings.relay_conn_status_connected) to Color.Green + is ConnStatus.Deleted -> generalGetString(MR.strings.relay_conn_status_deleted) to Color.Red + is ConnStatus.Failed -> generalGetString(MR.strings.relay_conn_status_failed) to Color.Red + else -> generalGetString(MR.strings.relay_conn_status_connecting) to WarningYellow + } +} + +fun hostFromRelayLink(link: String): String { + val ft = parseToMarkdown(link) + if (ft != null) { + for (f in ft) { + val format = f.format + if (format is Format.SimplexLink) { + val host = format.smpHosts.firstOrNull() + if (host != null) return host + } + } + } + return link +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index dd3374d50b..78eb31ccbe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -5,6 +5,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewLongClickable +import SectionItemViewSpaceBetween import SectionSpacer import SectionTextFooter import SectionView @@ -41,6 +42,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions +import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -114,7 +116,7 @@ fun ModalData.GroupChatInfoView( } } }, - showMemberInfo = { member -> + showMemberInfo = { member, groupRelay -> withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -126,7 +128,7 @@ fun ModalData.GroupChatInfoView( } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { closeCurrent() close() } @@ -165,7 +167,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -175,9 +177,14 @@ fun ModalData.GroupChatInfoView( fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { val chatInfo = chat.chatInfo - val titleId = if (groupInfo.businessChat == null) MR.strings.delete_group_question else MR.strings.delete_chat_question + val titleId = if (groupInfo.useRelays) MR.strings.delete_channel_question + else if (groupInfo.businessChat == null) MR.strings.delete_group_question + else MR.strings.delete_chat_question val messageId = - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.membership.memberCurrent) MR.strings.delete_channel_for_all_subscribers_cannot_undo_warning + else MR.strings.delete_channel_for_self_cannot_undo_warning + } else if (groupInfo.businessChat == null) { if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning } else { @@ -209,8 +216,12 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl } fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - val titleId = if (groupInfo.businessChat == null) MR.strings.leave_group_question else MR.strings.leave_chat_question - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.leave_channel_question + else if (groupInfo.businessChat == null) MR.strings.leave_group_question + else MR.strings.leave_chat_question + val messageId = if (groupInfo.useRelays) + MR.strings.you_will_stop_receiving_messages_from_this_channel_chat_history_will_be_preserved + else if (groupInfo.businessChat == null) MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved else MR.strings.you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved @@ -229,12 +240,16 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl } private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question + else MR.strings.button_remove_member_question + val messageId = if (groupInfo.useRelays) + MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone + else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone else MR.strings.member_will_be_removed_from_chat_cannot_be_undone AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_member_question), + generalGetString(titleId), generalGetString(messageId), buttons = { Column { @@ -358,6 +373,22 @@ fun AddGroupMembersButton( ) } +@Composable +fun ChannelLinkActionButton( + modifier: Modifier, + groupInfo: GroupInfo, + manageGroupLink: () -> Unit +) { + InfoViewActionButton( + modifier = modifier, + icon = painterResource(MR.images.ic_link), + title = stringResource(MR.strings.action_button_channel_link), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = manageGroupLink + ) +} + @Composable fun UserSupportChatButton( chat: Chat, @@ -409,7 +440,7 @@ fun ModalData.GroupChatInfoLayout( appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, addMembers: () -> Unit, - showMemberInfo: (GroupMember) -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, openMemberSupport: () -> Unit, @@ -478,14 +509,19 @@ fun ModalData.GroupChatInfoLayout( Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { + val showThreeButtons = if (groupInfo.useRelays) groupInfo.isOwner else groupInfo.canAddMembers Row( Modifier - .widthIn(max = if (groupInfo.canAddMembers) 320.dp else 230.dp) + .widthIn(max = if (showThreeButtons) 320.dp else 230.dp) .padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - if (groupInfo.canAddMembers) { + if (groupInfo.useRelays && groupInfo.isOwner) { + SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) + ChannelLinkActionButton(modifier = Modifier.fillMaxWidth(0.5f), groupInfo, manageGroupLink) + MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) + } else if (!groupInfo.useRelays && groupInfo.canAddMembers) { SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) AddGroupMembersButton(modifier = Modifier.fillMaxWidth(0.5f), chat, groupInfo) MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) @@ -498,59 +534,100 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() - var anyTopSectionRowShow = false - SectionView { - if (groupInfo.canAddMembers && groupInfo.businessChat == null) { - anyTopSectionRowShow = true - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) + if (groupInfo.useRelays && groupInfo.membership.memberIncognito) { + SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionItemViewSpaceBetween { + Text(generalGetString(MR.strings.incognito_random_profile)) + Text(groupInfo.membership.chatViewName, color = Indigo) } } - if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - anyTopSectionRowShow = true - MemberSupportButton(chat, openMemberSupport) + SectionDividerSpaced() + } + + var anyTopSectionRowShow = false + val channelLink = groupInfo.groupProfile.publicGroup?.groupLink + if (groupInfo.useRelays) { + SectionView { + if (groupInfo.isOwner && groupLink != null) { + anyTopSectionRowShow = true + ChannelLinkButton(manageGroupLink) + } else if (channelLink != null) { + anyTopSectionRowShow = true + ChannelLinkQRCodeSection(channelLink) + } + if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { + anyTopSectionRowShow = true + ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) + } } - if (groupInfo.canModerate) { - anyTopSectionRowShow = true - GroupReportsButton(chat) { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + if (!groupInfo.isOwner && channelLink != null) { + SectionTextFooter(stringResource(MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect)) + } + } else { + SectionView { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { + anyTopSectionRowShow = true + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) } } - } - if ( - groupInfo.membership.memberActive && - (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) - ) { - anyTopSectionRowShow = true - UserSupportChatButton(chat, groupInfo, scrollToItemId) + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton(chat) { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + if ( + groupInfo.membership.memberActive && + (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + ) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } } } + val showEditSection = (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) + || groupInfo.groupProfile.description != null + || !groupInfo.useRelays if (anyTopSectionRowShow) { SectionDividerSpaced(maxBottomPadding = false) } - - SectionView { - if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { - EditGroupProfileButton(editGroupProfile) + if (showEditSection) { + SectionView { + if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { + val editProfileTitleId = if (groupInfo.useRelays) MR.strings.button_edit_channel_profile else MR.strings.button_edit_group_profile + EditGroupProfileButton(editProfileTitleId, editGroupProfile) + } + if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { + AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + } + if (!groupInfo.useRelays) { + val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences + GroupPreferencesButton(prefsTitleId, openPreferences) + } } - if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { - AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + if (!groupInfo.useRelays) { + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) } - val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences - GroupPreferencesButton(prefsTitleId, openPreferences) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) SectionView { - if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { - SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) - } else { - SendReceiptsOptionDisabled() + if (!groupInfo.useRelays) { + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) + } else { + SendReceiptsOptionDisabled() + } } WallpaperButton { ModalManager.end.showModal { @@ -566,7 +643,7 @@ fun ModalData.GroupChatInfoLayout( } SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { if (groupInfo.canAddMembers) { val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers @@ -589,7 +666,7 @@ fun ModalData.GroupChatInfoLayout( } } } - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { items(filteredMembers.value, key = { it.groupMemberId }) { member -> Divider() val showMenu = remember { mutableStateOf(false) } @@ -601,7 +678,7 @@ fun ModalData.GroupChatInfoLayout( toggleItemSelection(member.groupMemberId, selectedItems) } } else { - showMemberInfo(member) + showMemberInfo(member, null) } }, longClick = { showMenu.value = true }, @@ -622,18 +699,30 @@ fun ModalData.GroupChatInfoLayout( } } item { - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } SectionView { + if (groupInfo.useRelays && (groupInfo.isOwner || activeSortedMembers.any { it.memberRole == GroupMemberRole.Relay })) { + ChannelRelaysButton(chat.remoteHostId, groupInfo, showMemberInfo) + } ClearChatButton(clearChat) if (groupInfo.canDelete) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat + val titleId = if (groupInfo.useRelays) MR.strings.button_delete_channel + else if (groupInfo.businessChat == null) MR.strings.button_delete_group + else MR.strings.button_delete_chat DeleteGroupButton(titleId, deleteGroup) } if (groupInfo.membership.memberCurrentOrPending) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat - LeaveGroupButton(titleId, leaveGroup) + val hasOtherOwner = activeSortedMembers.any { + it.memberRole == GroupMemberRole.Owner && it.groupMemberId != groupInfo.membership.groupMemberId + } + if (!groupInfo.useRelays || !groupInfo.isOwner || hasOtherOwner) { + val titleId = if (groupInfo.useRelays) MR.strings.button_leave_channel + else if (groupInfo.businessChat == null) MR.strings.button_leave_group + else MR.strings.button_leave_chat + LeaveGroupButton(titleId, leaveGroup) + } } } @@ -775,6 +864,17 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) + if (groupInfo.useRelays) { + val count = groupInfo.groupSummary.publicMemberCount + if (count != null && count > 0) { + Text( + subscriberCountStr(count), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } } } @@ -1016,10 +1116,72 @@ private fun CreateGroupLinkButton(onClick: () -> Unit) { } @Composable -fun EditGroupProfileButton(onClick: () -> Unit) { +private fun ChannelLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_link), + stringResource(MR.strings.channel_link), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelLinkQRCodeSection(groupLink: String) { + val clipboard = LocalClipboardManager.current + SimpleXLinkQRCode(connReq = groupLink) + SectionItemView({ + clipboard.shareText(simplexChatLink(groupLink)) + }) { + Icon(painterResource(MR.images.ic_share), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.share_link), color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + val title = if (groupInfo.isOwner) { + stringResource(MR.strings.channel_members_title_subscribers) + } else { + stringResource(MR.strings.channel_members_section_owners) + } + SettingsActionItem( + painterResource(MR.images.ic_group), + title, + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelMembersView(rhId, groupInfo, chatModel, close) { member -> showMemberInfo(member, null) } + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelRelaysButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + stringResource(MR.strings.button_channel_relays), + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelRelaysView(rhId, groupInfo, chatModel, close, showMemberInfo) + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +fun EditGroupProfileButton(titleId: StringResource = MR.strings.button_edit_group_profile, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_edit), - stringResource(MR.strings.button_edit_group_profile), + stringResource(titleId), onClick, iconColor = MaterialTheme.colors.secondary ) @@ -1147,7 +1309,7 @@ fun PreviewGroupChatInfoLayout() { appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, - showMemberInfo = {}, + showMemberInfo = { _, _ -> }, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openMemberSupport = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 5a94e7d505..c9745359b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -32,6 +32,7 @@ fun GroupLinkView( groupLink: GroupLink?, onGroupLinkUpdated: ((GroupLink?) -> Unit)?, creatingGroup: Boolean = false, + isChannel: Boolean = false, close: (() -> Unit)? = null ) { var groupLinkVar by rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(groupLink) } @@ -122,6 +123,7 @@ fun GroupLinkView( groupInfo, groupLinkMemberRole, creatingLink, + isChannel = isChannel, createLink = ::createLink, showAddShortLinkAlert = ::showAddShortLinkAlert, updateLink = { @@ -168,6 +170,7 @@ fun GroupLinkLayout( groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, + isChannel: Boolean = false, createLink: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, updateLink: () -> Unit, @@ -185,9 +188,9 @@ fun GroupLinkLayout( } ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.group_link)) + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_link else MR.strings.group_link)) Text( - stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), + stringResource(if (isChannel) MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect else MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp), lineHeight = 22.sp ) @@ -208,7 +211,9 @@ fun GroupLinkLayout( } } } else { - RoleSelectionRow(groupInfo, groupLinkMemberRole) + if (!isChannel) { + RoleSelectionRow(groupInfo, groupLinkMemberRole) + } var initialLaunch by remember { mutableStateOf(true) } LaunchedEffect(groupLinkMemberRole.value) { if (!initialLaunch) { @@ -218,12 +223,12 @@ fun GroupLinkLayout( } val showShortLink = remember { mutableStateOf(true) } Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - if (groupLink.connLinkContact.connShortLink == null) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = false) - } else { - SectionViewWithButton(titleButton = { ToggleShortLinkButton(showShortLink) }) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) - } + SectionViewWithButton( + titleButton = + if (!isChannel && groupLink.connLinkContact.connShortLink != null) { + { ToggleShortLinkButton(showShortLink) } + } else null) { + SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) } Row( horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -235,7 +240,7 @@ fun GroupLinkLayout( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), click = { - if (groupLink.shouldBeUpgraded) { + if (!isChannel && groupLink.shouldBeUpgraded) { showAddShortLinkAlert { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } @@ -246,7 +251,7 @@ fun GroupLinkLayout( ) if (creatingGroup && close != null) { ContinueButton(close) - } else { + } else if (!isChannel) { SimpleButton( stringResource(MR.strings.delete_link), icon = painterResource(MR.images.ic_delete), @@ -255,7 +260,7 @@ fun GroupLinkLayout( ) } } - if (groupLink.shouldBeUpgraded) { + if (!isChannel && groupLink.shouldBeUpgraded) { AddShortLinkButton(text = stringResource(MR.strings.upgrade_group_link)) { showAddShortLinkAlert(null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 8902a0fd9e..fc5d697f4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -50,6 +50,7 @@ fun GroupMemberInfoView( connectionCode: String?, chatModel: ChatModel, openedFromSupportChat: Boolean, + groupRelay: GroupRelay? = null, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView ) { @@ -90,6 +91,7 @@ fun GroupMemberInfoView( newRole, developerTools, connectionCode, + groupRelay = groupRelay, getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { contactId -> scope.launch { @@ -311,6 +313,7 @@ fun GroupMemberInfoLayout( newRole: MutableState, developerTools: Boolean, connectionCode: String?, + groupRelay: GroupRelay? = null, getContactChat: (Long) -> Chat?, openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, @@ -365,7 +368,7 @@ fun GroupMemberInfoLayout( @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) - val canRemove = member.canBeRemoved(groupInfo) + val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay if (canBlockForAll || canRemove) { SectionDividerSpaced(maxBottomPadding = false) SectionView { @@ -380,7 +383,7 @@ fun GroupMemberInfoLayout( if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) { DeleteMemberMessagesButton(deleteMemberMessages) } else { - RemoveMemberButton(removeMember) + RemoveMemberButton(groupInfo.useRelays, removeMember) } } } @@ -417,77 +420,80 @@ fun GroupMemberInfoLayout( val contactId = member.memberContactId - Box( - Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Row( - Modifier - .widthIn(max = 320.dp) - .padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + if (!groupInfo.useRelays) { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - val knownChat = if (contactId != null) knownDirectChat(contactId) else null - if (knownChat != null) { - val (chat, contact) = knownChat - val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } + Row( + Modifier + .widthIn(max = 320.dp) + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val knownChat = if (contactId != null) knownDirectChat(contactId) else null + if (knownChat != null) { + val (chat, contact) = knownChat + val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } - LaunchedEffect(contact.contactId) { - withBGApi { - val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) - if (contactInfo != null) { - knownContactConnectionStats.value = contactInfo.first + LaunchedEffect(contact.contactId) { + withBGApi { + val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) + if (contactInfo != null) { + knownContactConnectionStats.value = contactInfo.first + } } } - } - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) - AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) - VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) - } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { - if (contactId != null) { - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group - } else { - OpenChatButton( - modifier = Modifier.fillMaxWidth(0.33f), - disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), - onClick = { createMemberContact() } - ) + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) + AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) + VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { + if (contactId != null) { + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group + } else { + OpenChatButton( + modifier = Modifier.fillMaxWidth(0.33f), + disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), + onClick = { createMemberContact() } + ) + } + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + } else { // no known contact chat && directMessages are off + val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) } - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - } else { // no known contact chat && directMessages are off - val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) } } - } - SectionSpacer() + SectionSpacer() + } if (member.memberActive) { SectionView { if ( !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + member.memberRole != GroupMemberRole.Relay && (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) ) { SupportChatButton() } - if (connectionCode != null) { + if (connectionCode != null && !(groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay)) { VerifyCodeButton(member.verified, verifyClicked) } if (cStats != null && cStats.ratchetSyncAllowed) { @@ -517,15 +523,46 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() } - SectionView(title = stringResource(MR.strings.member_info_section_title_member)) { - val titleId = if (groupInfo.businessChat == null) MR.strings.info_row_group else MR.strings.info_row_chat + val memberSectionTitle = if (groupInfo.useRelays) { + when (member.memberRole) { + GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay) + GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner) + else -> stringResource(MR.strings.member_info_section_title_subscriber) + } + } else { + stringResource(MR.strings.member_info_section_title_member) + } + SectionView(title = memberSectionTitle) { + val titleId = if (groupInfo.useRelays) MR.strings.info_row_channel + else if (groupInfo.businessChat == null) MR.strings.info_row_group + else MR.strings.info_row_chat InfoRow(stringResource(titleId), groupInfo.displayName) - val roles = remember { member.canChangeRoleTo(groupInfo) } - if (roles != null) { - RoleSelectionRow(roles, newRole, onRoleSelected) + if (!groupInfo.useRelays) { + val roles = remember { member.canChangeRoleTo(groupInfo) } + if (roles != null) { + RoleSelectionRow(roles, newRole, onRoleSelected) + } else { + InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) + } } else { InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) } + val relayLink = member.relayLink + if (relayLink != null) { + InfoRow(stringResource(MR.strings.info_row_relay_link), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayLink))) + } + val relayAddress = groupRelay?.userChatRelay?.address + if (relayAddress != null) { + InfoRow(stringResource(MR.strings.info_row_relay_address), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayAddress))) + val clipboard = LocalClipboardManager.current + ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) } + } + } + if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) { + SectionTextFooter( + if (groupInfo.isOwner) stringResource(MR.strings.relay_section_footer_owner) + else stringResource(MR.strings.relay_section_footer_subscriber) + ) } if (cStats != null) { SectionDividerSpaced() @@ -565,14 +602,19 @@ fun GroupMemberInfoLayout( val connFailedErr = member.activeConn?.connFailedErr if (connFailedErr != null) { SectionDividerSpaced() - SectionView { - InfoRow(stringResource(MR.strings.info_row_connection_failed), connFailedErr) + SectionView(title = stringResource(MR.strings.info_row_connection_failed), icon = painterResource(MR.images.ic_warning), iconTint = Color.Red, leadingIcon = true) { + SectionItemView { + Text( + connFailedErr, + color = MaterialTheme.colors.secondary + ) + } } } if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModeratorDestructiveSection() - } else { + } else if (!groupInfo.useRelays) { NonAdminBlockSection() } @@ -588,18 +630,20 @@ fun GroupMemberInfoLayout( else String.format(generalGetString(MR.strings.conn_level_desc_indirect), conn.connLevel) InfoRow(stringResource(MR.strings.info_row_connection), connLevelDesc) } - SectionItemView({ - withBGApi { - val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) - if (info != null) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.message_queue_info), - text = queueInfoText(info) - ) + if (!groupInfo.useRelays || member.memberRole == GroupMemberRole.Relay) { + SectionItemView({ + withBGApi { + val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) + if (info != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.message_queue_info), + text = queueInfoText(info) + ) + } } + }) { + Text(stringResource(MR.strings.info_row_debug_delivery)) } - }) { - Text(stringResource(MR.strings.info_row_debug_delivery)) } } } @@ -703,10 +747,11 @@ fun UnblockForAllButton(onClick: () -> Unit) { } @Composable -fun RemoveMemberButton(onClick: () -> Unit) { +fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) { + val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member SettingsActionItem( painterResource(MR.images.ic_delete), - stringResource(MR.strings.button_remove_member), + stringResource(label), click = onClick, textColor = Color.Red, iconColor = Color.Red, @@ -724,6 +769,17 @@ fun DeleteMemberMessagesButton(onClick: () -> Unit) { ) } +@Composable +fun ShareRelayAddressButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_share_filled), + stringResource(MR.strings.share_relay_address), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable fun OpenChatButton( modifier: Modifier, @@ -908,8 +964,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem } fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.block_subscriber_for_all_question else MR.strings.block_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.block_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { @@ -932,8 +989,9 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuc } fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.unblock_subscriber_for_all_question else MR.strings.unblock_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.unblock_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 633d6c454e..05c84db4c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -109,6 +110,7 @@ fun ChatItemView( showTimestamp: Boolean, itemSeparation: ItemSeparation, preview: Boolean = false, + swipeOffset: Float = 0f, ) { val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current @@ -298,8 +300,11 @@ fun ChatItemView( } Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - Row(verticalAlignment = Alignment.CenterVertically) { - val bubbleInteractionSource = remember { MutableInteractionSource() } + val canReply = (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && + cInfo !is ChatInfo.Local && !cItem.isReport && !cItem.meta.isLive && cItem.meta.itemDeleted == null + Box { + Row(verticalAlignment = Alignment.CenterVertically) { + val bubbleInteractionSource = remember { MutableInteractionSource() } val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() if (cItem.chatDir.sent) { GoToItemButton(true, bubbleHovered) @@ -800,6 +805,15 @@ fun ChatItemView( if (!cItem.chatDir.sent) { GoToItemButton(false, bubbleHovered) } + } + if (canReply && swipeOffset < 0) { + Icon( + painterResource(MR.images.ic_reply), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterEnd).offset(x = 26.dp).size(18.dp).alpha(minOf(1f, -swipeOffset / 30f)), + tint = MaterialTheme.colors.secondary + ) + } } if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { ChatItemReactions() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 7aca0466f9..3bcd02411f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -1,9 +1,11 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle @@ -12,6 +14,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont +import chat.simplex.common.views.chat.* import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) @@ -19,11 +22,20 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { + val emojiText = chatItem.content.text.trim() + val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length) + Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - EmojiText(chatItem.content.text) + if (isSelected) { + Box(Modifier.background(SelectionHighlightColor)) { + EmojiText(chatItem.content.text) + } + } else { + EmojiText(chatItem.content.text) + } CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 900fa238a5..8aab0bbbb6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -368,9 +368,11 @@ fun CIMarkdownText( showTimestamp: Boolean, prefix: AnnotatedString? = null ) { - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { - val chatInfo = chat.chatInfo - val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, @@ -379,7 +381,9 @@ fun CIMarkdownText( chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null }, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, + selectionRange = selection.highlightRange, + onTextLayoutResult = selection.onTextLayoutResult ) } } @@ -437,7 +441,10 @@ fun PriorityLayout( ) { measureable, constraints -> // Find important element which should tell what max width other elements can use // Expecting only one such element. Can be less than one but not more - val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints) + // Max image height for chat item display, taller images are cropped + val maxImageHeight = (constraints.maxWidth * 2.33f).toInt().coerceAtMost(constraints.maxHeight) + val imageConstraints = constraints.copy(maxHeight = maxImageHeight) + val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(imageConstraints) val placeables: List = measureable.map { if (it.layoutId == priorityLayoutId) imagePlaceable!! diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 3984e5bc40..9e8583a79b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.* @@ -55,6 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) +// Display text for a single formatted segment — must be coordinated with MarkdownText. +fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = + when (ft.format) { + is Format.Mention -> { + val mention = ci.mentions?.get(ft.format.memberName) + if (mention?.memberRef != null) { + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName + else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" + mentionText(name) + } else if (mention != null) mentionText(ft.format.memberName) + else ft.text + } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { + val t = ft.format.showText + ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + if (t != null) "$t ${ft.format.viaHosts}" else ft.text + } + is Format.Command -> ft.text + else -> ft.text + } + +// Full display text for a chat item — joins segment display texts. +fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text + return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +} + +// Text transformations in MarkdownText must match itemSegmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, @@ -77,7 +108,9 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, showTimestamp: Boolean = true, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -126,19 +159,27 @@ fun MarkdownText ( ) } if (formattedText == null) { + var selectableEnd = 0 val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + if (onTextLayoutResult != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } else { + var selectableEnd = 0 var hasLinks = false var hasSecrets = false var hasCommands = false @@ -247,6 +288,7 @@ fun MarkdownText ( is Format.Unknown -> append(ft.text) } } + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } @@ -255,9 +297,10 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } + val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { val icon = remember { mutableStateOf(PointerIcon.Default) } - ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -300,10 +343,15 @@ fun MarkdownText ( annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) - } + }, + onTextLayout = { onTextLayoutResult?.invoke(it) } ) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + if (onTextLayoutResult != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } } } @@ -314,6 +362,7 @@ fun ClickableText( text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, + selectionRange: IntRange? = null, softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, @@ -356,7 +405,7 @@ fun ClickableText( BasicText( text = text, - modifier = modifier.then(pressIndicator), + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -368,6 +417,42 @@ fun ClickableText( ) } +@Composable +private fun SelectableText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null +) { + val layoutResult = remember { mutableStateOf(null) } + + BasicText( + text = text, + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)), + style = style, + maxLines = maxLines, + overflow = overflow, + onTextLayout = { + layoutResult.value = it + onTextLayoutResult?.invoke(it) + } + ) +} + +private fun selectionHighlight(selectionRange: IntRange?, textLength: Int, layoutResult: State): Modifier = + if (selectionRange != null) { + Modifier.drawBehind { + layoutResult.value?.let { result -> + if (selectionRange.first <= selectionRange.last && selectionRange.last + 1 <= textLength) { + drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor) + } + } + } + } else Modifier + fun openBrowserAlert(uri: String, uriHandler: UriHandler) { val (res, err) = sanitizeUri(uri) if (res == null) { @@ -446,4 +531,4 @@ private fun isRtl(s: CharSequence): Boolean { return false } -private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" +fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 293a93b15a..0cec9ab773 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -316,7 +316,7 @@ fun GroupMenuItems( } } GroupMemberStatus.MemAccepted -> { - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -338,7 +338,7 @@ fun GroupMenuItems( } } ClearChatAction(chat, showMenu) - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 9264ca69af..4aeb929624 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -117,12 +118,25 @@ fun DatabaseErrorView( OpenDatabaseDirectoryButton() } is MigrationError.Downgrade -> { + val warnings = downMigrationWarnings(err.downMigrations).reversed() DatabaseErrorDetails(MR.strings.database_downgrade) { TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) { Text(generalGetString(MR.strings.downgrade_and_open_chat)) } Spacer(Modifier.height(20.dp)) + Icon( + painterResource(MR.images.ic_warning_filled), + contentDescription = null, + Modifier.size(40.dp).align(Alignment.CenterHorizontally), + tint = Color.Red + ) + Spacer(Modifier.height(12.dp)) Text(generalGetString(MR.strings.database_downgrade_warning), fontWeight = FontWeight.Bold) + if (warnings.isNotEmpty()) { + warnings.forEach { warning -> + Text(warning, fontWeight = FontWeight.Bold) + } + } FileNameText(status.dbFile) MigrationsText(err.downMigrations) AppVersionText() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index dc0a86b8fb..34d8099951 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -272,6 +272,7 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, + subtitle: String? = null, confirmText: String = generalGetString(MR.strings.connect_plan_open_chat), onConfirm: () -> Unit, dismissText: String = generalGetString(MR.strings.cancel_verb), @@ -317,6 +318,17 @@ class AlertManager { modifier = Modifier.fillMaxWidth() ) } + if (subtitle != null) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text( + subtitle, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + maxLines = 1, + modifier = Modifier.fillMaxWidth() + ) + } } Column( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 300e5f44fe..e584fcc11c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.helpers import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.res.MR import kotlinx.serialization.* import java.io.File import java.security.SecureRandom @@ -108,6 +109,15 @@ data class UpMigration( // val withDown: Boolean ) +fun downMigrationWarnings(downMigrations: List): List { + val warnings = listOf( + "20260222_chat_relays" to MR.strings.down_migration_warning_chat_relays + ) + return warnings.mapNotNull { (key, res) -> + if (downMigrations.contains(key)) generalGetString(res) else null + } +} + @Serializable sealed class MTRError { @Serializable @SerialName("noDown") class NoDown(val dbMigrations: List): MTRError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index da16e2b7e7..e8070b5c76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -32,7 +32,8 @@ fun TextEditor( placeholder: String? = null, contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), isValid: (String) -> Boolean = { true }, - focusRequester: FocusRequester? = null + focusRequester: FocusRequester? = null, + enabled: Boolean = true ) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } @@ -64,6 +65,7 @@ fun TextEditor( value = value.value, onValueChange = { value.value = it }, modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester), + enabled = enabled, textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -83,7 +85,7 @@ fun TextEditor( leadingIcon = null, trailingIcon = null, singleLine = false, - enabled = true, + enabled = enabled, isError = false, interactionSource = remember { MutableInteractionSource() }, colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 6199621c39..cabfbf031e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -474,7 +474,9 @@ private fun MutableState.MigrationConfirmationView(status: DB Tuple4( generalGetString(MR.strings.database_downgrade), generalGetString(MR.strings.downgrade_and_open_chat), - generalGetString(MR.strings.database_downgrade_warning), + (listOf(generalGetString(MR.strings.database_downgrade_warning)) + + downMigrationWarnings(err.downMigrations).reversed()) + .joinToString("\n"), MigrationConfirmation.YesUpDown ) is MigrationError.Error -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt new file mode 100644 index 0000000000..cf10f5e545 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -0,0 +1,611 @@ +package chat.simplex.common.views.newchat + +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.res.MR +import java.net.URI +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +@Composable +fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit) { + val view = LocalMultiplatformView() + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val displayName = rememberSaveable { mutableStateOf("") } + val chosenImage = rememberSaveable { mutableStateOf(null) } + val profileImage = rememberSaveable { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val hasRelays = rememberSaveable { mutableStateOf(true) } + val groupInfo = remember { mutableStateOf(null) } + val groupLink = rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(null) } + val groupRelays = remember { mutableStateOf>(emptyList()) } + val creationInProgress = rememberSaveable { mutableStateOf(false) } + val showLinkStep = rememberSaveable { mutableStateOf(false) } + val relayListExpanded = rememberSaveable { mutableStateOf(false) } + + val gInfo = groupInfo.value + if (showLinkStep.value && gInfo != null) { + LinkStepView(chatModel, gInfo, groupLink, closeAll) + } else if (gInfo != null) { + ProgressStepView( + chatModel, gInfo, groupRelays, relayListExpanded, + onLinkReady = if (appPlatform.isDesktop) { + { + chatModel.creatingChannelId.value = null + closeAll() + withBGApi { + openGroupChat(null, gInfo.groupId) + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, close = close) + } + } + } + } else { + { showLinkStep.value = true } + }, + cancelChannelCreation = { + chatModel.creatingChannelId.value = null + ChannelRelaysModel.reset() + closeAll() + withBGApi { + try { + chatModel.controller.apiDeleteChat(rh = null, type = ChatType.Group, id = gInfo.apiId) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(null, gInfo.id) + } + } catch (e: Exception) { + Log.e(TAG, "cancelChannelCreation error: ${e.message}") + } + } + } + ) + } else { + ProfileStepView( + chatModel = chatModel, + displayName = displayName, + profileImage = profileImage, + chosenImage = chosenImage, + focusRequester = focusRequester, + hasRelays = hasRelays, + creationInProgress = creationInProgress, + bottomSheetModalState = bottomSheetModalState, + scope = scope, + view = view, + close = close, + createChannel = { + hideKeyboard(view) + val trimmedName = displayName.value.trim() + displayName.value = trimmedName + val profile = GroupProfile( + displayName = trimmedName, + fullName = "", + shortDescr = null, + image = profileImage.value, + groupPreferences = GroupPreferences(history = GroupPreference(GroupFeatureEnabled.ON)) + ) + creationInProgress.value = true + withBGApi { + try { + val enabledRelays = chooseRandomRelays() + val relayIds = enabledRelays.mapNotNull { it.chatRelayId } + if (relayIds.isEmpty()) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + hasRelays.value = false + } + return@withBGApi + } + val result = chatModel.controller.apiNewPublicGroup( + rh = null, + incognito = false, + relayIds = relayIds, + groupProfile = profile + ) + if (result != null) { + val (gI, gL, gR) = result + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId = null, gI) + chatModel.creatingChannelId.value = gI.id + groupInfo.value = gI + groupLink.value = gL + groupRelays.value = gR.sortedBy { relayDisplayName(it) } + ChannelRelaysModel.set(gI.groupId, gR) + creationInProgress.value = false + } + } else { + withContext(Dispatchers.Main) { creationInProgress.value = false } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_creating_channel), + text = e.message + ) + } + } + } + } + ) + } +} + +private const val maxRelays = 3 + +private suspend fun chooseRandomRelays(): List { + val servers = getUserServers(rh = null) ?: return emptyList() + // Operator relays are grouped per operator; custom relays (null operator) + // are treated independently to maximize trust distribution. + val operatorGroups = mutableListOf>() + var customRelays = mutableListOf() + for (op in servers) { + val relays = op.chatRelays.filter { it.enabled && !it.deleted && it.chatRelayId != null } + if (relays.isEmpty()) continue + if (op.operator != null) { + operatorGroups.add(relays.shuffled()) + } else { + customRelays = relays.shuffled().toMutableList() + } + } + val selected = mutableListOf() + // Prefer at least one custom relay when available - + // user's own infrastructure for trust distribution. + if (customRelays.isNotEmpty()) { + selected.add(customRelays.removeAt(0)) + if (selected.size >= maxRelays) return selected + } + // Round-robin across shuffled groups to distribute relays across operators. + val groups = (operatorGroups + customRelays.map { listOf(it) }).shuffled() + val maxDepth = groups.maxOfOrNull { it.size } ?: 0 + for (depth in 0 until maxDepth) { + for (group in groups) { + if (depth < group.size) { + selected.add(group[depth]) + if (selected.size >= maxRelays) return selected + } + } + } + return selected +} + +private suspend fun checkHasRelays(): Boolean { + val servers = try { getUserServers(rh = null) } catch (_: Exception) { null } ?: return false + return servers.any { op -> + op.chatRelays.any { it.enabled && !it.deleted && it.chatRelayId != null } + } +} + +@Composable +private fun ProfileStepView( + chatModel: ChatModel, + displayName: MutableState, + profileImage: MutableState, + chosenImage: MutableState, + focusRequester: FocusRequester, + hasRelays: MutableState, + creationInProgress: MutableState, + bottomSheetModalState: ModalBottomSheetState, + scope: CoroutineScope, + view: Any?, + close: () -> Unit, + createChannel: () -> Unit +) { + LaunchedEffect(Unit) { + hasRelays.value = checkHasRelays() + } + + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.imePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + } + ) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + ModalView(close = close) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.create_channel_title)) + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(108.dp, image = profileImage.value) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + Row( + Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + generalGetString(MR.strings.channel_display_name_field), + fontSize = 16.sp + ) + if (!isValidDisplayName(displayName.value.trim())) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) + } + Spacer(Modifier.height(8.dp)) + + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + generalGetString(MR.strings.configure_relays), + click = { + ModalManager.start.showCustomModal { close -> + NetworkAndServersView(close) + } + }, + textColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange, + iconColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange + ) + + val canCreate = canCreateProfile(displayName.value) && hasRelays.value && !creationInProgress.value + SettingsActionItem( + painterResource(MR.images.ic_check), + generalGetString(MR.strings.create_channel_button), + click = createChannel, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreate + ) + + SectionTextFooter( + if (!hasRelays.value) { + generalGetString(MR.strings.enable_at_least_one_chat_relay) + } else { + val name = chatModel.currentUser.value?.displayName ?: "" + String.format(generalGetString(MR.strings.your_profile_shared_with_channel_relays), name) + } + ) + + LaunchedEffect(Unit) { + delay(1000) + focusRequester.requestFocus() + } + } + } + } +} + +@Composable +private fun ProgressStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupRelays: MutableState>, + relayListExpanded: MutableState, + onLinkReady: () -> Unit, + cancelChannelCreation: () -> Unit +) { + val failedCount = groupRelays.value.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + val total = groupRelays.value.size + + if (appPlatform.isDesktop) { + DisposableEffect(Unit) { + chatModel.centerPanelBackgroundClickHandler = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.cancel_creating_channel_question), + confirmText = generalGetString(MR.strings.cancel_creating_channel_confirm), + onConfirm = cancelChannelCreation, + dismissText = generalGetString(MR.strings.wait_verb), + destructive = true, + ) + true + } + onDispose { + chatModel.centerPanelBackgroundClickHandler = null + } + } + } + + LaunchedEffect(gInfo.groupId) { + snapshotFlow { ChannelRelaysModel.groupRelays.toList() } + .collect { relays -> + if (ChannelRelaysModel.groupId.value != gInfo.groupId) return@collect + groupRelays.value = relays.sortedBy { relayDisplayName(it) } + if (relays.all { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }) { + onLinkReady() + ChannelRelaysModel.reset() + } + } + } + + ModalView( + close = cancelChannelCreation, + showClose = false, + endButtons = { + TextButton(onClick = cancelChannelCreation) { + Text(generalGetString(MR.strings.cancel_verb)) + } + } + ) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.creating_channel)) + + Box( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + ProfileImage(108.dp, image = gInfo.groupProfile.image) + } + Text( + gInfo.groupProfile.displayName, + style = MaterialTheme.typography.h6, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + textAlign = TextAlign.Center + ) + + SectionView { + SectionItemView(click = { relayListExpanded.value = !relayListExpanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f)) + Icon( + painterResource(if (relayListExpanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (relayListExpanded.value) { + groupRelays.value.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + if (failedErr != null) { + SectionItemView( + click = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + }, + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = true) + } + } else { + SectionItemView( + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = false) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + SectionView { + val enabled = activeCount > 0 + SettingsActionItem( + painterResource(MR.images.ic_link), + generalGetString(MR.strings.channel_link), + click = { + if (activeCount >= total) { + onLinkReady() + } else if (activeCount > 0) { + val alertText = String.format( + generalGetString(MR.strings.channel_will_start_with_relays), + activeCount, total + ) + if (activeCount + failedCount < total) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + buttons = { + Row(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.wait_verb)) + } + TextButton(onClick = { + AlertManager.shared.hideAlert() + onLinkReady() + }) { + Text(generalGetString(MR.strings.proceed_verb)) + } + } + } + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + confirmText = generalGetString(MR.strings.proceed_verb), + onConfirm = { onLinkReady() } + ) + } + } + }, + textColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + disabled = !enabled + ) + } + } + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + +@Composable +private fun RelayRow(relay: GroupRelay, connFailed: Boolean) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(relayDisplayName(relay)) + RelayStatusIndicator(relay.relayStatus, connFailed = connFailed) + } +} + +@Composable +private fun LinkStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupLink: MutableState, + closeAll: () -> Unit +) { + val close: () -> Unit = { + chatModel.creatingChannelId.value = null + withBGApi { + delay(500) + withContext(Dispatchers.Main) { + ModalManager.start.closeModals() + openGroupChat(null, gInfo.groupId) + } + } + } + ModalView(close = close, showClose = false) { + GroupLinkView( + chatModel = chatModel, + rhId = null, + groupInfo = gInfo, + groupLink = groupLink.value, + onGroupLinkUpdated = { groupLink.value = it }, + creatingGroup = true, + isChannel = true, + close = close + ) + } +} + +fun relayDisplayName(relay: GroupRelay): String { + if (relay.userChatRelay.displayName.isNotEmpty()) return relay.userChatRelay.displayName + relay.userChatRelay.domains.firstOrNull()?.let { return it } + relay.relayLink?.let { return hostFromRelayLink(it) } + return "relay ${relay.groupRelayId}" +} + + +@Composable +fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false) { + val color = if (connFailed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow + val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else status.text + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Canvas(Modifier.size(8.dp)) { + drawCircle(color = color) + } + Text( + text, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + if (connFailed) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +fun RelayProgressIndicator(active: Int, total: Int) { + if (active == 0) { + CircularProgressIndicator( + Modifier.size(20.dp), + strokeWidth = 2.5.dp + ) + } else { + val progress = active.toFloat() / total.coerceAtLeast(1).toFloat() + Box(Modifier.size(20.dp)) { + Canvas(Modifier.fillMaxSize()) { + // Background circle + drawCircle( + color = Color.Gray.copy(alpha = 0.3f), + style = Stroke(width = 2.5.dp.toPx()) + ) + // Progress arc + drawArc( + color = Color(0xFF2196F3), // accent blue + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewAddChannelView() { + SimpleXTheme { + AddChannelView(chatModel = ChatModel, close = {}, closeAll = {}) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index e8084e055a..0494cbb463 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -56,7 +56,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 434cb6ce27..68c7d5b3f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -28,6 +29,15 @@ suspend fun planAndConnect( filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { + val link = strHasSingleSimplexLink(shortOrFullLink.trim()) + if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.relay_address_alert_title), + generalGetString(MR.strings.relay_address_alert_message), + ) + cleanup?.invoke() + return CompletableDeferred(false) + } connectProgressManager.cancelConnectProgress() val inProgress = mutableStateOf(true) connectProgressManager.startConnectProgress(generalGetString(MR.strings.loading_profile)) { @@ -203,6 +213,7 @@ private suspend fun planAndConnectTask( showPrepareGroupAlert( rhId, connectionLink, + connectionPlan.groupLinkPlan.groupSLinkInfo_, connectionPlan.groupLinkPlan.groupSLinkData_, close, cleanup @@ -421,52 +432,79 @@ fun ownGroupLinkConfirmConnect( close: (() -> Unit)?, cleanup: (() -> Unit)?, ) { - AlertManager.privacySensitive.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_plan_join_your_group), - text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, - buttons = { - Column { - // Open group - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - openKnownGroup(chatModel, rhId, close, groupInfo) - cleanup?.invoke() - }) { - Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - // Use current profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + if (groupInfo.useRelays) { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName), + buttons = { + Column { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - // Use new incognito profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - // Cancel - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - cleanup?.invoke() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } else { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Use current profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Cancel + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } - } - }, - onDismissRequest = cleanup, - hostDevice = hostDevice(rhId), - ) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } } private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { + val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupInfo.groupProfile.displayName, profileFullName = groupInfo.groupProfile.fullName, @@ -477,8 +515,11 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( icon = groupInfo.chatIconName ) }, + subtitle = subscriberCount, confirmText = generalGetString( - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel + } else if (groupInfo.businessChat == null) { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_group else MR.strings.connect_plan_open_group } else { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat @@ -544,21 +585,39 @@ fun showPrepareContactAlert( fun showPrepareGroupAlert( rhId: Long?, connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { + val isChannel = !(groupShortLinkInfo?.direct ?: true) + val subscriberCount = if (isChannel) groupShortLinkData.publicGroupData?.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupShortLinkData.groupProfile.displayName, profileFullName = groupShortLinkData.groupProfile.fullName, - profileImage = { ProfileImage(size = alertProfileImageSize, image = groupShortLinkData.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled) }, - confirmText = generalGetString(MR.strings.connect_plan_open_new_group), + profileImage = { + ProfileImage( + size = alertProfileImageSize, + image = groupShortLinkData.groupProfile.image, + icon = if (isChannel) MR.images.ic_bigtop_updates_padded else MR.images.ic_supervised_user_circle_filled + ) + }, + subtitle = subscriberCount, + confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { - val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, groupShortLinkData) + val directLink = groupShortLinkInfo?.direct ?: true + val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData) if (chat != null) { withContext(Dispatchers.Main) { + val relays = groupShortLinkInfo?.groupRelays + if (!relays.isNullOrEmpty()) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Group) { + chatModel.channelRelayHostnames[chatInfo.groupInfo.groupId] = relays + } + } ChatController.chatModel.chatsContext.addChat(chat) openChat_(chatModel, rhId, close, chat) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index ef6e426141..292aa10f70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -63,6 +63,9 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { createGroup = { ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) } }, + createChannel = { + ModalManager.start.showCustomModal { close -> AddChannelView(chatModel, close, closeAll) } + }, rh = rh, close = close ) @@ -110,6 +113,7 @@ private fun ModalData.NewChatSheetLayout( addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit, + createChannel: () -> Unit, close: () -> Unit, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -193,6 +197,11 @@ private fun ModalData.NewChatSheetLayout( painterResource(MR.images.ic_group), stringResource(MR.strings.create_group_button), createGroup, + ), + Triple( + painterResource(MR.images.ic_bigtop_updates), + stringResource(MR.strings.create_channel_beta_button), + createChannel, ) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt new file mode 100644 index 0000000000..1c68e780dc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -0,0 +1,416 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.res.MR +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@Composable +fun ShowRelayTestStatus(relay: UserChatRelay, modifier: Modifier = Modifier) = + when (relay.tested) { + true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) + false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) + else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent) + } + +fun validRelayName(name: String): Boolean = + name.isNotEmpty() && isValidDisplayName(name) + +fun showInvalidRelayNameAlert(name: MutableState) { + val validName = mkValidName(name.value) + if (validName.isEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_name) + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.invalid_name), + text = String.format(generalGetString(MR.strings.correct_name_to), validName), + onConfirm = { + name.value = validName + } + ) + } +} + +fun validRelayAddress(address: String): Boolean { + val parsedMd = parseToMarkdown(address) + return parsedMd != null && + parsedMd.size == 1 && + parsedMd.first().format is Format.SimplexLink && + (parsedMd.first().format as Format.SimplexLink).linkType == SimplexLinkType.relay +} + +fun addChatRelay( + relay: UserChatRelay, + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>?, + rhId: Long?, + close: () -> Unit +) { + val nameEmpty = relay.displayName.trim().isEmpty() + val addressEmpty = relay.address.trim().isEmpty() + if (nameEmpty && addressEmpty) { + close() + } else if (!validRelayName(relay.displayName)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else if (!validRelayAddress(relay.address)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } else { + val i = userServers.value.indexOfFirst { it.operator == null } + if (i != -1) { + val updatedUserServers = userServers.value.toMutableList() + val operatorServers = updatedUserServers[i] + updatedUserServers[i] = operatorServers.copy( + chatRelays = operatorServers.chatRelays + relay + ) + userServers.value = updatedUserServers + withBGApi { + validateServers_(rhId, userServers.value, serverErrors, serverWarnings) + } + close() + } else { // Shouldn't happen + close() + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_relay)) + } + } +} + +@Composable +fun ChatRelayView( + relay: UserChatRelay, + onDelete: () -> Unit, + onUpdate: (UserChatRelay) -> Unit, + close: () -> Unit +) { + val relayToEdit = remember { mutableStateOf(relay) } + + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + if (relayToEdit.value.address == relay.address) { + relayToEdit.value = relayToEdit.value.copy(tested = relay.tested, relayProfile = relay.relayProfile) + } else { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + } + + ModalView( + close = { + val validName = validRelayName(relayToEdit.value.displayName) + val validAddress = validRelayAddress(relayToEdit.value.address) + if (validName && validAddress) { + onUpdate(relayToEdit.value) + close() + } else if (!validName) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } + } + ) { + ChatRelayLayout( + relayToEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun ChatRelayLayout( + relay: MutableState, + onDelete: (() -> Unit)? +) { + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.chat_relay)) + if (relay.value.preset) { + PresetRelay(relay, testing) + } else { + CustomRelay(relay, onDelete, testing) + } + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } + } +} + +@Composable +private fun PresetRelay(relay: MutableState, testing: MutableState) { + SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { + SelectionContainer { + Text( + relay.value.address, + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + color = MaterialTheme.colors.secondary + ) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionItemView { + Text(relay.value.displayName) + } + } + SectionDividerSpaced() + UseRelaySection(relay, testing = testing) +} + +@Composable +private fun CustomRelay( + relay: MutableState, + onDelete: (() -> Unit)?, + testing: MutableState +) { + val relayName = remember { mutableStateOf(relay.value.displayName) } + val relayAddress = remember { mutableStateOf(relay.value.address) } + val validName = remember { derivedStateOf { validRelayName(relayName.value) } } + val validAddress = remember { derivedStateOf { validRelayAddress(relayAddress.value) } } + + LaunchedEffect(Unit) { + snapshotFlow { relayName.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copyWithName(it) } + } + LaunchedEffect(Unit) { + snapshotFlow { relay.value.displayName } + .distinctUntilChanged() + .collect { relayName.value = it } + } + LaunchedEffect(Unit) { + snapshotFlow { relayAddress.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copy(address = it) } + } + + SectionView( + stringResource(MR.strings.your_relay_address).uppercase(), + icon = painterResource(MR.images.ic_error), + iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, + ) { + TextEditor( + relayAddress, + Modifier.height(144.dp) + ) + } + SectionDividerSpaced(maxTopPadding = true) + + Column { + val iconSize = with(LocalDensity.current) { 21.sp.toDp() } + Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.your_relay_name).uppercase(), + color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp + ) + IconButton( + onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) }, + enabled = !validName.value, + modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize) + ) { + Icon( + painterResource(MR.images.ic_error), null, + tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent + ) + } + } + Column(Modifier.fillMaxWidth()) { + TextEditor( + relayName, + Modifier, + placeholder = generalGetString(MR.strings.enter_relay_name), + enabled = relay.value.tested != true + ) + } + } + if (relay.value.tested != true) { + SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name)) + } + SectionDividerSpaced(maxTopPadding = true) + + UseRelaySection(relay, validAddress.value, testing) + + if (onDelete != null) { + SectionDividerSpaced() + SectionView { + SectionItemView(onDelete) { + Text(stringResource(MR.strings.delete_relay), color = MaterialTheme.colors.error) + } + } + } +} + +@Composable +private fun UseRelaySection( + relay: MutableState, + valid: Boolean = true, + testing: MutableState +) { + val scope = rememberCoroutineScope() + SectionView(stringResource(MR.strings.use_relay).uppercase()) { + SectionItemViewSpaceBetween( + click = { + testing.value = true + relay.value = relay.value.copy(tested = null) + scope.launch { + val f = testRelayConnection(relay) + if (f != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_test_failed_alert), + text = f.localizedDescription + ) + } + testing.value = false + } + }, + disabled = !valid || testing.value + ) { + Text( + stringResource(MR.strings.test_relay), + color = if (valid && !testing.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary + ) + ShowRelayTestStatus(relay.value) + } + + val enabled = rememberUpdatedState(relay.value.enabled) + PreferenceToggle( + stringResource(MR.strings.use_for_new_channels), + checked = enabled.value + ) { + relay.value = relay.value.copy(enabled = it) + } + } +} + +@Composable +fun ChatRelayViewLink( + relay: UserChatRelay, + duplicateRelayAddresses: Set, + onClick: () -> Unit +) { + SectionItemView(onClick) { + Box(Modifier.width(16.dp)) { + when { + relay.address in duplicateRelayAddresses -> InvalidServer() + !relay.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) + else -> ShowRelayTestStatus(relay) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val displayName = relay.displayName.ifEmpty { relay.domains.firstOrNull() ?: relay.address } + if (relay.enabled) { + Text(displayName, color = MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(displayName, maxLines = 1, color = MaterialTheme.colors.secondary) + } + } +} + +@Composable +fun ModalData.NewChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val relayToEdit = remember { + mutableStateOf( + UserChatRelay( + chatRelayId = null, address = "", relayProfile = RelayProfile(displayName = "", fullName = ""), domains = emptyList(), + preset = false, tested = null, enabled = true, deleted = false + ) + ) + } + + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + + ModalView(close = { + addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) + }) { + NewChatRelayLayout(relayToEdit) + } +} + +@Composable +private fun NewChatRelayLayout(relay: MutableState) { + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat_relay)) + CustomRelay(relay, onDelete = null, testing = testing) + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } + } +} + +suspend fun testRelayConnection(relay: MutableState): RelayTestFailure? = + try { + val (relayProfile, testFailure) = chatModel.controller.testChatRelay(chatModel.remoteHostId(), relay.value.address) + if (testFailure != null) { + relay.value = relay.value.copy(tested = false) + testFailure + } else { + relay.value = relay.value.copy(tested = true).let { + if (relayProfile != null) it.copyWithName(relayProfile.displayName) else it + } + null + } + } catch (e: Exception) { + Log.e(TAG, "testRelayConnection ${e.stackTraceToString()}") + relay.value = relay.value.copy(tested = false) + null + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 26ecf151ff..bbd2a0af49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -54,6 +54,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } val userServers = remember { stateGetOrPut("userServers") { emptyList() } } val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + val serverWarnings = remember { stateGetOrPut("serverWarnings") { emptyList() } } val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } fun onClose(close: () -> Unit): Boolean = if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) { @@ -91,6 +92,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, toggleSocksProxy = { enable -> val def = NetCfg.defaults val proxyDef = NetCfg.proxyDefaults @@ -158,6 +160,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { onionHosts: MutableState, currUserServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, userServers: MutableState>, toggleSocksProxy: (Boolean) -> Unit, ) { @@ -209,7 +212,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { userServers.value.forEachIndexed { index, srv -> - srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) } + srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, serverWarnings, currentRemoteHost?.remoteHostId) } } } if (conditionsAction != null && anyOperatorEnabled.value) { @@ -234,6 +237,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { YourServersView( userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = nullOperatorIndex, rhId = currentRemoteHost?.remoteHostId ) @@ -284,6 +288,12 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced() @@ -664,6 +674,7 @@ private fun ServerOperatorRow( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { SectionItemView( @@ -673,6 +684,7 @@ private fun ServerOperatorRow( currUserServers, userServers, serverErrors, + serverWarnings, index, rhId ) @@ -848,6 +860,30 @@ fun ServersErrorFooter(errStr: String) { } } +@Composable +fun ServersWarningFooter(warnStr: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = stringResource(MR.strings.server_warning), + tint = WarningOrange, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + warnStr, + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } +} + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.smp_save_servers_question), @@ -887,11 +923,13 @@ fun updateOperatorsConditionsAcceptance(usvs: MutableState, - serverErrors: MutableState> + serverErrors: MutableState>, + serverWarnings: MutableState>? = null ) { try { - val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + val (errors, warnings) = chatController.validateServers(rhId, userServersToValidate) ?: return serverErrors.value = errors + serverWarnings?.value = warnings } catch (ex: Exception) { Log.e(TAG, ex.stackTraceToString()) } @@ -914,6 +952,15 @@ fun globalServersError(serverErrors: List): String? { return null } +fun globalServersWarning(serverWarnings: List): String? { + for (warn in serverWarnings) { + if (warn.globalWarning != null) { + return warn.globalWarning + } + } + return null +} + fun globalSMPServersError(serverErrors: List): String? { for (err in serverErrors) { if (err.globalSMPError != null) { @@ -943,6 +990,9 @@ fun findDuplicateHosts(serverErrors: List): Set { return duplicateHostsList.toSet() } +fun findDuplicateRelayAddresses(serverErrors: List): Set = + serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayAddress)?.duplicateAddress }.toSet() + private suspend fun saveServers( rhId: Long?, currUserServers: MutableState>, @@ -987,7 +1037,8 @@ fun PreviewNetworkAndServersLayout() { toggleSocksProxy = {}, currUserServers = remember { mutableStateOf(emptyList()) }, userServers = remember { mutableStateOf(emptyList()) }, - serverErrors = remember { mutableStateOf(emptyList()) } + serverErrors = remember { mutableStateOf(emptyList()) }, + serverWarnings = remember { mutableStateOf(emptyList()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index 6a999aa89d..a3a843d034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.* fun ModalData.NewServerView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long?, close: () -> Unit ) { @@ -28,6 +29,7 @@ fun ModalData.NewServerView( newServer.value, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -101,6 +103,7 @@ fun addServer( server: UserServer, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>? = null, rhId: Long?, close: () -> Unit ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index c619ae6ebc..1449e0cd0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -47,6 +47,7 @@ fun OperatorView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -57,7 +58,7 @@ fun OperatorView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -68,9 +69,10 @@ fun OperatorView( currUserServers, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -87,6 +89,7 @@ fun OperatorView( fun navigateToProtocolView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, serverIndex: Int, @@ -100,6 +103,7 @@ fun navigateToProtocolView( serverProtocol = protocol, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, onDelete = { if (protocol == ServerProtocol.SMP) { deleteSMPServer(userServers, operatorIndex, serverIndex) @@ -130,11 +134,42 @@ fun navigateToProtocolView( } } +fun navigateToChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + operatorIndex: Int, + relayIndex: Int, + relay: UserChatRelay, + rhId: Long? +) { + ModalManager.start.showCustomModal { close -> + ChatRelayView( + relay = relay, + onDelete = { + deleteChatRelay(userServers, operatorIndex, relayIndex) + close() + }, + onUpdate = { updatedRelay -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + chatRelays = this[operatorIndex].chatRelays.toMutableList().apply { + this[relayIndex] = updatedRelay + } + ) + } + }, + close = close + ) + } +} + @Composable fun OperatorViewLayout( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -170,15 +205,21 @@ fun OperatorViewLayout( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId ) } val serversErr = globalServersError(serverErrors.value) + val serversWarn = globalServersWarning(serverWarnings.value) if (serversErr != null) { SectionCustomFooter { ServersErrorFooter(serversErr) } + } else if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } } else { val footerText = when (val c = operator.conditionsAcceptance) { is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) { @@ -194,6 +235,21 @@ fun OperatorViewLayout( } if (operator.enabled) { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { index, relay -> + if (!relay.deleted) { + ChatRelayViewLink(relay, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, index, relay, rhId) + } + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { @@ -387,21 +443,30 @@ fun OperatorViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) } SectionBottomSpacer() @@ -458,6 +523,7 @@ private fun UseOperatorToggle( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -485,6 +551,7 @@ private fun UseOperatorToggle( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId, close = close @@ -510,6 +577,7 @@ private fun SingleOperatorUsageConditionsView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, close: () -> Unit diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index ccad962313..01630a2b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -36,6 +36,7 @@ fun ProtocolServerView( serverProtocol: ServerProtocol, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, onDelete: () -> Unit, onUpdate: (UserServer) -> Unit, close: () -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63bf8b1dc4..b232c7994e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch fun ModalData.YourServersView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -40,7 +41,7 @@ fun ModalData.YourServersView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -51,9 +52,10 @@ fun ModalData.YourServersView( scope, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -72,6 +74,7 @@ fun YourServersViewLayout( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -81,7 +84,21 @@ fun YourServersViewLayout( val duplicateHosts = findDuplicateHosts(serverErrors.value) Column { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { i, relay -> + if (relay.deleted) return@forEachIndexed + ChatRelayViewLink(relay, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, i, relay, rhId) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionDividerSpaced() SectionView(generalGetString(MR.strings.message_servers).uppercase()) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed @@ -150,7 +167,8 @@ fun YourServersViewLayout( if ( userServers.value[operatorIndex].smpServers.any { !it.deleted } || - userServers.value[operatorIndex].xftpServers.any { !it.deleted } + userServers.value[operatorIndex].xftpServers.any { !it.deleted } || + userServers.value[operatorIndex].chatRelays.any { !it.deleted } ) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) } @@ -159,7 +177,7 @@ fun YourServersViewLayout( SettingsActionItem( painterResource(MR.images.ic_add), stringResource(MR.strings.smp_servers_add), - click = { showAddServerDialog(scope, userServers, serverErrors, rhId) }, + click = { showAddServerDialog(scope, userServers, serverErrors, serverWarnings, rhId) }, disabled = testing.value, textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -171,6 +189,12 @@ fun YourServersViewLayout( ServersErrorFooter(serversErr) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) SectionView { @@ -178,21 +202,30 @@ fun YourServersViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) HowToButton() } @@ -204,16 +237,20 @@ fun YourServersViewLayout( fun TestServersButton( smpServers: List, xftpServers: List, + chatRelays: List = emptyList(), testing: MutableState, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? = null ) { val scope = rememberCoroutineScope() - val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + val disabled = derivedStateOf { + (smpServers.none { it.enabled } && xftpServers.none { it.enabled } && chatRelays.filter { !it.deleted }.none { it.enabled }) || testing.value + } SectionItemView( { scope.launch { - testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + testServers(testing, smpServers, xftpServers, chatRelays, chatModel, onUpdate, onUpdateRelays) } }, disabled = disabled.value @@ -226,6 +263,7 @@ fun showAddServerDialog( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { AlertManager.shared.showAlertDialogButtonsColumn( @@ -235,7 +273,7 @@ fun showAddServerDialog( SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showCustomModal { close -> - NewServerView(userServers, serverErrors, rhId, close) + NewServerView(userServers, serverErrors, serverWarnings, rhId, close) } }) { Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) @@ -250,6 +288,7 @@ fun showAddServerDialog( server, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -260,6 +299,14 @@ fun showAddServerDialog( Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showCustomModal { close -> + NewChatRelayView(userServers, serverErrors, serverWarnings, rhId, close) + } + }) { + Text(stringResource(MR.strings.chat_relay), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } } ) @@ -303,20 +350,28 @@ private suspend fun testServers( testing: MutableState, smpServers: List, xftpServers: List, + chatRelays: List, m: ChatModel, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? ) { + val relaysResetStatus = resetRelayTestStatus(chatRelays) + onUpdateRelays?.invoke(relaysResetStatus) val smpResetStatus = resetTestStatus(smpServers) onUpdate(ServerProtocol.SMP, smpResetStatus) val xftpResetStatus = resetTestStatus(xftpServers) onUpdate(ServerProtocol.XFTP, xftpResetStatus) testing.value = true + val relayFailures = runRelaysTest(relaysResetStatus) { onUpdateRelays?.invoke(it) } val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } testing.value = false - val fs = smpFailures + xftpFailures - if (fs.isNotEmpty()) { - val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + val failures = mutableListOf() + failures += relayFailures.map { (name, f) -> "$name: ${f.localizedDescription}" } + failures += smpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + failures += xftpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + if (failures.isNotEmpty()) { + val msg = failures.joinToString("\n") AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_test_failed), text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg @@ -354,6 +409,37 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUp return fs } +private fun resetRelayTestStatus(relays: List): List { + val copy = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + copy.removeAt(index) + copy.add(index, relay.copy(tested = null)) + } + } + return copy +} + +private suspend fun runRelaysTest(relays: List, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedRelays = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + interruptIfCancelled() + val relayState = mutableStateOf(relay) + val f = testRelayConnection(relayState) + updatedRelays.removeAt(index) + updatedRelays.add(index, relayState.value) + onUpdated(updatedRelays.toList()) + if (f != null) { + val name = relayState.value.displayName.ifEmpty { relayState.value.domains.firstOrNull() ?: relayState.value.address } + fs[name] = f + } + } + } + return fs +} + fun deleteXFTPServer( userServers: MutableState>, operatorServersIndex: Int, @@ -405,3 +491,28 @@ fun deleteSMPServer( } } } + +fun deleteChatRelay( + userServers: MutableState>, + operatorServersIndex: Int, + relayIndex: Int +) { + val relay = userServers.value[operatorServersIndex].chatRelays[relayIndex] + if (relay.chatRelayId == null) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this.removeAt(relayIndex) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this[relayIndex] = this[relayIndex].copy(deleted = true) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 86e51e6937..2a76fa292a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2527,4 +2527,18 @@ احذف الرسائل ستُحذف رسائل العضو - ولا يمكن التراجع عن ذلك! أزل واحذف الرسائل + كل الرسائل + فشل الاتصال + فشل + ملفات + تصفية + صور + روابط + ابحث عن ملفات + ابحث عن صور + ابحث عن روابط + ابحث عن فيديوهات + ابحث عن رسائل صوتية + فيديوهات + رسائل صوتية diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 8b1eb44249..ac9f9b2fc8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,6 +54,7 @@ %d messages blocked by admin sending files is not supported yet receiving files is not supported yet + Voice recording is not supported on your platform you unknown message format invalid message format @@ -100,7 +101,7 @@ SimpleX one-time invitation SimpleX group link SimpleX channel link - SimpleX relay link + SimpleX relay address via %1$s SimpleX links Description @@ -141,6 +142,8 @@ No servers to receive files. For chat profile %s: Errors in servers configuration. + No chat relays enabled. + Server warning Error accepting conditions Spam Content violates conditions of use @@ -514,8 +517,11 @@ Your contact Bot Tap Join group + Tap Join channel Your group + Your channel Group + Channel Business connection Your business contact @@ -561,6 +567,8 @@ Report sent to moderators You can view your reports in Chat with admins. Join group + Join channel + Broadcast Add message Connect Send contact request? @@ -575,7 +583,9 @@ not synchronized contact disabled you are observer + you are subscriber Please contact group admin. + channel request to join rejected group is deleted removed from group @@ -1638,6 +1648,7 @@ different migration in the app/database: %s / %s Migrations: %s Warning: you may lose some data! + If you joined or created channels, they will stop working permanently. Chat is stopped @@ -1655,8 +1666,10 @@ You joined this group. Connecting to inviting group member. Leave Leave group? + Leave channel? Leave chat? You will stop receiving messages from this group. Chat history will be preserved. + You will stop receiving messages from this channel. Chat history will be preserved. You will stop receiving messages from this chat. Chat history will be preserved. Invite members Group inactive @@ -1755,6 +1768,7 @@ moderator admin owner + relay rejected @@ -1803,24 +1817,32 @@ %1$s MEMBERS you: %1$s Delete group + Delete channel Delete chat Delete group? + Delete channel? Delete chat? Group will be deleted for all members - this cannot be undone! + Channel will be deleted for all subscribers - this cannot be undone! Chat will be deleted for all members - this cannot be undone! Group will be deleted for you - this cannot be undone! + Channel will be deleted for you - this cannot be undone! Chat will be deleted for you - this cannot be undone! Leave group + Leave channel Leave chat Edit group profile + Edit channel profile Add welcome message Welcome message Group link + Channel link Create group link Create link Delete link? Delete link 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. + You can share a link or a QR code - anybody will be able to join the channel. All group members will remain connected. Error creating group link Error updating group link @@ -1837,7 +1859,10 @@ Receipts are disabled This group has over %1$d members, delivery receipts are not sent. Invite + Link Chat with admins + Channel members + Chat relays FOR CONSOLE @@ -1872,6 +1897,7 @@ Remove member? + Remove subscriber? Remove members? Delete member messages? Remove member @@ -1879,6 +1905,7 @@ Chat with member Send direct message Member will be removed from group - this cannot be undone! + Subscriber will be removed from channel - this cannot be undone! Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! Members will be removed from chat - this cannot be undone! @@ -1925,7 +1952,7 @@ Group Chat Connection - Connection failed + CONNECTION FAILED direct indirect (%1$s) Message queue info @@ -2794,4 +2821,107 @@ You can mention up to %1$s members per message! + + + Subscribers + Owners + %1$d subscriber + %1$d subscribers + you + + + Chat relay + New chat relay + Preset relay name + Preset relay address + Your relay name + Your relay address + Enter relay name… + Use relay + Test relay + Use for new channels + Delete relay + Test relay to retrieve its name.]]> + Relay test failed! + Get link + Decode link + Connect + Wait response + Verify + Test failed at step %s. + Server requires authorization to connect to relay, check password. + Invalid relay name! + Check relay name and try again. + Invalid relay address! + Check relay address and try again. + Error adding relay + + + Chat relays + Chat relays forward messages in channels you create. + + + Chat relays + No chat relays + Chat relays forward messages to channel subscribers. + connected + connecting + deleted + failed + new + invited + accepted + active + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relays active + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d relays connected + %1$d relays + + + RELAY + OWNER + SUBSCRIBER + Channel + Relay link + Relay address + via %1$s + Share relay address + Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel. + You connected to the channel via this relay link. + Remove subscriber + Block subscriber for all? + + + Create public channel + Create public channel + Create public channel (BETA) + Channel name + Creating channel + Error creating channel + Cancel creating channel? + Cancel + Enable at least one chat relay to create a channel. + Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages. + Configure relays + failed + Relay connection failed + Not all relays connected + Wait + Proceed + Channel will start working with %1$d of %2$d relays. Proceed? + + + Relay address + This is a chat relay address, it cannot be used to connect. + Open channel + Open new channel + Your channel + %1$s!]]> + Error opening channel + + + Unblock subscriber for all? \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 423f0e135f..b559431261 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -2213,7 +2213,7 @@ Chyba dočasného souboru Přesunout sezení TCP připojení - Použité servery + Použít servery Použit %s Pro příjem Systém @@ -2517,7 +2517,7 @@ Pro odeslání příkazů musíte být připojen. Pro použití jiného profilu po pokusu o připojení, smažte chat a znovu použijte odkaz. Aktualizovat vaši adresu - Povýšení + Povýšit Povýšit adresu? Povýšit odkaz skupiny Povýšit odkaz skupiny? @@ -2528,4 +2528,26 @@ Váš kontakt Vaše skupina Váš profil + Všechny zprávy + Smazat zprávy člena + Smazat zprávy člena? + Smazat zprávy + Soubory + Filtr + Obrázky + Odkazy + Zprávy člena budou smazány - nemůže být zrušeno! + bez předplatného + Odebrat a smazat zprávy + Hledat soubory + Hledat obrázky + Hledat odkazy + Hledat videa + Hledat hlasové zprávy + Videa + Hlasové zprávy + Nejste připojen k serveru, který se používá k přijímání zpráv z tohoto připojení (bez předplatného). + Připojení selhalo + selhal + Pokud jste se připojili k nějakým kanálům nebo je vytvořili, přestanou trvale fungovat. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 3254d0a63f..5d3d9ac90e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2629,4 +2629,7 @@ Sprachnachrichten suchen Videos Sprachnachrichten + Verbindung fehlgeschlagen + Fehlgeschlagen + Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 6f326c33c8..20a271f58f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -2521,4 +2521,7 @@ Αναζήτηση φωνητικών μηνυμάτων Βίντεο Φωνητικά μηνύματα + Η σύνδεση απέτυχε + απέτυχε + Αν έχετε συμμετάσχει ή δημιουργήσει κανάλια, θα σταματήσουν να λειτουργούν μόνιμα. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 38f8a81d3a..09a843c91c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -233,7 +233,7 @@ Partnerek Kapcsolódási hiba A partnere még nem kapcsolódott! - - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. + - kapcsolódás a könyvtárszolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. Közreműködés kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása @@ -299,7 +299,7 @@ kapcsolódás… Hívás kapcsolása Törli a fájlokat és a médiatartalmakat? - befejezett + kész CSEVEGÉSI ADATBÁZIS Önmegsemmisítő jelkód módosítása Várólista létrehozása @@ -1392,7 +1392,7 @@ A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával. Kód ellenőrzése a hordozható eszközön Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. - a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> + a SimpleX Chat fejlesztőivel, akiktől bármit kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. Ismeretlen adatbázishiba: %s Elrejtheti vagy lenémíthatja a felhasználóprofiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. @@ -1654,7 +1654,7 @@ Átköltöztetés egy másik eszközről Kvantumbiztos titkosítás Megpróbálhatja még egyszer. - Átköltöztetés befejezve + Átköltöztetés kész Átköltöztetés egy másik eszközre QR-kód használatával. Átköltöztetés Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> @@ -2522,4 +2522,7 @@ Hangüzenetek keresése Videók Hangüzenetek + Nem sikerült létrehozni a kapcsolatot + sikertelen + Ha csatornákat hozott létre vagy csatlakozott hozzájuk, akkor azok véglegesen le fognak állni. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg new file mode 100644 index 0000000000..fc1e09a3cb --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg new file mode 100644 index 0000000000..9f4edcfd98 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 1c191a78bd..fe4a658a68 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2558,4 +2558,7 @@ Video Messaggi vocali Filtro + Connessione fallita + fallito + Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 1c4d265515..4c5b279ba7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1826,7 +1826,7 @@ SMPサーバーの構成 接続中 XFTPサーバーの構成 - チャトリスト切り替え + チャットリスト表示切り替え 連絡先 メッセージサーバ メディア&ファイルサーバ @@ -2055,4 +2055,10 @@ メンバーとして承認する オブザーバーとして承認する スパム + アーカイブ + 自己紹介 + 自己紹介の文字数が上限を超えています + ぼかし + 連絡先 + お気に入り diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 2f26545913..281a734ed3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1619,7 +1619,7 @@ Zakończ połączenie Połączenie wideo Błąd podczas otwierania przeglądarki - Do połączeń wymagana jest domyślna przeglądarka. Proszę skonfigurować domyślną przeglądarkę systemową, i podzielić się informacją z twórcami. + Do wykonywania połączeń wymagana jest domyślna przeglądarka internetowa. Skonfiguruj domyślną przeglądarkę w systemie i przekaż więcej informacji programistom. Ten czat jest chroniony przez szyfrowanie e2e odporne na ataki kwantowe. szyfrowanie end-to-end z perfect forward secrecy, zaprzeczalnością i odzyskiwaniem bezpieczeństwa po kompromitacji.]]> Otwórz ekran migrowania @@ -2234,7 +2234,7 @@ Połącz Połącz się szybciej! 🚀 kontakt usunięty - kontakt zablokowany + kontakt wyłączony kontakt nie gotowy PROŚBY O KONTAKT OD GRUP kontakt powinien zaakceptować… @@ -2376,7 +2376,7 @@ - Otwórz czat w pierwszej nieprzeczytanej wiadomości.\n- Przejdź do cytowanych wiadomości. Otwórz czysty link Otwórz warunki - Otwórz pełen link + Otwórz pełny link Otwórz link Otwórz linki z listy czatów Otwórz nowy czat @@ -2395,8 +2395,8 @@ Nie można odczytać hasła w magazynie kluczy. Wprowadź je ręcznie. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. Nie można odczytać hasła w magazynie kluczy. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. oczekuje - oczekiwanie zaakceptowane - oczekująca recenzja + oczekuje na zatwierdzenie + oczekuje na ocenę Zmniejsz rozmiar wiadomości i wyślij ją ponownie. Zmniejsz rozmiar wiadomości lub usuń multimedia i wyślij ponownie. Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. @@ -2418,7 +2418,7 @@ Odrzucić członka? Zdalne telefony komórkowe Usuń i skasuj wiadomości - przeniesiono z grupy + usunięty z grupy Usuń śledzenie linków Usunąć członka? Usuwa wiadomości i blokuje członków. @@ -2428,7 +2428,7 @@ Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. Zgłoś inne: zobaczą to tylko moderatorzy grupy. Jaki jest powód zgłoszenia? - Zgłoś: %s + Zgłoszenie: %s Zgłoszenia Zgłoszenia wysłane do moderatorów Zgłoś spam: tylko moderatorzy grupy będą to widzieć. @@ -2437,7 +2437,7 @@ poproszono o połączenie prośba została wysłana prośba o dołączenie została odrzucona - przejrzyj + ocena Przejrzyj warunki sprawdzone przez administratorów Przejrzyj członków grupy @@ -2445,12 +2445,12 @@ Przejrzyj członków Przejrzyj członków przed przyjęciem (pukanie). Zapisać ustawienia wstępu? - Zachowaj listę - Poszukaj plików - Poszukaj obrazów - Poszukaj linków - Poszukaj wideo - Poszukaj wiadomości głosowych + Zapisz listę + Szukaj plików + Szukaj zdjęć + Szukaj linków + Szukaj wideo + Szukaj wiadomości głosowych Wybierz operatora sieci Wysłać prośbę o kontakt? Wyślij prywatne zgłoszenia @@ -2459,7 +2459,7 @@ Wyślij swoją prywatną opinię do grup. Wysłano do Twojego kontaktu po połączeniu. Serwer dodany do operatora %s. - Operator serwera zmieniony. + Operator serwera został zmieniony. Operatorzy serwera Protokół serwera zmieniony. Ustaw nazwę czatu… @@ -2554,4 +2554,7 @@ Twój profil Twoje serwery Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. + Połączenie nie powiodło się + niepowodzenie + Jeśli dołączyłeś do kanałów lub je utworzyłeś, przestaną one działać na stałe. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 3e5e09c039..a67f00a459 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -2542,4 +2542,7 @@ 搜索语音消息 视频 语音消息 + 连接失败 + 失败 + 如果你加入了或创建了频道,它们会永远停止工作。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index f9a1a5b131..05997a9fec 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1574,7 +1574,7 @@ 桌面設備 已連結桌面選項 已連結桌面 - 直接連線中 + 已請求連接 邀請 建立群組 修復群組成員不支援的問題 @@ -1677,7 +1677,7 @@ 連接中 錯誤 為群組停用回執? - 過往的成員 %1$s + 成員 %1$s 私密訊息路由 🚀 貼上封存連結 從桌面使用並掃描QR code。]]> @@ -1909,7 +1909,7 @@ 你分享了一個無效的檔案路徑。請將此問題報告給應用程式開發者。 如果沒有 Tor 或 VPN,你的 IP 位址將對以下 XFTP 中繼可見:\n%1$s。 檢視已崩潰 - 新增短連結 + 升級地址 接受了 %1$s 接受了你 新增團隊成員 @@ -2128,4 +2128,72 @@ 開啓新聊天 接受聯絡請求 接受聯絡請求 + 機械人 + 圖片 + 影片 + 檔案 + 連結 + 過濾器 + %d 個舉報 + 已棄用的選項 + 無訂閱 + 刪除訊息 + 搜尋圖片 + 搜尋影片 + 搜尋檔案 + 搜尋連結 + 語音訊息 + 所有訊息 + 重複加入請求? + 點擊以掃描 + 顯示內部錯誤 + 標準端對端加密 + 驗證資料庫密碼 + 顯示訊息狀態 + 設定預設主題 + 安全地接收檔案 + 臨時性檔案錯誤 + 重設所有統計 + 重設所有統計? + 有更新可用:%s + 略過此版本 + 更新下載已取消 + 儲存並重新連接 + 重設所有提示 + 自動升級應用程式 + 選擇聊天個人檔案 + 使用隨機憑證 + 儲存代理時發生錯誤 + 轉發訊息時發生錯誤 + 轉發 %1$s 條訊息? + 正在轉發 %1$s 條訊息 + 正在儲存 %1$s 條訊息 + 儲存伺服器時發生錯誤 + 接受條款時發生錯誤 + 公開地分享地址 + 用於社交媒體 + 用於訊息 + 用於私密路由 + 用於檔案 + 更新伺服器時發生錯誤 + 伺服器營運者已變更。 + 增加伺服器時發生錯誤 + 檢視已更新的條款 + 通知和電量 + 邀請加入聊天 + 使用 %s 開啟 + 沒有未讀聊天 + 找不到聊天 + 開啟以加入 + 開啟以連接 + 開啟以使用機械人 + 開啟以接受 + 搜尋或貼上 SimpleX 連結 + 你的每個訊息最多可以提及 %1$s 位成員! + 已透過代理傳送 + 伺服器統計資料將被重設—此操作無法撤銷! + 你沒有連接至這些伺服器。已使用私密路由將訊息傳送至這些伺服器。 + 不能在兩部裝置上使用同一資料庫。]]> + 警告:不支援在多個裝置上同時聊天,否則會導致訊息傳送失敗 + 或匯入封存檔案 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 3a93df406d..0f830e7b60 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -25,13 +25,27 @@ private val base64BitmapCache = Collections.synchronizedMap(object : LinkedHashM override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > 200 }) +private const val MAX_IMAGE_DIMENSION = 4320 + actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { base64BitmapCache[base64ImageString]?.let { return it } val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") return try { - ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap().also { + val bytes = Base64.getMimeDecoder().decode(imageString) + val stream = ImageIO.createImageInputStream(ByteArrayInputStream(bytes)) + val reader = ImageIO.getImageReaders(stream).next() + reader.setInput(stream) + val width = reader.getWidth(0) + val height = reader.getHeight(0) + if (width <= 0 || height <= 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION || height > width * 256) { + reader.dispose() + return errorBitmap() + } + val image = reader.read(0) + reader.dispose() + image.toComposeImageBitmap().also { base64BitmapCache[base64ImageString] = it } } catch (e: Throwable) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 9f34891b37..59d71a83f1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -5,6 +5,7 @@ import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* +import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent @@ -12,20 +13,77 @@ import java.io.File import java.util.* import kotlin.math.max +internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() } + actual class RecorderNative: RecorderInterface { + private var player: MediaPlayer? = null + private var progressJob: Job? = null + private var filePath: String? = null + private var recStartedAt: Long? = null + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { - /*LALAL*/ - return "" + VideoPlayerHolder.stopAll() + AudioPlayer.stop() + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) + fileToSave.deleteOnExit() + val path = fileToSave.absolutePath + filePath = path + val mrl = when { + desktopPlatform.isMac() -> "qtsound://" + desktopPlatform.isLinux() -> "pulse://" + desktopPlatform.isWindows() -> "dshow://" + else -> { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.voice_recording_not_supported)) + return "" + } + } + val sout = ":sout=#transcode{vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000}:std{access=file,mux=mp4,dst=$path}" + val options = mutableListOf(sout, ":sout-avcodec-strict=-2") + if (desktopPlatform.isWindows()) { + options.add(":dshow-vdev=none") + options.add(":dshow-adev=") + } + RecorderInterface.stopRecording = { stop() } + progressJob = CoroutineScope(Dispatchers.Default).launch { + // Shared factory init may take a few seconds on first VLC use — progress shows 0 until recording starts + val p = vlcFactory.mediaPlayers().newMediaPlayer() + player = p + p.media().play(mrl, *options.toTypedArray()) + recStartedAt = System.currentTimeMillis() + while (isActive) { + val ms = progress() + onProgressUpdate(ms, false) + if (ms != null && ms >= MAX_VOICE_MILLIS_FOR_SENDING) { + stop() + break + } + delay(50) + } + }.apply { + invokeOnCompletion { onProgressUpdate(realDuration(path), true) } + } + return path } override fun stop(): Int { - /*LALAL*/ - return 0 + val path = filePath ?: return 0 + RecorderInterface.stopRecording = null + runCatching { player?.controls()?.stop() } + runCatching { player?.release() } + runBlocking { progressJob?.cancelAndJoin() } + progressJob = null + filePath = null + player = null + return (realDuration(path) ?: 0).also { recStartedAt = null } } + + private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() } + + private fun realDuration(path: String): Int? = AudioPlayer.duration(path) ?: progress() } actual object AudioPlayer: AudioPlayerInterface { - private val player by lazy { AudioPlayerComponent().mediaPlayer() } + private val player by lazy { AudioPlayerComponent(vlcFactory).mediaPlayer() } override val currentlyPlaying: MutableState = mutableStateOf(null) private var progressJob: Job? = null @@ -170,7 +228,7 @@ actual object AudioPlayer: AudioPlayerInterface { override fun duration(unencryptedFilePath: String): Int? { var res: Int? = null try { - val helperPlayer = AudioPlayerComponent().mediaPlayer() + val helperPlayer = AudioPlayerComponent(vlcFactory).mediaPlayer() helperPlayer.media().startPaused(unencryptedFilePath) res = helperPlayer.duration helperPlayer.stop() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 50eeaee604..c5a38ec4a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -10,6 +10,7 @@ import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent +import uk.co.caprica.vlcj.player.component.MediaPlayerSpecs import java.awt.Component import java.awt.image.BufferedImage import java.io.File @@ -32,7 +33,7 @@ actual class VideoPlayer actual constructor( override val duration: MutableState = mutableStateOf(defaultDuration) override val preview: MutableState = mutableStateOf(defaultPreview) - val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } } + val mediaPlayerComponent by lazy { getOrCreatePlayer() } val player by lazy { mediaPlayerComponent.mediaPlayer() } init { @@ -207,9 +208,9 @@ actual class VideoPlayer actual constructor( private fun initializeMediaPlayerComponent(): Component { return if (desktopPlatform.isMac()) { - CallbackMediaPlayerComponent() + CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) } else { - EmbeddedMediaPlayerComponent() + EmbeddedMediaPlayerComponent(MediaPlayerSpecs.embeddedMediaPlayerSpec().apply { withFactory(vlcFactory) }) } } @@ -277,7 +278,7 @@ actual class VideoPlayer actual constructor( private fun putPlayer(player: Component) = playersPool.add(player) - private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent() + private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player) } } diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 1e7bda37c4..8f072539e8 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -73,6 +73,12 @@ compose { iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.icns")) appCategory = "public.app-category.social-networking" bundleID = "chat.simplex.app" + infoPlist { + extraKeysRawXml = """ + NSMicrophoneUsageDescription + SimpleX needs microphone access to record voice messages + """ + } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? val keychain = rootProject.extra["desktop.mac.signing.keychain"] as String? val appleId = rootProject.extra["desktop.mac.notarization.apple_id"] as String? diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 1354ce0cf3..1926f35f0f 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5-beta.5 -android.version_code=335 +android.version_name=6.5-beta.7 +android.version_code=339 android.bundle=false -desktop.version_name=6.5-beta.5 -desktop.version_code=131 +desktop.version_name=6.5-beta.7 +desktop.version_code=134 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 3b8391ada0..34b63ff06a 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -326,6 +326,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName notifyAdminUsers msg logError msg groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d + knockingStr :: Maybe GroupMemberAdmission -> [Text] + knockingStr = \case + Just GroupMemberAdmission {review = Just MCAll} -> ["New members are reviewed by admins"] + _ -> [] groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} = n <> maybe "" (\d' -> " (" <> d' <> ")") descr where @@ -485,9 +489,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GroupInfo {groupId, groupProfile = p} = fromGroup GroupInfo {groupProfile = p'} = toGroup sameProfile - GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d} - GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d'} = - n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') + GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} + GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = + n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') && ma == ma' groupLinkAdded gr byMember = getDuplicateGroup toGroup >>= \case Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e @@ -532,9 +536,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName checkRolesSendToApprove gr' n' where onlyLinkChanged - GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d} - GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d'} = - dn == dn' && fn == fn' && i == i' && sd == sd' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') + GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} + GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = + dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where @@ -586,7 +590,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName mc <- getCaptchaContent s sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] where - sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False gmId = groupMemberId' m sendVoiceCaptcha :: SendRef -> String -> IO () @@ -636,7 +640,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText | memberRequiresCaptcha a m = do let gmId = groupMemberId' m - sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False -- /audio is matched as text, not as DirectoryCmd, because it is only valid -- in group context at captcha stage, while DirectoryCmd is for DM commands. isAudioCmd = T.strip msgText == "/audio" @@ -672,9 +676,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g rejectPendingMember rjctNotice = do let gmId = groupMemberId' m - sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice] sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case - Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + Right (CRUserDeletedMembers _ _ (_ : _) _ _) -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected remove member response: " <> tshow r @@ -996,10 +1000,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary = GroupSummary {currentMembers}}, _) = + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary = GroupSummary {currentMembers}}, _) = let membersStr = "_" <> tshow currentMembers <> " members_" showId = if isAdmin then tshow groupId <> ". " else "" - text = showId <> groupInfoText p <> "\n" <> membersStr + text = T.unlines $ [showId <> groupInfoText p, membersStr] ++ knockingStr memberAdmission in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).") @@ -1181,14 +1185,14 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs' where groupMessage ((g, gr), ct_) = - let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary} = g + let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary} = g GroupReg {userGroupRegId, groupRegStatus} = gr useGroupId = if isAdmin then groupId else userGroupRegId statusStr = "Status: " <> groupRegStatusText groupRegStatus membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_" cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'" ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_ - text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr, cmds] + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr] ++ knockingStr memberAdmission ++ [cmds] msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ in (Nothing, msg) diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index e22f4ed470..aa101d7bf7 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -22,8 +22,9 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store.Groups (getGroupInfo, getHostMember) +import Simplex.Chat.Store.Groups (getHostMember) import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Store.Shared (getGroupInfo) import Simplex.Chat.Types import Simplex.Messaging.Agent.Store.Common import qualified Simplex.Messaging.Agent.Store.DB as DB diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 9f63770df6..b5d49077c0 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -30,6 +30,8 @@ This file is generated automatically. - [APILeaveGroup](#apileavegroup) - [APIListMembers](#apilistmembers) - [APINewGroup](#apinewgroup) +- [APINewPublicGroup](#apinewpublicgroup) +- [APIGetGroupRelays](#apigetgrouprelays) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -596,7 +598,7 @@ Add contact to group. Requires bot to have Admin role. **Syntax**: ``` -/_add # observer|author|member|moderator|admin|owner +/_add # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -675,7 +677,7 @@ Accept group member. Requires Admin role. **Syntax**: ``` -/_accept member # observer|author|member|moderator|admin|owner +/_accept member # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -718,7 +720,7 @@ Set members role. Requires Admin role. **Syntax**: ``` -/_member role # [,...] observer|author|member|moderator|admin|owner +/_member role # [,...] relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -737,6 +739,7 @@ MembersRoleUser: Members role changed by user. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -778,6 +781,7 @@ MembersBlockedForAllUser: Members blocked for all by admin. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - blocked: bool +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -819,6 +823,7 @@ UserDeletedMembers: Members deleted. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - withMessages: bool +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -943,6 +948,86 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APINewPublicGroup + +Create public group. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- incognito: bool +- relayIds: [int64] +- groupProfile: [GroupProfile](./TYPES.md#groupprofile) + +**Syntax**: + +``` +/_public group [ incognito=on] [,...] +``` + +```javascript +'/_public group ' + userId + (incognito ? ' incognito=on' : '') + ' ' + relayIds.join(',') + ' ' + JSON.stringify(groupProfile) // JavaScript +``` + +```python +'/_public group ' + str(userId) + (' incognito=on' if incognito else '') + ' ' + ','.join(map(str, relayIds)) + ' ' + json.dumps(groupProfile) # Python +``` + +**Responses**: + +PublicGroupCreated: Public group created. +- type: "publicGroupCreated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### 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. @@ -975,6 +1060,7 @@ GroupUpdated: Group updated. - fromGroup: [GroupInfo](./TYPES.md#groupinfo) - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -1001,7 +1087,7 @@ Create group link. **Syntax**: ``` -/_create link # observer|author|member|moderator|admin|owner +/_create link # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1040,7 +1126,7 @@ Set member role for group link. **Syntax**: ``` -/_set link role # observer|author|member|moderator|admin|owner +/_set link role # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1521,6 +1607,7 @@ GroupDeletedUser: User deleted group. - type: "groupDeletedUser" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index d7405ef846..947c60586a 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -38,6 +38,8 @@ This file is generated automatically. - [MemberAcceptedByOther](#memberacceptedbyother) - [MemberBlockedForAll](#memberblockedforall) - [GroupMemberUpdated](#groupmemberupdated) + - [GroupLinkDataUpdated](#grouplinkdataupdated) + - [GroupRelayUpdated](#grouprelayupdated) [File events](#file-events) - Main events @@ -300,6 +302,7 @@ Group profile or preferences updated. - fromGroup: [GroupInfo](./TYPES.md#groupinfo) - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -329,6 +332,7 @@ Member (or bot user's) group role changed. - member: [GroupMember](./TYPES.md#groupmember) - fromRole: [GroupMemberRole](./TYPES.md#groupmemberrole) - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -344,6 +348,7 @@ Another member is removed from the group. - byMember: [GroupMember](./TYPES.md#groupmember) - deletedMember: [GroupMember](./TYPES.md#groupmember) - withMessages: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -357,6 +362,7 @@ Another member left the group. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -371,6 +377,7 @@ Bot user was removed from the group. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) - withMessages: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -384,6 +391,7 @@ Group was deleted by the owner (not bot user). - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -427,6 +435,7 @@ Another member blocked for all members. - byMember: [GroupMember](./TYPES.md#groupmember) - member: [GroupMember](./TYPES.md#groupmember) - blocked: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -445,6 +454,35 @@ Another group member profile updated. --- +### GroupLinkDataUpdated + +Group link data updated. + +**Record type**: +- type: "groupLinkDataUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] +- relaysChanged: bool + +--- + + +### GroupRelayUpdated + +Group relay member updated. + +**Record type**: +- type: "groupRelayUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- groupRelay: [GroupRelay](./TYPES.md#grouprelay) + +--- + + ## File events Bots that send or receive files may process these events to track delivery status and to process completion. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4840a2b169..c5734c2eda 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -90,6 +90,7 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) +- [GroupKeys](#groupkeys) - [GroupLink](#grouplink) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) @@ -102,9 +103,13 @@ This file is generated automatically. - [GroupPreference](#grouppreference) - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) +- [GroupRelay](#grouprelay) +- [GroupRootKey](#grouprootkey) - [GroupShortLinkData](#groupshortlinkdata) +- [GroupShortLinkInfo](#groupshortlinkinfo) - [GroupSummary](#groupsummary) - [GroupSupportChat](#groupsupportchat) +- [GroupType](#grouptype) - [HandshakeError](#handshakeerror) - [InlineFileMode](#inlinefilemode) - [InvitationLinkPlan](#invitationlinkplan) @@ -121,6 +126,7 @@ This file is generated automatically. - [MsgFilter](#msgfilter) - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) +- [MsgSigStatus](#msgsigstatus) - [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) @@ -132,6 +138,8 @@ This file is generated automatically. - [Profile](#profile) - [ProxyClientError](#proxyclienterror) - [ProxyError](#proxyerror) +- [PublicGroupData](#publicgroupdata) +- [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) - [RcvConnEvent](#rcvconnevent) @@ -140,6 +148,8 @@ This file is generated automatically. - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) +- [RelayProfile](#relayprofile) +- [RelayStatus](#relaystatus) - [ReportReason](#reportreason) - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) @@ -164,6 +174,7 @@ This file is generated automatically. - [UIThemeEntityOverrides](#uithemeentityoverrides) - [UpdatedMessage](#updatedmessage) - [User](#user) +- [UserChatRelay](#userchatrelay) - [UserContact](#usercontact) - [UserContactLink](#usercontactlink) - [UserContactRequest](#usercontactrequest) @@ -601,6 +612,9 @@ GroupRcv: - type: "groupRcv" - groupMember: [GroupMember](#groupmember) +ChannelRcv: +- type: "channelRcv" + LocalSnd: - type: "localSnd" @@ -771,6 +785,7 @@ Group: - editable: bool - forwardedByMember: int64? - showGroupAsSender: bool +- msgSigned: [MsgSigStatus](#msgsigstatus)? - createdAt: UTCTime - updatedAt: UTCTime @@ -957,6 +972,9 @@ UserExists: - type: "userExists" - contactName: string +ChatRelayExists: +- type: "chatRelayExists" + DifferentActiveUser: - type: "differentActiveUser" - commandUserId: int64 @@ -1204,6 +1222,10 @@ ConnectionUserChangeProhibited: PeerChatVRangeIncompatible: - type: "peerChatVRangeIncompatible" +RelayTestError: +- type: "relayTestError" +- message: string + InternalError: - type: "internalError" - message: string @@ -2153,6 +2175,7 @@ MemberSupport: **Record type**: - groupId: int64 - useRelays: bool +- relayOwnStatus: [RelayStatus](#relaystatus)? - localDisplayName: string - groupProfile: [GroupProfile](#groupprofile) - localAlias: string @@ -2172,6 +2195,17 @@ MemberSupport: - groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? +- groupKeys: [GroupKeys](#groupkeys)? + + +--- + +## GroupKeys + +**Record type**: +- publicGroupId: string +- groupRootKey: [GroupRootKey](#grouprootkey) +- memberPrivKey: string --- @@ -2195,6 +2229,7 @@ MemberSupport: Ok: - type: "ok" +- groupSLinkInfo_: [GroupShortLinkInfo](#groupshortlinkinfo)? - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? OwnLink: @@ -2238,6 +2273,8 @@ Known: - createdAt: UTCTime - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? +- memberPubKey: string? +- relayLink: string? --- @@ -2274,6 +2311,7 @@ Known: ## GroupMemberRole **Enum type**: +- "relay" - "observer" - "author" - "member" @@ -2348,16 +2386,55 @@ Known: - shortDescr: string? - description: string? - image: string? +- publicGroup: [PublicGroupProfile](#publicgroupprofile)? - groupPreferences: [GroupPreferences](#grouppreferences)? - memberAdmission: [GroupMemberAdmission](#groupmemberadmission)? +--- + +## GroupRelay + +**Record type**: +- groupRelayId: int64 +- groupMemberId: int64 +- userChatRelay: [UserChatRelay](#userchatrelay) +- relayStatus: [RelayStatus](#relaystatus) +- relayLink: string? + + +--- + +## GroupRootKey + +**Discriminated union type**: + +Private: +- type: "private" +- rootPrivKey: string + +Public: +- type: "public" +- rootPubKey: string + + --- ## GroupShortLinkData **Record type**: - groupProfile: [GroupProfile](#groupprofile) +- publicGroupData: [PublicGroupData](#publicgroupdata)? + + +--- + +## GroupShortLinkInfo + +**Record type**: +- direct: bool +- groupRelays: [string] +- publicGroupId: string? --- @@ -2366,6 +2443,7 @@ Known: **Record type**: - currentMembers: int64 +- publicMemberCount: int64? --- @@ -2380,6 +2458,14 @@ Known: - lastMsgFromMemberTs: UTCTime? +--- + +## GroupType + +**Enum type**: +- "channel" + + --- ## HandshakeError @@ -2652,6 +2738,15 @@ Unknown: - "badMsgHash" +--- + +## MsgSigStatus + +**Enum type**: +- "verified" +- "signedNoKey" + + --- ## NetworkError @@ -2687,6 +2782,7 @@ SubscribeError: **Record type**: - profile: [Profile](#profile)? - pastTimestamp: bool +- userChatRelay: bool - clientService: bool @@ -2823,6 +2919,24 @@ NO_SESSION: - type: "NO_SESSION" +--- + +## PublicGroupData + +**Record type**: +- publicMemberCount: int64 + + +--- + +## PublicGroupProfile + +**Record type**: +- groupType: [GroupType](#grouptype) +- groupLink: string +- publicGroupId: string + + --- ## RCErrorType @@ -3059,6 +3173,31 @@ MemberProfileUpdated: NewMemberPendingReview: - type: "newMemberPendingReview" +MsgBadSignature: +- type: "msgBadSignature" + + +--- + +## RelayProfile + +**Record type**: +- displayName: string +- fullName: string +- shortDescr: string? +- image: string? + + +--- + +## RelayStatus + +**Enum type**: +- "new" +- "invited" +- "accepted" +- "active" + --- @@ -3298,6 +3437,9 @@ UserNotFound: - type: "userNotFound" - userId: int64 +RelayUserNotFound: +- type: "relayUserNotFound" + UserNotFoundByName: - type: "userNotFoundByName" - contactName: string @@ -3401,6 +3543,9 @@ GroupWithoutUser: DuplicateGroupMember: - type: "duplicateGroupMember" +DuplicateMemberId: +- type: "duplicateMemberId" + GroupAlreadyJoined: - type: "groupAlreadyJoined" @@ -3589,6 +3734,18 @@ OperatorNotFound: UsageConditionsNotFound: - type: "usageConditionsNotFound" +UserChatRelayNotFound: +- type: "userChatRelayNotFound" +- chatRelayId: int64 + +GroupRelayNotFound: +- type: "groupRelayNotFound" +- groupRelayId: int64 + +GroupRelayNotFoundByMemberId: +- type: "groupRelayNotFoundByMemberId" +- groupMemberId: int64 + InvalidQuote: - type: "invalidQuote" @@ -3766,10 +3923,26 @@ Handshake: - sendRcptsSmallGroups: bool - autoAcceptMemberContacts: bool - userMemberProfileUpdatedAt: UTCTime? +- userChatRelay: bool - clientService: bool - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? +--- + +## UserChatRelay + +**Record type**: +- chatRelayId: int64 +- address: string +- relayProfile: [RelayProfile](#relayprofile) +- 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 db38592617..4c9b46c112 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -117,6 +117,8 @@ chatCommandsDocsData = ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), ("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") ] ), @@ -243,6 +245,7 @@ cliCommands = "MemberRole", "MuteUser", "NewGroup", + "NewPublicGroup", "QuitChat", "ReactToMessage", "RejectContact", @@ -367,6 +370,7 @@ undocumentedCommands = "APIGetUsageConditions", "APIGetUserServers", "APIGroupInfo", + "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", "APIGroupMemberQueueInfo", "APIHideUser", @@ -411,6 +415,7 @@ undocumentedCommands = "APISwitchGroupMember", "APISyncContactRatchet", "APISyncGroupMemberRatchet", + "APITestChatRelay", "APITestProtoServer", "APIUnhideUser", "APIUnmuteUser", @@ -443,6 +448,7 @@ undocumentedCommands = "GetChatItemTTL", "GetRemoteFile", "GetUserProtoServers", + "GetUserChatRelays", "ListRemoteCtrls", "ListRemoteHosts", "ReconnectAllServers", @@ -460,12 +466,14 @@ undocumentedCommands = "SetServerOperators", "SetTempFolder", "SetUserProtoServers", + "SetUserChatRelays", "SlowSQLQueries", "StartRemoteHost", "StopRemoteCtrl", "StopRemoteHost", "StoreRemoteFile", "SwitchRemoteHost", + "TestChatRelay", "TestProtoServer", "TestStorageEncryption", "VerifyRemoteCtrlSession" diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 9d43c4e51c..f0c9352efd 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -97,7 +97,9 @@ chatEventsDocsData = [ ("CEvtConnectedToGroupMember", "Connected to another group member."), ("CEvtMemberAcceptedByOther", "Another group owner, admin or moderator accepted member to the group after review (\"knocking\")."), ("CEvtMemberBlockedForAll", "Another member blocked for all members."), - ("CEvtGroupMemberUpdated", "Another group member profile updated.") + ("CEvtGroupMemberUpdated", "Another group member profile updated."), + ("CEvtGroupLinkDataUpdated", "Group link data updated."), + ("CEvtGroupRelayUpdated", "Group relay member updated.") ] ), ( "File events", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 60fe129cdb..873ca5eb97 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -68,6 +68,8 @@ chatResponsesDocsData = ("CRGroupLinkCreated", ""), ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), + ("CRPublicGroupCreated", ""), + ("CRGroupRelays", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), @@ -130,6 +132,7 @@ undocumentedResponses = "CRChatItemInfo", "CRChatItems", "CRChatItemTTL", + "CRChatRelayTestResult", "CRChats", "CRConnectionsDiff", "CRChatTags", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 21970ce419..37fc6121ce 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 @@ -256,7 +259,7 @@ chatTypesDocsData = (sti @FileError, STUnion, "FileErr", [], "", ""), (sti @FileErrorType, STUnion, "", [], "", ""), (sti @FileInvitation, STRecord, "", [], "", ""), - (sti @FileProtocol, (STEnum' $ consLower "FP"), "", [], "", ""), + (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @Format, STUnion, "", ["Unknown"], "", ""), @@ -269,21 +272,26 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), + (sti @GroupKeys, STRecord, "", [], "", ""), + (sti @GroupRootKey, STUnion, "GRK", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), - (sti @GroupMemberCategory, (STEnum' $ dropPfxSfx "GC" "Member"), "", [], "", ""), + (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), - (sti @GroupMemberRole, STEnum, "GR", [], "", ""), + (sti @GroupMemberRole, STEnum' (dropPfxSfx "GR" ""), "", ["GRUnknown"], "", ""), (sti @GroupMemberSettings, STRecord, "", [], "", ""), - (sti @GroupMemberStatus, (STEnum' $ (\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), + (sti @GroupMemberStatus, STEnum' ((\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), (sti @GroupPreference, STRecord, "", [], "", ""), (sti @GroupPreferences, STRecord, "", [], "", ""), (sti @GroupProfile, STRecord, "", [], "", ""), + (sti @GroupRelay, STRecord, "", [], "", ""), (sti @GroupShortLinkData, STRecord, "", [], "", ""), + (sti @GroupShortLinkInfo, STRecord, "", [], "", ""), (sti @GroupSummary, STRecord, "", [], "", ""), (sti @GroupSupportChat, STRecord, "", [], "", ""), + (sti @GroupType, STEnum1, "GT", ["GTUnknown"], "", ""), (sti @HandshakeError, STEnum, "", [], "", ""), (sti @InlineFileMode, STEnum, "IFM", [], "", ""), (sti @InvitationLinkPlan, STUnion, "ILP", [], "", ""), @@ -300,6 +308,7 @@ chatTypesDocsData = (sti @MsgFilter, STEnum, "MF", [], "", ""), (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), + (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), @@ -312,6 +321,8 @@ chatTypesDocsData = (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), (sti @ProxyError, STUnion, "", [], "", ""), + (sti @PublicGroupData, STRecord, "", [], "", ""), + (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), (sti @RcvConnEvent, STUnion, "RCE", [], "", ""), @@ -320,7 +331,9 @@ chatTypesDocsData = (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), - (sti @ReportReason, (STEnum' $ dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), + (sti @RelayProfile, STRecord, "", [], "", ""), + (sti @RelayStatus, STEnum, "RS", [], "", ""), + (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), @@ -344,6 +357,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, "", [], "", ""), @@ -456,6 +470,8 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo +deriving instance Generic GroupKeys +deriving instance Generic GroupRootKey deriving instance Generic GroupLink deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember @@ -468,7 +484,10 @@ deriving instance Generic GroupMemberStatus deriving instance Generic GroupPreference deriving instance Generic GroupPreferences deriving instance Generic GroupProfile +deriving instance Generic GroupRelay deriving instance Generic GroupShortLinkData +deriving instance Generic GroupShortLinkInfo +deriving instance Generic GroupType deriving instance Generic GroupSummary deriving instance Generic GroupSupportChat deriving instance Generic HandshakeError @@ -493,6 +512,7 @@ deriving instance Generic MsgErrorType deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus +deriving instance Generic MsgSigStatus deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder @@ -505,6 +525,8 @@ deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError deriving instance Generic ProxyError +deriving instance Generic PublicGroupData +deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState deriving instance Generic RCErrorType deriving instance Generic RcvConnEvent @@ -513,6 +535,8 @@ deriving instance Generic RcvFileDescr deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent +deriving instance Generic RelayProfile +deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexLinkType @@ -535,6 +559,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 a70de72d01..37f74e4275 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -1,5 +1,4 @@ {-# LANGUAGE AllowAmbiguousTypes #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -8,9 +7,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeOperators #-} -{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} @@ -170,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" [] @@ -210,6 +209,8 @@ toTypeInfo tr = "MemberId", "Text", "MREmojiChar", + "PrivateKey", + "PublicKey", "ProtocolServer", "SbKey", "SharedMsgId", diff --git a/cabal.project b/cabal.project index 35a6e3265f..12250de34b 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 5f08457b7e5cd6e42f03a3d5bcabd716afd8b91c + tag: 99f9de71e5df213bb062fa11dd165778fc1d7160 source-repository-package type: git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index f0e9466c61..95cf972c0d 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -8,6 +8,8 @@ revision: 09.09.2024 You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). +If you cannot access GitHub, you can download SimpleX Chat apps from our mirror at [git.simplex.chat](https://git.simplex.chat/simplex-chat/simplex-chat/releases) + - [desktop](#desktop-app) - [mobile](#mobile-apps) - [terminal](#terminal-console-app) (console) diff --git a/docs/contributing/CODE.md b/docs/contributing/CODE.md index 7ae6d176ac..1dcf795c00 100644 --- a/docs/contributing/CODE.md +++ b/docs/contributing/CODE.md @@ -2,6 +2,12 @@ This file provides guidance on coding style and approaches and on building the code. +## Code Security + +When designing code and planning implementations: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. +- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances. + ## Code Style, Formatting and Approaches The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`. @@ -38,9 +44,16 @@ Some files that use CPP language extension cannot be formatted as a whole, so in **Diff and refactoring:** - Avoid unnecessary changes and code movements +- Never rename existing variables, parameters, or functions unless the rename is the point of the change - Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring - Aim to minimize the code changes - do what is minimally required to solve users' problems +**Type-driven development:** +- Types must reflect business semantics, not data shape. E.g., `CIChannelRcv` (channel message) vs `CIGroupRcv GroupMember` (member message) are semantically distinct — do not collapse them into `CIGroupRcv (Maybe GroupMember)` just because the data overlaps. Duplicate pattern match arms across semantic constructors are acceptable. +- Duplicate function bodies are not acceptable. When adding a new variant of existing behavior, parameterize existing functions to handle both variants — do not copy function bodies into parallel code paths. +- Concrete example: if `groupMessageFileDescription` and `channelMessageFileDescription` share 90% of their logic, extract a shared helper and make both into thin wrappers — do not maintain two near-identical function bodies. +- When the return type differs between variants (e.g., one returns `Maybe X`, another returns `()`), use the more general return type and have callers discard what they don't need. + **Document and code structure:** - **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure. - When adding new sections to documents, continue the existing numbering scheme. diff --git a/docs/rfcs/2025-04-14-signing-messages.md b/docs/rfcs/2025-04-14-signing-messages.md index 8845de0cd8..1ad6e6778f 100644 --- a/docs/rfcs/2025-04-14-signing-messages.md +++ b/docs/rfcs/2025-04-14-signing-messages.md @@ -81,3 +81,13 @@ Cons: - two-stage decoding may be seen as a downside, but it is offset by the fact that re-encodings are avoided, and under the hood JSON is decoded in stages anyway. While deterministic JSON is [quite simple](https://github.com/simplex-chat/aeson/pull/4/files) for aeson implementation, the Option 2 seems more attractive overall, as it avoids questionable design of including signatures into JSON and the need to re-encode JSON to sign and to verify signatures. + +## Signing scope: roster changes only, not content messages + +Only roster-modifying and group management messages are signed (e.g. `XGrpMemNew`, `XGrpMemRole`, `XGrpMemDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpDel`). Regular content messages (`XMsgNew`, etc.) are not signed. + +Two reasons: + +1. **Deniability.** Signing content messages would create non-repudiable proof of authorship — any party with access to the message bytes could prove who wrote a specific message. This is antithetical to SimpleX's privacy model, where messages should be deniable. Administrative actions (adding/removing members, changing roles) don't need deniability — they are organizational actions, not personal communications. + +2. **Different threat model.** Content message manipulation by relays is detectable post-hoc: with multiple independent relays, members can cross-check message consistency and detect forgery after the fact. This is sufficient for content because content delivery is not irreversible — a forged message can be flagged and corrected. Roster and profile changes, on the other hand, are disruptive and irreversible (a member removed, a role changed, a group deleted). By the time forgery is detected, the damage is done. These actions must be authenticated at processing time, before they take effect. diff --git a/docs/rfcs/2025-10-20-chat-relays.md b/docs/rfcs/2025-10-20-chat-relays.md new file mode 100644 index 0000000000..d1a3180b70 --- /dev/null +++ b/docs/rfcs/2025-10-20-chat-relays.md @@ -0,0 +1,304 @@ +# Chat relays + +## Security objectives + +Group relay protocol should achieve following objectives: +1. Stable message delivery between group members. +2. No possibility for relay to substitute group. +3. No possibility for relay to impersonate owner(s). +4. Prevent relay from altering member roster (member removal, role change, etc.). +5. Prevent relay from terminally destabilizing group by stopping to serve it. At the same time, allow owner to remove (last) relay with possibility to restore group functionality. +6. Allow owner(s) to send messages as "message from channel", hiding specific sender out of multiple owners from members. +7. Prevent relays from altering/dropping messages. + +## Protocol for adding chat relays to group + +Activations (execution bars) with looped arrows indicate internal calls/steps. + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay(s) + participant RSMP as Chat relays'
SMP server(s) + +note over O, RSMP: Owner creates new group, adds chat relays + +activate O +O ->> O: 1. Create new group
(user action) +O ->> O: 2. Prepare group link,
owner key,
group ID (agent) +O ->> O: 3. Add link, owner key
to group profile, sign +O ->> OSMP: 4. Create group link,
signed profile as data +deactivate O +OSMP -->> O: Group link created +activate O +O ->> O: 5. Choose chat relays
(automatic/user choice) +note left of O: Relay status: New +par With each relay + O ->> R: 6. Contact request
(x.grp.relay.inv
incl. group link) + deactivate O + activate R + note left of O: Relay status: Invited + note right of R: Relay status: Invited + R ->> OSMP: 7. Retrieve group link data + deactivate R + OSMP -->> R: Group link data + activate R + R ->> R: 8. Validate group profile,
verify profile signature + opt Bad profile or signature + R -x R: Abort (reject) + end + R ->> RSMP: 9. Create relay link,
set group ID
in immutable data + deactivate R + RSMP -->> R: Relay link created + activate R + R ->> O: 10. Accept request
(x.grp.relay.acpt
incl. relay link) + deactivate R + activate O + note right of R: Relay status: Accepted + note left of O: Relay status: Accepted + note over O, R: RPC connection
with relay is ready + opt Protocol extension - 2 connections + O ->> R: * Connect via relay link
(share same owner key) + deactivate O + R -->> O: Accept messaging connection + activate O + note right of R: Relay status: Accepted,
"Connected" implied from
messaging connection + note left of O: Relay status: Accepted,
"Connected" implied from
messaging connection + note over O, R: Owner: Messaging connection with relay is ready,
relay link is tested + end + create participant M as Member + R --> M: + note over R, M: At this point relay can accept
connection requests from members + O ->> RSMP: 11. Retrieve relay link data + deactivate O + RSMP -->> O: Relay link data + activate O + O ->> O: 12. Validate group ID
in relay link data + opt Bad group ID + O -x O: Abort for relay (don't add) + end + O ->> OSMP: 13. Update group link
(add relay link) + deactivate O + OSMP -->> O: Group link updated + note left of O: Relay status: Active +end + +note over O, M: Chat relay checks link - monitoring + +loop Periodically + R ->> OSMP: Retrieve group link data for served gorup + OSMP -->> R: Group link data + activate R + R ->> R: Check relay link present + deactivate R + note right of R: Relay status: Active +end + +note over O, M: New member connects + +O -->> M: 14. Share group link
(social, out-of-band) +M ->> OSMP: 15. Retrieve short link data +par RPC connection + M ->> R: 16a. Connect via relay link +and + opt Protocol extension - Messaging connection + M ->> R: 16b*. Connect via relay link
(share same member key/
identifier to correlate) + end +end + +note over O, M: Message forwarding + +O ->> R: 17. Send message +R ->> M: 18. Forward message +activate M +M ->> M: 19. Deduplicate message +deactivate M +``` + +Notes: + +- Group ID - unique group identifier (not globally unique) baked in immutable part of group link data, and repeated by chat relays in immutable parts of respective relay links. + + Owner can validate they're adding relay link to the group link specifically for their group. + + Members can validate they join relay links corresponding to group link they connected to. + +- Protocol extension: Create connections pairs between relay and members with different priority for passing regular messages and for relay responding to member requests. + + Invitation sent in step 12 should contain same key as in group link, for relay to match connection to the same owner and "active" relay link (add to `XContact` message). + + Add new connection entity, special for groups with relay, referencing member record - parallel to first member connection. + +- Client can "know" link that will be created before creating it on server - so we can add it to profile before adding profile to group short link data. + + Agent to return link that will be created upon preparing connection record. + +- On adding group short link to group profile. + + Strengthens association between link and profile. Link already contains profile in attached data, but from perspective of group profile link itself is detached. All members "see" the same link they joined via in group profile. Chat relays "see" the same link they created relay links for, and can check it for presence of their relay link at any point. + + Link is recoverable from profile, e.g. for purpose of restoring connection with group via new chat relays. + + Overall it just seems a natural and convenient way to store group link for all members, rather than having it separately. + +- On updating group link data with one relay link at a time vs waiting for all links. + + Overhead is minimal - one request to owner's SMP server per relay. + + Waiting for a relay to send relay link can take indefinitely long. + + In proposed protocol owner doesn't have to wait for links from all relays for simplicity and to minimize wait time - it allows owner to conclude group creation potentially earlier, in case some relays are stuck or offline (owner can add their links later, once they successfully send it). + +- Lock owner group link from accepting connection on SMP server, possibly has some implementation gaps. + + Reject in owner code for foolproofing. + +- What should be in relay link user data: + + - Relay key for group. + - Relay identity if provided. + Operator relays want to provide identity for trust. + User relays may not want to provide identity. + Relay identity: profile, certificate, relay identity key (global across groups). + +## Protocol for removing chat relay from group, restoring connection to group + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay + participant RSMP as Chat relay
SMP server + participant M as Member + +note over O, M: Owner deletes chat relay, notifies relay + +O ->> OSMP: Remove relay link
(update group link data) +O ->> R: Delete chat relay
(x.grp.mem.del)
over RPC connection +par Chat relay to SMP + R ->> RSMP: Delete relay link +and Chat relay to members + R ->> M: Forward relay is deleted
over RPC connection +end + +note over O, M: Scenario 2. Owner deletes chat relay, fails to notify relay + +O ->> OSMP: Remove relay link
(update group link data) +O --x R: Fail to notify relay +opt Chat relay identifies
connection with owner is deleted + par Chat relay to SMP + destroy RSMP + R ->> RSMP: Delete relay link + and Chat relay to members + destroy R + R ->> M: Notify relay is deleted
over RPC connection + end +end + +note over O, M: Last relay is deleted + +O --x M: Owner can't send messages to members +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +activate M +M -x M: Members can't restore connection to group +deactivate M + +note over O, M: Restore connection to group + +create participant NR as New chat relay +O <<->> NR: Add new relay, relay creates and sends link +O <<->> OSMP: Update group link
(add relay link) +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +par RPC connection + M ->> NR: Connect via relay link +and Messaging connection + M ->> NR: Connect via relay link
(share same member key/
identifier to correlate) +end +O ->> NR: Send message +NR ->> M: Forward message +activate M +M ->> M: Deduplicate message +deactivate M +``` + +Notes: + +- New relay doesn't have group history. + + - We can prohibit to remove last relay without adding new one. + - Relays can synchronize history. + - Can be considered after MVP. + +## Correlation of design objectives with design elements + +1. Redundant delivery by multiple relays. High availability of relay clients. +2. Same group ID baked in immutable data of group link and relay links. +3. Owner public key in group link. +4. Actions altering member roster can be signed by owner key, verified by members. +5. Protocol for restoring connection to group by checking group link for new relays. +6. XMsgNew protocol extension - "message from channel" flag - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). +7. Redundant delivery by multiple relays, highlighting deduplicated messages differences - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). + +## Threat model + +**Single compromised chat relay / Colluding chat relays** + +can: +- effectively substitute group bar group ID and signed profile, by sending unsigned content from other group (or any arbitrary content), that doesn't require signature verification, such as regular messages. + - one way this could be further mitigated is requiring owner to sign all messages. + - owner could periodically sign message history as merkle dag. +- selectively drop any content or service messages from owner, including actions altering member roster. +- selectively drop messages for some of members. + +cannot: +- technically, redirect newly joining member to a different group. +- substitute group profile. +- impersonate owner, send any member message that requires signature. + +**Compromised chat relay (in situation where not all relays are compromised/colluding)** + +can: +- in case number of compromised relays is same as number of uncompromised ones, compromised relay(s) can drop messages or send arbitrary unsigned messages, misleading members from identifying which relays are compromised. +- ignore "message from channel" directive from owner, revealing which owner sent message. + - this can be revealed to owner by members out-of-band. +- fabricate new members, possibly inflating counts/costs for owner (depends on implementation). + - it can be identified that these imaginary members don't connect to other relays. + +**Member** + +can: +- infer which owner sent message as "message from channel", if group has a single owner. + - owner client should prohibit this option if group has a single owner. + +**Any client** + +can: +- connect to group unlimited number of times, inflating real counts/costs. + +## TODO list + +- Chat commands for creating group with relays. +- Protocol events processing. +- Recovery for both owner and relay when adding relay to group. +- On each subscription retrieve group link data for all groups, actualize connections for present relay links. +- Agent `prepareConnectionToJoin` api to return link that will be created. +- Asynchronous version of agent `setConnShortLink` api, correlation in chat. +- Agent to support adding relays to link (it has stub `relays :: [ConnShortLink 'CMContact]`). +- New connection entity for secondary member-in-relayed-group connection - priority/messages connections. +- Differentiate connection usage by priority in chat logic (receiving messages vs sending requests to relay). +- Finalize model - statuses, schema. +- UI for relay management (user level, similar to list of servers). +- UI for creating group with relays. +- UI for managing relays in group. +- Relay status updates events on adding relays for UI integration. +- Relay removal. +- Relay periodic checks for monitoring relay link presence. diff --git a/docs/rfcs/2026-01-08-relays-new-member-connection.md b/docs/rfcs/2026-01-08-relays-new-member-connection.md new file mode 100644 index 0000000000..471f4ed53f --- /dev/null +++ b/docs/rfcs/2026-01-08-relays-new-member-connection.md @@ -0,0 +1,99 @@ +# Connection of new member to chat relays + +## Problem + +Naive implementation of new member connection to chat relays can lead to partial failures (some relays fail to connect), or requires recovery or clean up. + +After group record is prepared from short link, naive flow is as follows (APIConnectPreparedGroup): + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: + -> Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Join connection (sync joinConnection) +``` + +Orthogonal smaller problem: + +If new member chooses to connect to group incognito, same incognito profile should be sent to all group relays. + +## Solution + +### Join Connection step + +"Join connection" is the main step, let's consider it first. + +#### Option 1: Synchronous approach with catches + recovery + +Keep all relay connections synchronous, catch on failure to continue for remaining relays, recovery for failed relays. All relays failing would mean full command failure, offer user retry. + +For partial failures it would require to track which relays succeeded/failed, then trigger recovery, basically recreating what asynchronous command processing already does. + +#### Option 2: First relay sync, then async + +Connect to first relay synchronously, connect to remaining asynchronously (using joinConnectionAsync). + +Choice of "first" relay is arbitrary and we may be choosing the one with worse network. + +Mixed (double) implementation - for "first" and remaining relays. + +#### Option 3: All relays async + +In this case agent already handles connection reliability, downside is no immediate failure visible to user on temporary network errors for all relays (for example, client is offline). + +UI already handles "connecting..." state, so async path doesn't hurt UX much other than in mentioned case. UI stays in "connecting..." until at least one relay connection succeeds. + +If all relay connections permanently fail, update state for UI - requires permanent error handling for connection creation on continuation (agent responses in Subscriber). Track relay connection states to detect "all failed", possibly on connection status, TBC at implementation. + +Pros: +- Simple flow: loop through relays, start async connections. +- Async agent commands provide recovery. + +### Link fetches + +We considered handling retries for Join step, but no retry mechanism for link fetch. If it's synchronous and fails for a given relay, it would result in permanent failure to connect to relay, without additional recovery logic. + +#### Option 1: Asynchronous command with continuation + +New agent asynchronous command + complexity in chat Subscriber logic. Seems overkill. + +#### Option 2: Per-relay "relay connection" worker + +An additional state machine, possibly based on relay member records as work items. Also overkill. + +#### Option 3: Make all link fetches synchronously before proceeding + +To avoid adding background recovery mechanisms for link fetching per relay, we could fetch all links data synchronously, and only then connect to relays asynchronously. + +In case any relay link fetch fails, user would be given option to retry. (Whole operation fails and is retried) + +Group link fetch is also synchronous (retrieve list of relay links), and also leads to immediate user retry. + +### On the incognito profile issue + +This should be addressed regardless of which approach to connection we choose. The incognito profile should be: + +1. Created once before starting any relay connections; +2. Passed to all relays on connection attempts. + +In case of synchronous approach and re-use of existing logic, it means `connectViaContact` should accept an optional profile (not just flag). + +### Overall proposed connection flow + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Once all links are resolved, proceed - create incognito profile ONCE for all relays, if needed + -> For each relay: Start async connection attempt (joinConnectionAsync) + -> Agent handles connection retries internally + -> Subscriber handles JOINED events and errors for each relay + - At least one relay JOINED -> group becomes functional + - All relays permanently fail -> show failure to user +``` + +Link fetches being synchronous in conjunction with asynchronous relay connections allows for similar UI reactivity to current single-connection flows: +- Network failures during link fetches require user retry; +- Connection attempts are retried by agent on network failures; +- Link fetches passing ensures client is not offline when starting async connection attempts (unless user goes offline in-between, but window is very small, and connections would be retried anyway). diff --git a/docs/rfcs/2026-01-23-member-keys-plan.md b/docs/rfcs/2026-01-23-member-keys-plan.md new file mode 100644 index 0000000000..b278cf0c32 --- /dev/null +++ b/docs/rfcs/2026-01-23-member-keys-plan.md @@ -0,0 +1,652 @@ +# Implementation Plan: Member Keys and Signatures for Simplex Chat + +## Overview + +Add cryptographic signatures to Simplex Chat messages to prevent relay impersonation and roster manipulation in public groups with chat relays. + +## Design Approach + +Following **RFC Option 2: Multi-stage encoding** (recommended in docs/rfcs/2025-04-14-signing-messages.md): +- Encoded JSON body (non-deterministic key ordering OK) +- Conversation binding (group root key + sender member ID for groups) +- Array of (key reference, signature) tuples + +## Key Files to Modify + +### Core Types +- `src/Simplex/Chat/Types.hs` - Add `MemberKey` type, add `memberKey` to `MemberInfo` +- `src/Simplex/Chat/Protocol.hs` - Add member keys to `XMember`, `XGrpLinkMem`; signed message envelope, encoding/decoding + +### Protocol Handling +- `src/Simplex/Chat/Library/Commands.hs` - Sign messages when sending +- `src/Simplex/Chat/Library/Subscriber.hs` - Verify signatures when receiving +- `src/Simplex/Chat/Library/Internal.hs` - Chat-level signature utilities (working with Member profiles, messages) + +### Agent API (simplexmq repo) - New Functions +- `../simplexmq/src/Simplex/Messaging/Agent.hs`: + - `prepareConnectionLink` - NEW: commits to server, generates link address + root key locally (no network) + - `createConnectionWithPreparedLink` - NEW: accepts server + root key, creates queue (single network call) +- `../simplexmq/src/Simplex/Messaging/Agent/Client.hs` - Implement new functions + +### Database +- New migration: `src/Simplex/Chat/Store/SQLite/Migrations/M20260124_member_keys.hs` +- New migration: `src/Simplex/Chat/Store/Postgres/Migrations/M20260124_member_keys.hs` +- `src/Simplex/Chat/Store/Profiles.hs` - Store/retrieve member keys + +## New Types + +### 1. Member Key Type (Types.hs) + +```haskell +newtype MemberKey = MemberKey C.PublicKeyEd25519 + deriving (Eq, Show) + +-- IMPORTANT: memberKey is NOT in Profile - profiles can be updated independently +-- Member keys are fixed at join time and sent via member announcement messages + +-- Add memberKey to MemberInfo (used in XGrpMemNew, XGrpMemIntro, XGrpMemFwd) +data MemberInfo = MemberInfo + { memberId :: MemberId, + memberRole :: GroupMemberRole, + v :: Maybe ChatVersionRange, + profile :: Profile, + memberKey :: Maybe MemberKey -- NEW: member's signing key + } + deriving (Eq, Show) +``` + +### 2. Protocol Messages with Member Keys (Protocol.hs) + +Member keys are communicated via member identification/announcement messages, NOT profile updates: + +```haskell +-- Member self-identification when joining group +-- newMemberKey is required (not Maybe) - every new member must have a key +XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json + +-- Member joining via group link +XGrpLinkMem :: Profile -> Maybe MemberKey -> ChatMsgEvent 'Json + +-- Member announcements use MemberInfo which now includes memberKey +-- XGrpMemNew, XGrpMemIntro, XGrpMemFwd all use MemberInfo + +-- Profile updates do NOT include memberKey - key is fixed at join time +XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json -- unchanged +``` + +**Key points:** +- `XMember.newMemberKey` is required (not Maybe) - joining member must provide key +- `XGrpLinkMem` has `Maybe MemberKey` for backward compatibility +- `MemberInfo.memberKey` is `Maybe` for backward compatibility with existing members +- Profile updates (`XGrpMemInfo`) don't include key - it's fixed at join time + +### 3. Member Key Storage + +- Private key stored in `groups.member_priv_key` (current user's signing key for this group) +- Public key stored in `group_members.member_pub_key` (for all members) +- NOT stored in profiles table - member keys are per-group, not per-profile + +### 4. Signed Message Types (Protocol.hs) + +Types as implemented in Protocol.hs: + +```haskell +-- Key reference tag — indicates which key to use for verification. +-- KRMember means "use the contextual member's key" (sender or forwarded author). +-- Can be extended to support profile identity keys (e.g., secp256k1 for Nostr). +data KeyRef = KRMember + deriving (Eq, Show) + +-- Conversation binding for signature scope +data ChatBinding + = CBDirect {securityCode :: ByteString} + | CBGroup {groupRootKey :: C.PublicKeyEd25519, senderMemberId :: MemberId} + deriving (Eq, Show) + +-- Signature with key reference +data MsgSignature = MsgSignature KeyRef C.ASignature + deriving (Show) + +-- Signatures with chat binding +data MsgSignatures = MsgSignatures + { chatBinding :: ChatBinding, + signatures :: NonEmpty MsgSignature + } + +-- Field order matches wire format: forward data (> prefix), then sig data (/ prefix), then message ({ prefix) +data ParsedMsg = ParsedMsg (Maybe MsgForwardData) (Maybe MsgSigData) AChatMessage + +data MsgSigData = MsgSigData + { signatures :: MsgSignatures, + signedBody :: ByteString -- exact bytes that were signed + } + +data MsgForwardData = MsgForwardData + { fwdMemberId :: MemberId, + fwdMemberName :: ContactName, -- may be empty + fwdBrokerTs :: UTCTime + } +``` + +**Key insight:** The binary batch format preserves the exact bytes of each element via length-prefix framing, enabling signature verification even after the message has been parsed. This is critical for forwarded messages. + +### 5. Key Resolution and Validation + +```haskell +-- Key resolution: lookup member's public key from GroupMember record +resolveKeyRef :: GroupInfo -> KeyRef -> Either String C.APublicVerifyKey +resolveKeyRef gInfo (KRMember mid) = + case findMemberByMemberId mid gInfo >>= memberKey of + Just (MemberKey k) -> Right $ C.APublicVerifyKey C.SEd25519 k + Nothing -> Left $ "unknown member key: " <> show mid + +-- findMemberByMemberId looks up GroupMember by MemberId in GroupInfo +-- memberKey is stored in GroupMember record (from group_members.member_pub_key) + +-- Owner validation: verify member's key matches OwnerAuth chain +-- Called when processing roster-modifying messages from owners +validateOwnerMember :: GroupInfo -> MemberId -> MemberKey -> Either String () +validateOwnerMember gInfo memberId memberKey = do + case findOwnerAuth memberId (groupOwners gInfo) of + Nothing -> Left "member is not an owner" + Just OwnerAuth {ownerId, ownerKey} -> do + when (ownerId /= memberId) $ + Left "owner ID mismatch" + case memberKey of + MemberKey k | k == ownerKey -> Right () + _ -> Left "owner key doesn't match member key" +``` + +### Owner Verification Strategy (future multi-owner support) + +**Question:** How to validate that a member is a legitimate owner? + +**Option A: Request link data from server** +- Fetch current `UserContactData.owners` from SMP server +- Expensive: network roundtrip for each verification + +**Option B: Store OwnerAuth chain locally, verify via signatures** ✓ +- When joining group: receive OwnerAuth chain (from link data or group info) +- When new owner added: receive signed OwnerAuth (signed by root or existing owner) +- Verify locally using signature chain - no network needed +- Store chain in `group_owners` table + +**Current implementation (single owner):** +- Group creator is sole owner +- OwnerAuth created at group creation, stored in link data +- Members receive owner info when joining +- No multi-owner support yet (deferred) + +### 6. Message Batching Analysis + +Analysis of current batching behavior (determines new format requirements): + +**Q1: Can there be multiple compressed parts in one wire message?** + +**NO** - only ONE compressed block is ever created. +- `compressedBatchMsgBody_` (Protocol.hs:712) creates singleton list: `(L.:| []) . compress1` +- Called only in Internal.hs:1901 (connection info) and Internal.hs:1941 (message body) +- Decoder supports `NonEmpty Compressed` for forward compatibility, but encoding always produces 1 block + +**Q2: Can messages from multiple members be batched together?** + +**YES** - in both relay and non-relay groups: +- Relay groups: Delivery.hs:168-184 - `getNextDeliveryTasks` does NOT filter by sender +- Non-relay groups: `sendHistory` (Internal.hs:1171-1184) batches history items from multiple senders + +**Q3: Can forwarded and non-forwarded messages be batched together?** + +**YES** - in `sendHistory` (Internal.hs:1176-1184): +- `XMsgNew` (welcome/description) appended to `XGrpMsgForward` events +- Both sent together via `sendGroupMemberMessages` + +### 7. Wire Format (Protocol.hs) + +#### Current Format (JSON-based batching) + +```abnf +; Current wire format +wireMessage = compressedMsg / jsonMsg +compressedMsg = %s"X" compressedBlock ; single compressed block +jsonMsg = singleJson / jsonArray +singleJson = %s"{" *OCTET ; single JSON object +jsonArray = %s"[" *OCTET ; JSON array of messages +``` + +JSON array batching uses `[msg1,msg2,...]` format - simple but cannot preserve exact bytes for signatures. + +#### New Format (Binary batching for signatures) + +For relay-based groups where signatures are required, use binary batching that preserves exact message bytes: + +```abnf +; Extended wire format (parser accepts all formats) +wireMessage = compressedMsg / binaryBatch / jsonMsg + +; New binary batch format - preserves exact bytes for signature verification +binaryBatch = %s"=" elementCount *batchElement +elementCount = 1*1 OCTET ; 1-255 elements +batchElement = elementLen elementBody +elementLen = 2*2 OCTET ; 16-bit big-endian length +elementBody = signedElement / forwardElement / plainElement + +; Signed element - signatures followed by JSON body +signedElement = %s"/" msgSignatures jsonBody +jsonBody = *OCTET ; JSON bytes (length from elementLen) + +; Forward element - relay forwarding with preserved bytes (relay groups only) +; originalBytes is a nested element (signed or plain, but NOT another forward) +forwardElement = %s">" forwardMeta originalElement +forwardMeta = senderMemberId senderMemberName brokerTs +brokerTs = 8*8 OCTET ; UTC timestamp, big-endian microseconds +originalElement = signedElement / plainElement + +; Plain message element - starts with '{' (JSON object) +plainElement = jsonBody + +; Signature data (no '/' prefix — the element prefix serves that role) +msgSignatures = chatBinding sigCount *msgSignature +chatBinding = directBinding / groupBinding +directBinding = %s"D" securityCode +securityCode = shortString +groupBinding = %s"G" groupRootKey senderMemberId +groupRootKey = 32*32 OCTET ; Ed25519 public key +senderMemberId = shortString + +sigCount = 1*1 OCTET ; 1-255 signatures +msgSignature = keyRef sigBytes +keyRef = memberKeyRef +memberKeyRef = %s"M" ; use contextual member's key (sender or forwarded author) +sigBytes = 64*64 OCTET ; Ed25519 signature + +shortString = length *OCTET +length = 1*1 OCTET + +; Compressed format unchanged - compression wraps the batch +compressedMsg = %s"X" compressedBlock +; After decompression: binaryBatch / jsonMsg +``` + +**Overhead comparison:** +- JSON array: `[` + `]` + `,` between = n+1 bytes for n elements +- Binary batch: `=` + count + 2-byte length per element = 1 + 1 + 2n = 2 + 2n bytes +- Difference: ~1 extra byte per element - acceptable for signature support + +**Format selection:** +- Relay-based groups: Use binary batch (`=` prefix) - preserves bytes for signatures +- Non-relay groups: Use JSON array (`[...]`) - backward compatible, no signatures needed +- Old groups with old members: Use JSON array - full backward compatibility + +**Parser behavior (`parseChatMessages`):** +- `'='` prefix → binary batch (new format) +- `'{'` prefix → single JSON object +- `'['` prefix → JSON array +- `'X'` prefix → compressed (decompress, then re-parse) +- All formats accepted regardless of version for forward/backward compatibility + +**Batcher behavior (`Messages/Batch.hs`):** +- Accept `BatchMode` parameter: `BMJson` or `BMBinary` +- `BMJson`: Current JSON array encoding +- `BMBinary`: Binary format with length prefixes, preserves exact bytes + +```haskell +data BatchMode = BMJson | BMBinary + +batchMessages :: BatchMode -> Int -> [Either ChatError SndMessage] -> [Either ChatError MsgBatch] +-- batchDeliveryTasks1 hardcodes BMBinary (relay groups only) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +``` + +**Key insight:** The binary batch format allows: +1. Each element's exact bytes preserved (length-prefixed, not re-encoded) +2. Mixed signed/unsigned elements in same batch +3. Forwarded messages preserve original sender's signature +4. Relay adds no signature - just wraps in forwarding envelope + +**Forwarding in binary batch (relay groups):** + +For relay-based groups, forwarding is NOT via `XGrpMsgForward` ChatMsgEvent (which would re-encode the inner message). Instead, forwarding uses a **binary batch element format** (`forwardElement` in the ABNF above) that preserves exact bytes: + +```abnf +; Forward element details (defined in batchElement above) +forwardElement = %s">" forwardMeta originalBytes +forwardMeta = senderMemberId senderMemberName brokerTs +senderMemberId = shortString +senderMemberName = shortString ; may be empty +brokerTs = 8*8 OCTET ; UTC timestamp, big-endian microseconds +originalBytes = *OCTET ; original signed message bytes (verbatim) +``` + +Forward elements only appear inside binary batches — there is no standalone forward envelope at the wire level. + +**Flow:** + +1. **Sender** creates signed message: + ``` + /<{"event":"x.msg.new",...}> + ``` + +2. **Relay** receives, parses to validate, stores original bytes in `msg_body` + +3. **Relay** forwards as binary batch element(s): + ``` + =( ">" )* + ``` + +4. **Recipient** parses binary batch, extracts `originalBytes` from forward elements, verifies sender's signature + +**Key difference from current approach:** +- Current: `XGrpMsgForward` nests **parsed** `ChatMessage 'Json` → re-encoded on send → bytes change +- New: Forward element contains **original element bytes** (`/` or `{`) → never re-encoded → signature remains valid +- Forward nesting is guarded: `elementP` rejects nested forward elements (`>` inside `>`) + +**Backward compatibility:** +- Old groups (non-relay): Continue using `XGrpMsgForward` ChatMsgEvent (JSON array batching) +- New relay groups: Use binary batch with forward elements (`>` prefix inside `=` batch) +- `XGrpMsgForward` JSON call site passes `Nothing` for `msgSig_` (no signature data available in JSON path) +- Parser accepts both formats + +**Key resolution:** +- `'M'` (member key ref): Use the contextual member's public key from `group_members.member_pub_key` — the sender (direct messages) or forwarded author (forward elements) + +## Messages Requiring Signatures + +### Owner/Admin Signatures (roster changes) +- `XGrpRelayInv` - Owner inviting relay (relay validates) +- `XGrpMemNew` - Adding new member +- `XGrpMemRole` - Changing member role +- `XGrpMemDel` - Removing member +- `XGrpInfo` - Updating group profile +- `XGrpPrefs` - Updating group preferences +- `XGrpDel` - Deleting group + +### Content messages — NOT signed +- `XMsgNew` and other content messages are not signed to preserve deniability. Relay manipulation of content is detectable post-hoc via cross-relay consistency. + +## Database Migration + +```sql +-- SQLite migration M20260124_member_keys.hs + +-- Group-level keys (current user's keys for this group) +ALTER TABLE groups ADD COLUMN shared_group_id BLOB; -- saved in link fixed data as entity ID +ALTER TABLE groups ADD COLUMN root_priv_key BLOB; -- root private key (only if user is the owner and group creator) +ALTER TABLE groups ADD COLUMN root_pub_key BLOB; -- needed for all members of public groups to verify ownership chains +ALTER TABLE groups ADD COLUMN member_priv_key BLOB; -- current user's member private key for this group + +-- Member public keys (for all members, including current user) +-- Public key is sent via MemberInfo/XMember and stored for signature verification +ALTER TABLE group_members ADD COLUMN member_pub_key BLOB; -- public key (all members) + +-- Note: root_priv_key is the root key from group link (immutable group identity), only for owner/creator +-- Note: root_pub_key is needed for all members of public groups to verify ownership chains +-- Note: member_priv_key is the current user's signing key for this group (unique per group) +-- Note: member_pub_key is received via MemberInfo (XGrpMemNew, etc.) or XMember message +``` + +## Root Key Management (Analysis Required) + +Currently, root key is generated in Agent (`ShortLinkCreds.linkPrivSigKey`) and stored in agent schema (`rcv_queues.link_priv_sig_key`). + +For Chat to sign owner messages, we need access to either: +- The root key (for initial owner) +- The owner key (for subsequent owners in chain) + +**Current Problem: Two-Step Group Creation (2 roundtrips)** + +Current flow in Commands.hs: +1. Chat creates connection → server roundtrip → gets link +2. Chat updates group profile to include link +3. Chat updates link data → another server roundtrip + +Problems: +- Double requests increase latency +- Risk of failing halfway (needs recovery management) +- Can't include signed owner key in initial link data + +**Solution: New Agent API with Prepare + Create Pattern** + +Two new Agent functions: + +```haskell +-- Prepared link data returned by prepare step (NO network, NO database) +-- Contains everything needed to: (a) construct the short link, (b) create the connection later +data PreparedConnLink c = PreparedConnLink + { pclServer :: SMPServerWithAuth, -- Committed server from config + pclNonce :: C.CbNonce, -- Nonce (corrId) - determines sender ID + pclRootKeyPair :: C.KeyPairEd25519, -- Root signing key for link + pclE2eKeyPair :: C.KeyPairX25519, -- E2E DH key for queue address + pclFixedLinkData :: FixedLinkData c, -- Contains connReq (with ratchet params for invitations) + pclLinkKey :: LinkKey, -- Derived from FixedLinkData: sha3_256(encoded fixedData) + pclPrivSigKey :: C.PrivateKeyEd25519 -- For signing link data (same as snd of pclRootKeyPair) + } + +-- 1. prepareConnectionLink: Generates all link parameters locally (NO network, NO database) +-- Returns PreparedConnLink + the actual short link that can be used in addresses +prepareConnectionLink :: ConnectionModeI c + => AgentClient -> UserId -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys + -> AM (PreparedConnLink c, ConnShortLink c) +-- Does: +-- - Selects server from config (getSMPServer) +-- - Generates nonce, derives sender ID: sha3_384(corrId)[:24] +-- - Generates root key pair (Ed25519) for signing +-- - Generates e2e DH key pair (X25519) for queue address +-- - For invitations: generates E2E ratchet params +-- - Builds ConnectionRequestUri (contains queue address + ratchet params) +-- - Builds FixedLinkData (contains connReq + rootKey + agentVRange) +-- - Derives linkKey = sha3_256(encoded fixedData) +-- - Constructs ConnShortLink (CSLContact or CSLInvitation) with linkKey +-- Returns (PreparedConnLink, ConnShortLink) - both can be roundtripped, nothing saved + +-- 2. createConnectionWithPreparedLink: Creates connection using prepared link +-- Single network call to create queue with pre-determined sender ID +createConnectionWithPreparedLink :: ConnectionModeI c => + AgentClient -> NetworkRequestMode -> UserId -> Bool -> Bool -> + PreparedConnLink c -> UserConnLinkData c -> SubscriptionMode -> + AM (ConnId, (CreatedConnLink c, Maybe ClientServiceId)) +-- Accepts: +-- - PreparedConnLink from prepare step (contains all crypto material) +-- - UserConnLinkData with signed OwnerAuth array (mutable part) +-- Does: +-- - Uses pclNonce to get deterministic sender ID +-- - Creates connection record (newConnNoQueues) +-- - Creates queue on server with prepared nonce → same sender ID +-- - Encrypts & uploads link data (fixed + user data) +-- Returns same as createConnection +``` + +**Key insights (from RFC 2025-03-16-smp-queues.md):** +- Sender ID = `sha3_384(nonce)[:24]` - derived locally from correlation ID (nonce) +- `FixedLinkData` contains `ConnectionRequestUri` (includes ratchet params for invitations) +- `LinkKey` = `sha3_256(encoded fixedData)` - derived from fixed data hash +- For **contact addresses**: `(link_id, enc_key) = HKDF(link_key, 56)` - fully deterministic +- For **1-time invitations**: `link_id` is server-generated, `enc_key = HKDF(link_key, 32)` +- Public groups use contact mode → short link address fully known at prepare step +- Everything can be roundtripped - no database needed for prepare step + +**New Flow (single roundtrip):** + +```haskell +-- In Chat (Commands.hs) when creating public group: +createPublicGroupWithRelays :: ... -> CM GroupInfo +createPublicGroupWithRelays ... = do + -- 1. Prepare link parameters (NO network, NO database) + -- Returns PreparedConnLink + the short link for use in group address + (preparedLink@PreparedConnLink {pclRootKeyPair = (rootPubKey, rootPrivKey)}, shortLink) <- + prepareConnectionLink c userId SCMContact clientData pqInitKeys + + -- 2. Generate owner's member key pair + (memberPubKey, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair g + + -- 3. Create signed OwnerAuth (Chat signs with root key) + let ownerAuth = OwnerAuth + { ownerId = memberId, + ownerKey = memberPubKey, + authOwnerSig = C.sign' rootPrivKey (memberId <> C.encodePubKey memberPubKey) + } + + -- 4. Create UserConnLinkData with owners array + let userLinkData = UserContactLinkData $ UserContactData { owners = [ownerAuth], direct = True } + + -- 5. Create connection with prepared link (SINGLE network call) + (connId, (createdLink, _)) <- createConnectionWithPreparedLink c NRMNormal userId + enableNtfs checkNotices preparedLink userLinkData SMSubscribe + + -- 6. Store keys in groups table + updateGroupKeys groupId rootPubKey rootPrivKey memberPrivKey + -- groups.root_pub_key = rootPubKey (for all members of public groups) + -- groups.root_priv_key = rootPrivKey (only for owner/creator) + -- groups.member_priv_key = memberPrivKey (current user's signing key) + -- group_members.member_pub_key = memberPubKey (for current user's membership) + + -- Note: shortLink can be used immediately in group profile/address + -- The link address is determined at step 1, not step 5 +``` + +**Key Points:** +- `prepareConnectionLink` generates all link parameters locally (no network, no DB) +- Returns `(PreparedConnLink, ConnShortLink)` - short link address is known immediately +- Sender ID is deterministic: `sha3_384(nonce)[:24]` - derived locally +- `FixedLinkData` contains `ConnectionRequestUri` (includes ratchet params for invitations) +- `LinkKey` derived from `FixedLinkData`, short link address derived from `LinkKey` +- Chat uses root key to sign owner's member key → OwnerAuth +- `createConnectionWithPreparedLink` makes single network roundtrip with complete link data +- `groups` table: `root_priv_key` (owner only), `root_pub_key` (all members), `member_priv_key` (current user) +- `group_members` table: `member_pub_key` (all members) + +## Current Public Group Creation (to be refactored) + +Review `src/Simplex/Chat/Library/Commands.hs` - current two-step process: +1. `APICreateGroup` / `createPreparedGroup` - creates group with connection +2. Server roundtrip to create link +3. Update profile with link +4. Update link data (another roundtrip) + +This needs refactoring to use new Agent API for single-roundtrip creation. + +## Implementation Steps + +### Phase 0: Agent API Changes (simplexmq) +1. Add `prepareConnectionLink` function - commits to server, generates link + root key locally +2. Add `createConnectionWithPreparedLink` function - accepts server + root key, single network call +3. Update Agent store to handle new flow (connection record without queue record) + +### Phase 1: Types and Encoding +1. Add `MemberKey` type and JSON encoding in Types.hs +2. Add `memberKey :: Maybe MemberKey` field to `MemberInfo` type +3. Add `newMemberKey :: MemberKey` to `XMember` message (required, not Maybe) +4. Add `Maybe MemberKey` parameter to `XGrpLinkMem` message +5. Types already added to Protocol.hs: `KeyRef`, `ChatBinding`, `MsgSignature`, `MsgSignatures`, `ParsedMsg`, `MsgSigData`, `MsgForwardData` +6. Encoding instances added: `KeyRef`, `ChatBinding`, `MsgSignature`, `MsgSignatures`, `MsgSigData`, `MsgForwardData` +7. Binary batch element parser (`elementP`) handles `/`/`>`/`{` prefixes with attoparsec +8. Update `parseChatMessages` to accept both JSON array and binary batch formats +9. Add `BatchMode` parameter to batching functions in Messages/Batch.hs + +### Phase 2: Key Generation and Storage +1. Add database migration for `member_pub_key` in group_members, `member_priv_key` in groups +2. Generate Ed25519 key pair when joining/creating group +3. Store private key in groups.member_priv_key (current user's key for this group) +4. Store public key in group_members.member_pub_key (for all members) +5. Include public key in XMember/XGrpLinkMem/MemberInfo when sending + +### Phase 3: Signing Messages +1. Add `signChatMessage` function in Internal.hs +2. Modify `sendGroupMessage` to sign roster-modifying messages +3. Add owner key to group link when creating public group +4. Sign `XGrpRelayInv` with owner key + +### Phase 4: Signature Verification +1. `verifySig` added in Subscriber.hs — verifies against member's stored public key, checks member ID match +2. `processAChatMsg` verifies direct messages; `xGrpMsgForward` verifies forwarded messages after author resolution +3. `xGrpMsgForward` extended with `Maybe GroupChatScopeInfo` and `Maybe MsgSigData` — eliminated `processForward` duplication +4. Bad signature creates `RGEMsgBadSignature` chat item for the user +5. Add relay validation for `XGrpRelayInv` in Subscriber.hs + +### Phase 5: Version Gating +1. Add new chat version (e.g., `memberSignaturesVersion = VersionChat 17`) +2. Gate signature features behind version check +3. Accept unsigned messages from older clients +4. Send signed messages only to clients supporting new version + +## Signature Verification Logic + +Current implementation (`verifySig` in Subscriber.hs) — minimal first step: + +```haskell +verifySig :: GroupMember -> Maybe MsgSigData -> Bool +verifySig GroupMember {memberPubKey = Just pubKey} (Just MsgSigData {signatures = MsgSignatures {signatures}, signedBody}) = + all verifyOne (L.toList signatures) + where + verifyOne (MsgSignature KRMember sig) = + C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig signedBody +verifySig _ _ = True +``` + +Verification is called in two places: +- `processAChatMsg`: verifies direct messages from the sender member +- `xGrpMsgForward`: verifies forwarded messages after resolving the author from `MsgForwardData.fwdMemberId` + +Future full verification should additionally: +1. Validate `ChatBinding` matches group (root key, sender member ID) +2. Reject unsigned messages for message types that require signatures + +## Owner Key Integration with Group Link (Separate Key Model) + +When creating a public group: +1. Generate group root key (Ed25519 key pair) - stored in group link's immutable FixedLinkData +2. Generate owner's member key (Ed25519 key pair) - stored in groups.member_priv_key and group_members.member_pub_key +3. Create OwnerAuth entry: `OwnerAuth { ownerId = memberId, ownerKey = memberKey, authOwnerSig = sig(memberId || memberKey, rootKey) }` +4. Add OwnerAuth to group link's mutable UserContactData.owners list + +This model: +- Root key is immutable (defines group identity) +- Owner key is in OwnerAuth chain (supports ownership transfer) +- Member keys are per-group, stored in groups/group_members tables (NOT in profiles) +- New owners can be added by existing owners signing their authorization + +```haskell +-- When creating public group +createPublicGroup :: ... -> CM GroupInfo +createPublicGroup ... = do + -- 1. Generate root key for group identity + (rootPubKey, rootPrivKey) <- generateKeyPair Ed25519 + + -- 2. Generate owner's member key for this group + (memberPubKey, memberPrivKey) <- generateKeyPair Ed25519 + + -- 3. Create owner authorization signed by root + let ownerAuth = OwnerAuth + { ownerId = memberId membership, + ownerKey = memberPubKey, + authOwnerSig = sign rootPrivKey (memberId <> encodePubKey memberPubKey) + } + + -- 4. Store keys: root_priv_key and member_priv_key in groups table + -- member_pub_key in group_members table + -- 5. Add ownerAuth to link data + ... +``` + +## Testing Considerations + +1. **Unit tests**: Encoding/decoding round-trips for signed messages +2. **Integration tests**: Message signing and verification flow +3. **Compatibility tests**: Old clients receiving signed messages +4. **Relay tests**: Signature validation in relay invitation flow +5. **Key rotation tests**: Profile updates with new member key + +## Backward Compatibility + +- **Hard fail mode**: Messages requiring signatures (roster changes) MUST be signed. Unsigned/invalid = rejected. +- Version-gated: Add `memberSignaturesVersion = VersionChat 17` +- New clients: Send signed roster messages, reject unsigned roster messages from new clients +- Old clients: Cannot send roster messages to new-version groups (version negotiation prevents this) +- Migration path: Existing groups without signatures continue working; new public groups require signatures + +## Design Decisions (Confirmed) + +1. **Message signing scope**: Only roster-modifying messages (XGrpRelayInv, XGrpMemNew, XGrpMemRole, XGrpMemDel, XGrpInfo, XGrpPrefs, XGrpDel). Regular content messages (XMsgNew) are NOT signed — signing them would destroy deniability by creating non-repudiable proof of authorship. Content manipulation by relays is detectable post-hoc via cross-relay consistency, which is sufficient because content delivery is not irreversible. Roster/profile changes are disruptive and irreversible (member removed, role changed, group deleted), so they must be authenticated at processing time before taking effect — post-detection is too late. + +2. **Signature failure handling**: Hard fail for all signed message types. Reject any message that should be signed but isn't or has invalid signature. + +3. **Key model**: Separate keys - root key is fixed in group link, owner is authorized via OwnerAuth chain. Supports ownership transfer without breaking group identity. Matches simplexmq pattern. diff --git a/docs/rfcs/2026-03-28-group-identity-binding.md b/docs/rfcs/2026-03-28-group-identity-binding.md new file mode 100644 index 0000000000..afc4ed965c --- /dev/null +++ b/docs/rfcs/2026-03-28-group-identity-binding.md @@ -0,0 +1,68 @@ +# Group identity and signature binding + +## Problem + +Group message signatures bind to a group identity via a prefix: + +``` +signedBytes = smpEncode (CBGroup, groupIdentity, memberId) <> messageBody +``` + +Using `groupRootKey` as identity is unstable: the root key is derived from the link's key pair, so link rotation (relay replacement, key compromise recovery) changes it, breaking existing bindings. + +Using an arbitrary entity ID is stable but not self-authenticating: any owner could copy another group's ID. + +## Design + +Use the **hash of the genesis root key** as group identity: + +``` +groupEntityId = sha256(genesisRootPubKey) +``` + +- Set at creation, never changes. +- Self-authenticating: derived from a key pair only the creator held. +- Stored as `linkEntityId` in the short link, and in the group profile distributed to all members. +- Used in the signature binding prefix instead of root key. + +### Why no validation now + +Current clients do not validate that `linkEntityId == sha256(rootKey)` on join. This is unconventional — normally, an unvalidated binding is pointless. Here it is deliberate forward-compatible design, not deferred work: + +- **Forward compatibility for joiners**: future link rotation will cause `rootKey` and `linkEntityId` to diverge. Current clients don't know how to verify a rotation chain, so they must accept diverged values. If we validated now, current clients could not join future rotated groups. Mobile clients have slow upgrade cycles and we have no mechanism to force upgrades, so we aim for at least 2-3 months backward compatibility for new features (1 year for existing). Validating now would force a breaking change on rotation. + +- **Forward compatibility for groups**: all groups created now have the correct binding (`entityId = sha256(rootKey)`). When a future protocol version introduces rotation and enforces validation, these groups are already compliant. Deferring the entity ID until then would mean some groups have IDs and some don't — a backward-compatibility problem. + +The cloning risk (copied entity ID in a malicious group) is acceptable now: groups are small, invite links come from trusted sources, and history merging on re-join is itself a future feature. By the time channels are large enough for cloning to matter, validation will be enforced. + +### Key hierarchy context + +The root key is a **bootstrap key**: it signs `OwnerAuth` entries to certify owners (see [simplexmq owner chain](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2025-04-04-short-links-for-groups.md#multiple-owners-managing-queue-data)), then need not be used again. Owner keys sign admin messages, group updates, and future rotation statements. This conceals the creator's identity — all owners are indistinguishable. + +Using the genesis root key *hash* as identity aligns with this: after rotation, the root key changes but the identity persists, bridged by owner-signed rotation statements. + +### What IS validated now + +- **Link vs profile consistency**: joiners validate that `linkEntityId` from the link matches `sharedGroupId` in the group profile. This prevents a directory or listing from substituting a different link for a group — the link is bound to the profile. This check remains valid after rotation (both preserve the original entity ID). + +- **Profile update immutability**: `sharedGroupId` in the group profile must not change. Clients reject `XGrpInfo` updates that modify it. + +### What is NOT validated now + +- **`linkEntityId == sha256(rootKey)`**: not checked on join. See "Why no validation now" above. + +## Changes + +### Done + +1. **Agent API** (`simplexmq`): `prepareConnectionLink` takes caller-provided root key pair and entity ID instead of generating the key internally. Caller controls both. + +2. **Link creation** (`Commands.hs`): owner generates root key pair, computes `sharedGroupId = sha256(rootPubKey)`, passes both to `prepareConnectionLink`. The entity ID is baked into signed `FixedLinkData`. + +### Remaining + +3. **Group profile**: add `sharedGroupId` field to `GroupProfile`, set from `linkEntityId` at genesis, immutable. Reject `XGrpInfo` updates that change it. + +4. **Joiner validation**: confirm `linkEntityId` from link matches `sharedGroupId` from group profile. + +5. **Signature binding**: change prefix from `smpEncode (CBGroup, groupRootPubKey, memberId)` to `smpEncode (CBGroup, sharedGroupId, memberId)` in both `groupMsgSigning` (signing) and `withVerifiedMsg` (verification). diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index edeabe7837..d5c3046e3a 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -341,6 +341,37 @@ export namespace APINewGroup { } } +// Create public group. +// Network usage: interactive. +export interface APINewPublicGroup { + userId: number // int64 + incognito: boolean + relayIds: number[] // int64, non-empty + groupProfile: T.GroupProfile +} + +export namespace APINewPublicGroup { + export type Response = CR.PublicGroupCreated | CR.ChatCmdError + + export function cmdString(self: APINewPublicGroup): string { + return '/_public group ' + self.userId + (self.incognito ? ' incognito=on' : '') + ' ' + self.relayIds.join(',') + ' ' + JSON.stringify(self.groupProfile) + } +} + +// 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/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index cb6ba85c8b..cc19305913 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -29,6 +29,8 @@ export type ChatEvent = | CEvt.MemberAcceptedByOther | CEvt.MemberBlockedForAll | CEvt.GroupMemberUpdated + | CEvt.GroupLinkDataUpdated + | CEvt.GroupRelayUpdated | CEvt.RcvFileDescrReady | CEvt.RcvFileComplete | CEvt.SndFileCompleteXFTP @@ -80,6 +82,8 @@ export namespace CEvt { | "memberAcceptedByOther" | "memberBlockedForAll" | "groupMemberUpdated" + | "groupLinkDataUpdated" + | "groupRelayUpdated" | "rcvFileDescrReady" | "rcvFileComplete" | "sndFileCompleteXFTP" @@ -213,6 +217,7 @@ export namespace CEvt { fromGroup: T.GroupInfo toGroup: T.GroupInfo member_?: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface JoinedGroupMember extends Interface { @@ -230,6 +235,7 @@ export namespace CEvt { member: T.GroupMember fromRole: T.GroupMemberRole toRole: T.GroupMemberRole + msgSigned?: T.MsgSigStatus } export interface DeletedMember extends Interface { @@ -239,6 +245,7 @@ export namespace CEvt { byMember: T.GroupMember deletedMember: T.GroupMember withMessages: boolean + msgSigned?: T.MsgSigStatus } export interface LeftMember extends Interface { @@ -246,6 +253,7 @@ export namespace CEvt { user: T.User groupInfo: T.GroupInfo member: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface DeletedMemberUser extends Interface { @@ -254,6 +262,7 @@ export namespace CEvt { groupInfo: T.GroupInfo member: T.GroupMember withMessages: boolean + msgSigned?: T.MsgSigStatus } export interface GroupDeleted extends Interface { @@ -261,6 +270,7 @@ export namespace CEvt { user: T.User groupInfo: T.GroupInfo member: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface ConnectedToGroupMember extends Interface { @@ -286,6 +296,7 @@ export namespace CEvt { byMember: T.GroupMember member: T.GroupMember blocked: boolean + msgSigned?: T.MsgSigStatus } export interface GroupMemberUpdated extends Interface { @@ -296,6 +307,23 @@ export namespace CEvt { toMember: T.GroupMember } + export interface GroupLinkDataUpdated extends Interface { + type: "groupLinkDataUpdated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + relaysChanged: boolean + } + + export interface GroupRelayUpdated extends Interface { + type: "groupRelayUpdated" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + groupRelay: T.GroupRelay + } + export interface RcvFileDescrReady extends Interface { type: "rcvFileDescrReady" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 684aeec7af..8d4f68c000 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -27,6 +27,8 @@ export type ChatResponse = | CR.GroupLinkCreated | CR.GroupLinkDeleted | CR.GroupCreated + | CR.PublicGroupCreated + | CR.GroupRelays | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -78,6 +80,8 @@ export namespace CR { | "groupLinkCreated" | "groupLinkDeleted" | "groupCreated" + | "publicGroupCreated" + | "groupRelays" | "groupMembers" | "groupUpdated" | "groupsList" @@ -217,6 +221,7 @@ export namespace CR { type: "groupDeletedUser" user: T.User groupInfo: T.GroupInfo + msgSigned: boolean } export interface GroupLink extends Interface { @@ -245,6 +250,21 @@ export namespace CR { groupInfo: T.GroupInfo } + export interface PublicGroupCreated extends Interface { + type: "publicGroupCreated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + 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 @@ -257,6 +277,7 @@ export namespace CR { fromGroup: T.GroupInfo toGroup: T.GroupInfo member_?: T.GroupMember + msgSigned: boolean } export interface GroupsList extends Interface { @@ -291,6 +312,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] blocked: boolean + msgSigned: boolean } export interface MembersRoleUser extends Interface { @@ -299,6 +321,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] toRole: T.GroupMemberRole + msgSigned: boolean } export interface NewChatItems extends Interface { @@ -392,6 +415,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] withMessages: boolean + msgSigned: boolean } export interface UserProfileUpdated extends Interface { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 7075d4b7ca..274ddb6924 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -554,11 +554,19 @@ export type CIDirection = | CIDirection.DirectRcv | CIDirection.GroupSnd | CIDirection.GroupRcv + | CIDirection.ChannelRcv | CIDirection.LocalSnd | CIDirection.LocalRcv export namespace CIDirection { - export type Tag = "directSnd" | "directRcv" | "groupSnd" | "groupRcv" | "localSnd" | "localRcv" + export type Tag = + | "directSnd" + | "directRcv" + | "groupSnd" + | "groupRcv" + | "channelRcv" + | "localSnd" + | "localRcv" interface Interface { type: Tag @@ -581,6 +589,10 @@ export namespace CIDirection { groupMember: GroupMember } + export interface ChannelRcv extends Interface { + type: "channelRcv" + } + export interface LocalSnd extends Interface { type: "localSnd" } @@ -783,6 +795,7 @@ export interface CIMeta { editable: boolean forwardedByMember?: number // int64 showGroupAsSender: boolean + msgSigned?: MsgSigStatus createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp } @@ -969,6 +982,7 @@ export type ChatErrorType = | ChatErrorType.NoRcvFileUser | ChatErrorType.UserUnknown | ChatErrorType.UserExists + | ChatErrorType.ChatRelayExists | ChatErrorType.DifferentActiveUser | ChatErrorType.CantDeleteActiveUser | ChatErrorType.CantDeleteLastUser @@ -1033,6 +1047,7 @@ export type ChatErrorType = | ChatErrorType.ConnectionIncognitoChangeProhibited | ChatErrorType.ConnectionUserChangeProhibited | ChatErrorType.PeerChatVRangeIncompatible + | ChatErrorType.RelayTestError | ChatErrorType.InternalError | ChatErrorType.Exception @@ -1044,6 +1059,7 @@ export namespace ChatErrorType { | "noRcvFileUser" | "userUnknown" | "userExists" + | "chatRelayExists" | "differentActiveUser" | "cantDeleteActiveUser" | "cantDeleteLastUser" @@ -1108,6 +1124,7 @@ export namespace ChatErrorType { | "connectionIncognitoChangeProhibited" | "connectionUserChangeProhibited" | "peerChatVRangeIncompatible" + | "relayTestError" | "internalError" | "exception" @@ -1143,6 +1160,10 @@ export namespace ChatErrorType { contactName: string } + export interface ChatRelayExists extends Interface { + type: "chatRelayExists" + } + export interface DifferentActiveUser extends Interface { type: "differentActiveUser" commandUserId: number // int64 @@ -1454,6 +1475,11 @@ export namespace ChatErrorType { type: "peerChatVRangeIncompatible" } + export interface RelayTestError extends Interface { + type: "relayTestError" + message: string + } + export interface InternalError extends Interface { type: "internalError" message: string @@ -2470,6 +2496,7 @@ export enum GroupFeatureEnabled { export interface GroupInfo { groupId: number // int64 useRelays: boolean + relayOwnStatus?: RelayStatus localDisplayName: string groupProfile: GroupProfile localAlias: string @@ -2489,6 +2516,13 @@ export interface GroupInfo { groupSummary: GroupSummary membersRequireAttention: number // int viaGroupLinkUri?: string + groupKeys?: GroupKeys +} + +export interface GroupKeys { + publicGroupId: string + groupRootKey: GroupRootKey + memberPrivKey: string } export interface GroupLink { @@ -2516,6 +2550,7 @@ export namespace GroupLinkPlan { export interface Ok extends Interface { type: "ok" + groupSLinkInfo_?: GroupShortLinkInfo groupSLinkData_?: GroupShortLinkData } @@ -2560,6 +2595,8 @@ export interface GroupMember { createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat + memberPubKey?: string + relayLink?: string } export interface GroupMemberAdmission { @@ -2580,6 +2617,7 @@ export interface GroupMemberRef { } export enum GroupMemberRole { + Relay = "relay", Observer = "observer", Author = "author", Member = "member", @@ -2634,16 +2672,53 @@ export interface GroupProfile { shortDescr?: string description?: string image?: string + publicGroup?: PublicGroupProfile groupPreferences?: GroupPreferences memberAdmission?: GroupMemberAdmission } +export interface GroupRelay { + groupRelayId: number // int64 + groupMemberId: number // int64 + userChatRelay: UserChatRelay + relayStatus: RelayStatus + relayLink?: string +} + +export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public + +export namespace GroupRootKey { + export type Tag = "private" | "public" + + interface Interface { + type: Tag + } + + export interface Private extends Interface { + type: "private" + rootPrivKey: string + } + + export interface Public extends Interface { + type: "public" + rootPubKey: string + } +} + export interface GroupShortLinkData { groupProfile: GroupProfile + publicGroupData?: PublicGroupData +} + +export interface GroupShortLinkInfo { + direct: boolean + groupRelays: string[] + publicGroupId?: string } export interface GroupSummary { currentMembers: number // int64 + publicMemberCount?: number // int64 } export interface GroupSupportChat { @@ -2654,6 +2729,10 @@ export interface GroupSupportChat { lastMsgFromMemberTs?: string // ISO-8601 timestamp } +export enum GroupType { + Channel = "channel", +} + export enum HandshakeError { PARSE = "PARSE", IDENTITY = "IDENTITY", @@ -2956,6 +3035,11 @@ export enum MsgReceiptStatus { BadMsgHash = "badMsgHash", } +export enum MsgSigStatus { + Verified = "verified", + SignedNoKey = "signedNoKey", +} + export type NetworkError = | NetworkError.ConnectError | NetworkError.TLSError @@ -3008,6 +3092,7 @@ export namespace NetworkError { export interface NewUser { profile?: Profile pastTimestamp: boolean + userChatRelay: boolean clientService: boolean } @@ -3132,6 +3217,16 @@ export namespace ProxyError { } } +export interface PublicGroupData { + publicMemberCount: number // int64 +} + +export interface PublicGroupProfile { + groupType: GroupType + groupLink: string + publicGroupId: string +} + export type RCErrorType = | RCErrorType.Internal | RCErrorType.Identity @@ -3387,6 +3482,7 @@ export type RcvGroupEvent = | RcvGroupEvent.MemberCreatedContact | RcvGroupEvent.MemberProfileUpdated | RcvGroupEvent.NewMemberPendingReview + | RcvGroupEvent.MsgBadSignature export namespace RcvGroupEvent { export type Tag = @@ -3406,6 +3502,7 @@ export namespace RcvGroupEvent { | "memberCreatedContact" | "memberProfileUpdated" | "newMemberPendingReview" + | "msgBadSignature" interface Interface { type: Tag @@ -3490,6 +3587,24 @@ export namespace RcvGroupEvent { export interface NewMemberPendingReview extends Interface { type: "newMemberPendingReview" } + + export interface MsgBadSignature extends Interface { + type: "msgBadSignature" + } +} + +export interface RelayProfile { + displayName: string + fullName: string + shortDescr?: string + image?: string +} + +export enum RelayStatus { + New = "new", + Invited = "invited", + Accepted = "accepted", + Active = "active", } export enum ReportReason { @@ -3772,6 +3887,7 @@ export namespace SrvError { export type StoreError = | StoreError.DuplicateName | StoreError.UserNotFound + | StoreError.RelayUserNotFound | StoreError.UserNotFoundByName | StoreError.UserNotFoundByContactId | StoreError.UserNotFoundByGroupId @@ -3799,6 +3915,7 @@ export type StoreError = | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember + | StoreError.DuplicateMemberId | StoreError.GroupAlreadyJoined | StoreError.GroupInvitationNotFound | StoreError.NoteFolderAlreadyExists @@ -3847,6 +3964,9 @@ export type StoreError = | StoreError.ProhibitedDeleteUser | StoreError.OperatorNotFound | StoreError.UsageConditionsNotFound + | StoreError.UserChatRelayNotFound + | StoreError.GroupRelayNotFound + | StoreError.GroupRelayNotFoundByMemberId | StoreError.InvalidQuote | StoreError.InvalidMention | StoreError.InvalidDeliveryTask @@ -3859,6 +3979,7 @@ export namespace StoreError { export type Tag = | "duplicateName" | "userNotFound" + | "relayUserNotFound" | "userNotFoundByName" | "userNotFoundByContactId" | "userNotFoundByGroupId" @@ -3886,6 +4007,7 @@ export namespace StoreError { | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" + | "duplicateMemberId" | "groupAlreadyJoined" | "groupInvitationNotFound" | "noteFolderAlreadyExists" @@ -3934,6 +4056,9 @@ export namespace StoreError { | "prohibitedDeleteUser" | "operatorNotFound" | "usageConditionsNotFound" + | "userChatRelayNotFound" + | "groupRelayNotFound" + | "groupRelayNotFoundByMemberId" | "invalidQuote" | "invalidMention" | "invalidDeliveryTask" @@ -3955,6 +4080,10 @@ export namespace StoreError { userId: number // int64 } + export interface RelayUserNotFound extends Interface { + type: "relayUserNotFound" + } + export interface UserNotFoundByName extends Interface { type: "userNotFoundByName" contactName: string @@ -4085,6 +4214,10 @@ export namespace StoreError { type: "duplicateGroupMember" } + export interface DuplicateMemberId extends Interface { + type: "duplicateMemberId" + } + export interface GroupAlreadyJoined extends Interface { type: "groupAlreadyJoined" } @@ -4321,6 +4454,21 @@ export namespace StoreError { type: "usageConditionsNotFound" } + export interface UserChatRelayNotFound extends Interface { + type: "userChatRelayNotFound" + chatRelayId: number // int64 + } + + export interface GroupRelayNotFound extends Interface { + type: "groupRelayNotFound" + groupRelayId: number // int64 + } + + export interface GroupRelayNotFoundByMemberId extends Interface { + type: "groupRelayNotFoundByMemberId" + groupMemberId: number // int64 + } + export interface InvalidQuote extends Interface { type: "invalidQuote" } @@ -4495,10 +4643,22 @@ export interface User { sendRcptsSmallGroups: boolean autoAcceptMemberContacts: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp + userChatRelay: boolean clientService: boolean uiThemes?: UIThemeEntityOverrides } +export interface UserChatRelay { + chatRelayId: number // int64 + address: string + relayProfile: RelayProfile + 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/plans/2026-03-13-message-keys-forwarding.md b/plans/2026-03-13-message-keys-forwarding.md new file mode 100644 index 0000000000..d495b30201 --- /dev/null +++ b/plans/2026-03-13-message-keys-forwarding.md @@ -0,0 +1,473 @@ +# Plan: Signed Message Storage, Forwarding, and Verification + +## Context + +The protocol types for signatures exist (`MsgSignatures`, `MsgSigData`, `ChatBinding`), the parser handles `/`/`>`/`{` element prefixes, and `verifySig` checks signatures. What's missing: + +1. **Signing when sending** — members sign their messages before sending to the relay +2. **Signature storage** — persisting signatures alongside message content +3. **Signature forwarding** — relay preserves and forwards original signatures intact +4. **Binding correctness** — bindings aren't covered by signatures or validated +5. **Required signatures** — admin events must require valid signatures in relay groups +6. **Visibility** — expose signature verification status in chat items + +## Design + +### A. Binding: Reconstructed, Not Sent + +`CBGroup {groupRootKey, senderMemberId}` — both known to verifier from context. Replace with single-byte binding tag on wire. + +Wire: ` ()*` + +Signed payload (constructed by signer and verifier, not on wire): +``` +smpEncode 'G' <> smpEncode (groupRootKey, senderMemberId) <> jsonBody +``` + +The binding tag is separate from the binding-specific prefix. SMP tuple encoding is concatenation, so `smpEncode ('G', k, m) = smpEncode 'G' <> smpEncode (k, m)` — same bytes either way. + +### B. Signing Context — Data, Not Function + +A generic record carries key material and binding data for signing: + +```haskell +data MsgSigning = MsgSigning + { sigBindingTag :: BindingTag + , sigPrefix :: ByteString -- binding-specific, e.g. smpEncode (rootKey, memberId) + , sigPrivKey :: C.PrivateKeyEd25519 + } +``` + +`sigBindingTag` goes into `MsgSignatures` on the wire (tells verifier which binding to reconstruct). `sigPrefix` is the binding-specific bytes. The signing function combines: `smpEncode sigBindingTag <> sigPrefix <> jsonBody`. + +Group-specific constructor: +```haskell +groupMsgSigning :: GroupKeys -> GroupMember -> MsgSigning +groupMsgSigning GroupKeys {groupRootKey, memberPrivKey} GroupMember {memberId} = + MsgSigning BTGroup (smpEncode (groupRootPubKey groupRootKey, memberId)) memberPrivKey +``` + +For contacts in the future — different constructor, different binding tag, same `MsgSigning` record and same `createSndMessages` path. + +### C. Per-Event Signing Decision — Caller, Not Policy + +The decision of whether to sign each event lives with the caller, not inside `createSndMessages`. The caller provides `Maybe MsgSigning` per event: + +```haskell +createSndMessages :: (MsgEncodingI e, Traversable t) + => t (ConnOrGroupId, ChatMsgEvent e, Maybe MsgSigning) + -> CM' (t (Either ChatError SndMessage)) +``` + +In `sendGroupMessages_`: +```haskell +let signing evt = case groupKeys gInfo of + Just gk | requiresSignature (toCMEventTag evt) -> Just (groupMsgSigning gk (membership gInfo)) + _ -> Nothing + idsEvts = L.map (\evt -> (GroupId groupId, evt, signing evt)) events +``` + +`requiresSignature` is group policy — only roster-modifying events (`XGrpDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpMemDel`, `XGrpMemRole`, `XGrpMemRestrict`). Content is never signed (deniability). When contact signing is added, a different caller uses a different predicate — `createSndMessages` is mechanical. + +### D. Signature Storage — Persisted for History + +Signatures are persisted in `msg_sigs BLOB` column alongside `msg_body` in the same INSERT. One DB operation. + +**Why persist (not ephemeral):** History delivery needs original signatures. In relay groups, history is forwarded with signatures preserved. In non-relay groups (if signing is extended), own sent signatures must survive for delivery to new members. Persisting from the start avoids losing generality. + +`msg_body` remains unchanged (JSON, backward compatible). Content and authentication are orthogonal. + +### E. Signing Scope — Deniability vs Authentication + +Only roster-modifying messages are signed. Content messages (`XMsgNew` etc.) are NEVER signed. + +1. **Deniability** — signing content creates non-repudiable proof of authorship. Anyone with the message bytes could prove who wrote it. Antithetical to SimpleX's privacy model. + +2. **Threat model** — relay manipulation of content is detectable post-hoc via cross-relay consistency (multiple independent relays). Sufficient because content is not irreversible. Roster/profile changes are disruptive and irreversible (member removed, role changed, group deleted) — must be authenticated at processing time. + +### F. Symmetric Encoding + +```haskell +encodeMsgElement :: Maybe MsgSignatures -> ByteString -> ByteString +encodeMsgElement Nothing body = body +encodeMsgElement (Just sigs) body = "/" <> smpEncode sigs <> body +``` + +Dual of `elementP`'s `'/'`/`'{'` cases. Used by both send batcher (`batchMessages`) and forward batcher (`batchDeliveryTasks1`). No signing logic in any batcher — only structural encoding. + +### E. Delivery Tasks: `msgBody` not `chatMessage` + +`MessageDeliveryTask` carries `msgBody :: ByteString` (raw JSON from `msg_body`) + `msgSignatures_ :: Maybe MsgSignatures` — NOT `chatMessage :: ChatMessage 'Json`. + +**Why `msgBody` is sufficient:** +- All delivery task processing is structural — encode, batch, send. Content decisions happen at task CREATION time (in `processEvent`), not delivery time. +- `DJRelayRemoved` currently wraps `chatMessage` in JSON `XGrpMsgForward` — but should use binary encoding instead (same `>element` format as normal batching, just single-element). Binary encoding only needs raw bytes + signatures, not parsed ChatMessage. +- More general — works for any future message type without coupling to JSON. +- Eliminates a parse+re-encode cycle (raw bytes → ChatMessage → chatMsgToBody → bytes). + +### F. DJRelayRemoved: Binary Encoding + +Current: wraps chatMessage in JSON `XGrpMsgForward` event. New: produces binary batch with single `>/` element, same as normal forwarding. The receiver already handles binary forwarded elements through `elementP` → `xGrpMsgForward`. + +### G. Verification with Binding + +```haskell +verifySig gInfo GroupMember {memberPubKey = Just pk, memberId} + (Just MsgSigData {signatures = MsgSignatures {bindingTag = BTGroup, signatures}, signedBody}) + | Just gk <- groupKeys gInfo = + let binding = smpEncode ('G', groupRootPubKey (groupRootKey gk), memberId) + in all (\(MsgSignature KRMember sig) -> C.verify pk sig (binding <> signedBody)) signatures +verifySig _ _ _ = True +``` + +### H. Signature Enforcement + +**Must be signed** (reject if unsigned in relay groups with keys): +- `XGrpDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpMemDel`, `XGrpMemRole`, `XGrpMemRestrict` + +**Not signed** (deniability — see §E): +- `XMsgNew` and all other content events + +**Conditionally signed:** +- `XGrpMemNew` — not always signed because members/subscribers can join via chat relays. Signed when owners/admins add members directly. Enforcement is context-dependent (checks sender role, not just event tag). + +**Channel posts** (`FwdChannel`): validate if signed, strip before forwarding. + +### I. Expose in UI + +Two display paths in CLI: + +**Path 1: Chat item history** (also used by mobile UI) +- `CIMeta.msgSigned :: Bool` — set during chat item creation +- Flow: `VerifiedMsg` → `isJust signedMsg_` → `RcvMessage.msgSigned` → `createNewRcvChatItem` → `createNewChatItem_` (INSERT with `msg_signed`) → SELECT reads `msg_signed` → `mkCIMeta` → View.hs +- Migration: `ALTER TABLE chat_items ADD COLUMN msg_signed` (in `chat_relays` migration) +- Note: `RcvMessage` is a goner (see pending refactor). In future, `msgSigned` flows from `VerifiedMsg` directly. + +**Path 2: Immediate CLI events** (ChatEvent/ChatResponse) +- Receive events: add `Bool` to ChatEvent constructors that correspond to signed events + - `CEvtMemberRole` — XGrpMemRole + - `CEvtMemberBlockedForAll` — XGrpMemRestrict + - `CEvtDeletedMemberUser` — XGrpMemDel (self) + - `CEvtDeletedMember` — XGrpMemDel (other) + - `CEvtGroupDeleted` — XGrpDel + - `CEvtGroupUpdated` — XGrpInfo / XGrpPrefs +- Send responses: add `Bool` to ChatResponse constructors for send-side + - `CRMembersRoleUser` — APIMembersRole + - `CRMembersBlockedForAllUser` — APIBlockMembersForAll + - `CRUserDeletedMembers` — APIRemoveMembers + - `CRGroupDeletedUser` — APIDeleteChat (group) + - `CRGroupUpdated` — APIUpdateGroupProfile +- Source: receive `msgSigned` from `RcvMessage`; send from `useRelays' gInfo` +- View.hs: append " (signed)" to event text when Bool is True + +**Correlation: `requiresSignature` events ↔ CLI display** + +| Event | Receive ChatEvent | Send ChatResponse | +|-------|-------------------|-------------------| +| XGrpDel | CEvtGroupDeleted | CRGroupDeletedUser | +| XGrpInfo | CEvtGroupUpdated | CRGroupUpdated | +| XGrpPrefs | CEvtGroupUpdated | CRGroupUpdated | +| XGrpMemDel | CEvtDeletedMember[User] | CRUserDeletedMembers | +| XGrpMemRole | CEvtMemberRole | CRMembersRoleUser | +| XGrpMemRestrict | CEvtMemberBlockedForAll | CRMembersBlockedForAllUser | + +### J. Pending Refactor: Remove RcvMessage + +`RcvMessage` carries redundant fields (`msgBody`, `authorMember` never read; `chatMsgEvent`, `sharedMsgId_` derivable from `verifiedMsg`). Plan: +1. Remove `RcvMessage` type +2. `NewRcvMessage` = `verifiedMsg` + `brokerTs` + `forwardedByMember` (drop `chatMsgEvent`) +3. `createNewRcvMessage` returns just `msgId` +4. Consumers extract what they need from `verifiedMsg` already in scope + +## Implementation Steps + +### Step 1: Foundation — Types + Encoding + Storage Schema ✅ + +- `ChatBinding = CBGroup` with `Encoding` instance (was `BindingTag`) +- `MsgSignatures { chatBinding :: ChatBinding, signatures :: NonEmpty MsgSignature }` +- `MsgSigning { bindingTag, bindingData, keyRef, privKey }` — generic signing context record +- `encodeBatchElement` in `Batch.hs` (moved from Protocol.hs) +- `requiresSignature :: CMEventTag e -> Bool` +- Migration: `ALTER TABLE messages ADD COLUMN msg_sigs BLOB` +- `SndMessage` gains `msgSignatures_ :: Maybe MsgSignatures` +- `createNewRcvMessage`: already accepts and stores `Maybe MsgSignatures` + +### Step 2: Sign on Send + Verify with Binding ✅ + +- `groupMsgSigning :: GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning` in Internal.hs — takes GroupInfo, decides per-event +- `createSndMessages` takes `(ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent e)` triples +- `createNewSndMessage` accepts `Maybe MsgSigning`, signs inline, stores `msg_sigs` in same INSERT +- `batchMessages` encodes elements via `encodeBatchElement` (two parallel lists, encode once per message) +- `verifySig` in Subscriber.hs reconstructs binding prefix from `GroupInfo` + `memberId`, verifies with `C.verify` +- Removed dead code: `signGroupMessages`, `updateSndMsgSignatures`, `groupSignFn`, `signMsgBody` + +### Step 3: Store, Forward, Verify — End-to-End + +Steps 3-5 from the original plan are one flow. They must ship together because the e2e test — member A signs → relay stores → relay forwards → member B verifies — is the only meaningful test. + +#### Critical invariant: original bytes must be preserved + +JSON round-trip through aeson doesn't preserve key ordering. Currently `msg_body` is stored via `chatMsgToBody chatMsg` (re-encoded from parsed `ChatMessage`). These bytes may differ from what the sender signed. For signature verification after forwarding, the relay must store the **original** bytes in `msg_body`. + +When `elementP` parses a signed element (`/`), `A.match msgP` captures the exact JSON bytes as `signedBody` in `MsgSigData`. This is what must be stored as `msg_body` for signed messages. + +For unsigned messages, `chatMsgToBody chatMsg` is fine — no signature to preserve. + +#### E2E Flow + +``` +Member A Relay Member B +───────── ───── ──────── +sign(roster event) + ↓ +/ ──────────→ receive + parse (elementP) + msgSig_ has signedBody (exact bytes) + verify (withVerifiedSig) + store signedBody as msg_body ──(a) + store MsgSignatures as msg_sigs + ↓ + read msg_body + msg_sigs from DB ──(b) + >/ ──────→ receive + parse + elementP: > → / → json + msgSig_ has signedBody + verify (withVerifiedSig) + store signedBody + sigs ──(c) +``` + +#### (a) Relay receives signed message → stores with original bytes + +**Current call chain** (Subscriber.hs → Internal.hs → Store/Messages.hs): + +``` +processAChatMsg(line 920) — has msgSig_ (with signedBody), chatMsg + │ passes chatMsg only, msgSig_ not threaded + ▼ +processEvent(line 941) — has chatMsg only + │ body = chatMsgToBody chatMsg ← RE-ENCODES, loses original bytes + ▼ +saveGroupRcvMsg(Internal.hs:2218) — params: user, groupId, member, conn, msgMeta, body, chatMsg + │ no signature parameter + ▼ +createNewMessageAndRcvMsgDelivery(Store/Messages.hs:262) — no signature parameter + │ passes Nothing for msgSignatures_ + ▼ +createNewRcvMessage(Store/Messages.hs:294) — HAS Maybe MsgSignatures param, receives Nothing + │ + ▼ +INSERT INTO messages ... msg_body=RE-ENCODED, msg_sigs=Nothing +``` + +**Changes (6 functions):** + +1. **`processAChatMsg`** (Subscriber.hs:920→934): pass `msgSig_` to `processEvent` + - Current: `processEvent gInfo' m' chatMsg` + - New: `processEvent gInfo' m' chatMsg msgSig_` + +2. **`processEvent`** (Subscriber.hs:941): accept `Maybe MsgSigData`, use `signedBody` when signed + - Current sig: `GroupInfo -> GroupMember -> ChatMessage e -> CM (Maybe NewMessageDeliveryTask)` + - New sig: `GroupInfo -> GroupMember -> ChatMessage e -> Maybe MsgSigData -> CM (Maybe NewMessageDeliveryTask)` + - Current: `let body = chatMsgToBody chatMsg` + - New: `let body = maybe (chatMsgToBody chatMsg) signedBody msgSig_` + - Extract: `let sigs_ = signatures <$> msgSig_` (where `signatures :: MsgSigData -> MsgSignatures`) + - Pass both `body` and `sigs_` to `saveGroupRcvMsg` + +3. **`saveGroupRcvMsg`** (Internal.hs:2218): add `Maybe MsgSignatures` parameter + - Current sig: `User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> CM (...)` + - New sig: `User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> Maybe MsgSignatures -> CM (...)` + - Pass to `createNewMessageAndRcvMsgDelivery` + - 1 caller: Subscriber.hs:944 + +4. **`createNewMessageAndRcvMsgDelivery`** (Store/Messages.hs:262): add `Maybe MsgSignatures` parameter + - Current sig: `DB.Connection -> ConnOrGroupId -> NewRcvMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage` + - New: add `Maybe MsgSignatures` after `Maybe SharedMsgId` + - Current: passes `Nothing` to `createNewRcvMessage` + - New: passes the received `Maybe MsgSignatures` + - 2 callers: `saveGroupRcvMsg` (Internal.hs:2226) and `saveDirectRcvMSG` (Internal.hs:2215) + - `saveDirectRcvMSG` passes `Nothing` (direct messages not signed yet) + +5. **`createNewRcvMessage`** (Store/Messages.hs:294): no change — already has `Maybe MsgSignatures` param + +After change: +``` +INSERT INTO messages ... msg_body=ORIGINAL_BYTES, msg_sigs=MsgSignatures +``` + +#### (b) Relay reads delivery tasks → forwards with preserved signatures + +**Current call chain** (Store/Delivery.hs → Delivery.hs → Batch.hs): + +``` +getMsgDeliveryTask_(Store/Delivery.hs:130) + │ SQL: SELECT ... msg.msg_body ... ← no msg_sigs + │ Row type: ... ChatMessage 'Json ... ← parsed via FromField, RE-ENCODES on read + ▼ +MessageDeliveryTask { chatMessage :: ChatMessage 'Json } (Delivery.hs:128) + ▼ +batchDeliveryTasks1(Batch.hs:73) + │ destructures: MessageDeliveryTask {taskId, fwdSender, brokerTs, chatMessage} + ▼ +encodeFwdElement(Batch.hs:96) — takes GrpMsgForward -> ChatMessage 'Json -> ByteString + │ ">" <> smpEncode fwd <> chatMsgToBody chatMessage ← RE-ENCODES AGAIN + ▼ +Wire: > ← signature would fail +``` + +**Changes (5 functions/types):** + +6. **`MessageDeliveryTask`** (Delivery.hs:128): replace `chatMessage` field + - Current: `chatMessage :: ChatMessage 'Json` + - New: `msgBody :: ByteString, msgSignatures_ :: Maybe MsgSignatures` + - `chatMessage` used only in 2 places: `batchDeliveryTasks1` (Batch.hs:86) and `DJRelayRemoved` (Subscriber.hs:3375) — both just encode, no content inspection + +7. **`MessageDeliveryTaskRow`** (Store/Delivery.hs:128): change column type + - Current: `... ChatMessage 'Json, BoolInt` + - New: `... DB.Binary, Maybe MsgSignatures, BoolInt` + +8. **`getMsgDeliveryTask_`** (Store/Delivery.hs:130): add `msg.msg_sigs` to SELECT + - Current SQL: `msg.msg_body, t.message_from_channel` + - New SQL: `msg.msg_body, msg.msg_sigs, t.message_from_channel` + - `toTask`: destructure `DB.Binary` as raw bytes, `Maybe MsgSignatures` from `msg_sigs` + +9. **`encodeFwdElement`** (Batch.hs:96): take raw bytes + signatures + - Current sig: `GrpMsgForward -> ChatMessage 'Json -> ByteString` + - New sig: `GrpMsgForward -> Maybe MsgSignatures -> ByteString -> ByteString` + - Body: `">" <> smpEncode fwd <> encodeBatchElement sigs_ msgBody` + +10. **`batchDeliveryTasks1`** (Batch.hs:73): use new task fields + - Current: `MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, chatMessage} = task` + - New: `MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, msgBody, msgSignatures_} = task` + - Current: `msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} chatMessage` + - New: `fwdBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} msgSignatures_ msgBody` + +After change: +``` +Wire: >/ ← signature valid +``` + +#### (c) Member receives forwarded message → stores with original bytes + +**Current call chain** (Subscriber.hs → Internal.hs → Store/Messages.hs): + +``` +xGrpMsgForward(Subscriber.hs:3159) — has chatMsg + msgSig_ (with signedBody) + ▼ +processForwardedMsg(Subscriber.hs:3172) — closure, has chatMsg, msgSig_ in scope but not used + │ body = chatMsgToBody chatMsg ← RE-ENCODES + ▼ +saveGroupFwdRcvMsg(Internal.hs:2237) — no signature parameter + │ passes Nothing to createNewRcvMessage + ▼ +createNewRcvMessage(Store/Messages.hs:294) — receives Nothing + ▼ +INSERT INTO messages ... msg_body=RE-ENCODED, msg_sigs=Nothing +``` + +**Changes (3 functions):** + +11. **`processForwardedMsg`** (Subscriber.hs:3172): use `signedBody` when signed, pass sigs + - `msgSig_` is in scope from `xGrpMsgForward` closure + - Current: `let body = chatMsgToBody chatMsg` + - New: `let body = maybe (chatMsgToBody chatMsg) signedBody msgSig_` + - Extract: `let sigs_ = signatures <$> msgSig_` + - Pass `sigs_` to `saveGroupFwdRcvMsg` + +12. **`saveGroupFwdRcvMsg`** (Internal.hs:2237): add `Maybe MsgSignatures` parameter + - Current sig: `User -> GroupInfo -> GroupMember -> Maybe GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage)` + - New: add `Maybe MsgSignatures` after `UTCTime` + - Current: passes `Nothing` to `createNewRcvMessage` + - New: passes the received `Maybe MsgSignatures` + - 1 caller: Subscriber.hs:3175 + +13. **`createNewRcvMessage`**: no change — already has param + +After change: +``` +INSERT INTO messages ... msg_body=ORIGINAL_BYTES, msg_sigs=MsgSignatures +``` + +#### (d) DJRelayRemoved — binary encoding + +**Current** (Subscriber.hs:3371-3382): +```haskell +let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, chatMessage} = task + fwdEvt = XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} chatMessage ← JSON wrapping + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + body = chatMsgToBody cm ← RE-ENCODES +createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body +``` + +**Change** (1 function, same location): + +14. **DJRelayRemoved handler** (Subscriber.hs:3374): use binary encoding + ```haskell + let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, msgBody, msgSignatures_} = task + fwd = GrpMsgForward {fwdSender, fwdBrokerTs} + body = encodeBinaryBatch [encodeFwdElement fwd msgSignatures_ msgBody] + createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + ``` + Receiver handles via `elementP` → same path as batched forwarding. + +#### (e) Enforcement — required signatures + +**Current**: `withVerifiedSig` (Subscriber.hs:3203) calls `verifySig` which returns `True` for `Nothing` (unsigned). All unsigned messages pass. + +**Change** (1 function): + +15. **`withVerifiedSig`** (Subscriber.hs:3203): add unsigned rejection + - Needs the event tag to check `requiresSignature` + - Current sig: `GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe MsgSigData -> UTCTime -> CM a -> CM (Maybe a)` + - New: add `CMEventTag e` parameter, or pass from caller + - Logic: if `isNothing msgSig_` AND `groupKeys gInfo` is `Just` AND `requiresSignature tag` → reject + +#### (f) Channel stripping + +**Current** (Subscriber.hs:3169): `FwdChannel -> processForwardedMsg Nothing` — skips `withVerifiedSig` entirely. + +**Change** (in `xGrpMsgForward`): + +16. For `FwdChannel`: validate signature if present (call `verifySig`), then call `processForwardedMsg` with `msgSig_` replaced by `Nothing` — strips signatures before storage. Channel posts are anonymous; storing the author's signature would leak identity. + +#### Summary: 16 function changes + +| # | Function | File | Change | +|---|----------|------|--------| +| 1 | `processAChatMsg` | Subscriber.hs:920 | Pass `msgSig_` to `processEvent` | +| 2 | `processEvent` | Subscriber.hs:941 | Accept `Maybe MsgSigData`, use `signedBody` as body when signed | +| 3 | `saveGroupRcvMsg` | Internal.hs:2218 | Add `Maybe MsgSignatures` parameter (1 caller) | +| 4 | `createNewMessageAndRcvMsgDelivery` | Store/Messages.hs:262 | Add `Maybe MsgSignatures` parameter (2 callers: group passes sigs, direct passes Nothing) | +| 5 | `createNewRcvMessage` | Store/Messages.hs:294 | No change — already has param | +| 6 | `MessageDeliveryTask` | Delivery.hs:128 | `msgBody :: ByteString` + `msgSignatures_` instead of `chatMessage` | +| 7 | `MessageDeliveryTaskRow` | Store/Delivery.hs:128 | `DB.Binary` + `Maybe MsgSignatures` instead of `ChatMessage 'Json` | +| 8 | `getMsgDeliveryTask_` | Store/Delivery.hs:130 | Add `msg.msg_sigs` to SELECT, read `msg_body` as raw bytes | +| 9 | `encodeFwdElement` | Batch.hs:96 | `GrpMsgForward -> Maybe MsgSignatures -> ByteString -> ByteString` | +| 10 | `batchDeliveryTasks1` | Batch.hs:73 | Use task's `msgBody` + `msgSignatures_` | +| 11 | `processForwardedMsg` | Subscriber.hs:3172 | Use `signedBody` as body when signed, pass sigs | +| 12 | `saveGroupFwdRcvMsg` | Internal.hs:2237 | Add `Maybe MsgSignatures` parameter (1 caller) | +| 13 | `createNewRcvMessage` | Store/Messages.hs:294 | No change — already has param | +| 14 | DJRelayRemoved handler | Subscriber.hs:3374 | Binary encoding with `encodeFwdElement` | +| 15 | `withVerifiedSig` | Subscriber.hs:3203 | Reject unsigned messages when `requiresSignature` in relay group with keys | +| 16 | `xGrpMsgForward` FwdChannel | Subscriber.hs:3169 | Validate sig if present, strip before storage | + +#### Test + +E2E test in relay group with keys: +1. Member A sends `XGrpMemRole` (requires signature) → signed in DB on A +2. Relay receives → verifies → stores `signedBody` as `msg_body` + `MsgSignatures` as `msg_sigs` +3. Relay reads `msg_body` + `msg_sigs` from DB → `>/` on wire +4. Member B receives → `elementP` parses >→/→json → `signedBody` has original bytes → verifies → stores +5. Unsigned `XGrpDel` from member without keys → rejected by enforcement +6. Channel post with signature → signature stripped before storage + +## Files + +| File | Step | Changes | +|------|------|---------| +| `Protocol.hs` | 1,2 | `ChatBinding`, `MsgSignatures` encoding, `MsgSigning`, `requiresSignature` | +| `Messages.hs` | 1 | `SndMessage` + `msgSignatures_` | +| `Store/Messages.hs` | 1,2,3 | `createNewSndMessage` signs + stores; `createNewRcvMessage` already has sig param; `createNewMessageAndRcvMsgDelivery` add sig param | +| Migration | 1 | `msg_sigs` column | +| `Internal.hs` | 2,3 | `groupMsgSigning`; `createSndMessages` per-event signing; `saveGroupRcvMsg` + `saveGroupFwdRcvMsg` add sig params | +| `Batch.hs` | 2,3 | `encodeBatchElement` in `batchMessages`; `encodeFwdElement` takes sigs + raw bytes; `batchDeliveryTasks1` uses raw task fields | +| `Subscriber.hs` | 2,3 | `verifySig` with binding; `processAChatMsg`→`processEvent` thread `msgSig_`; `processForwardedMsg` use `signedBody`; `withVerifiedSig` enforcement; channel strip; DJRelayRemoved binary | +| `Delivery.hs` | 3 | `MessageDeliveryTask`: `msgBody` + `msgSignatures_` instead of `chatMessage` | +| `Store/Delivery.hs` | 3 | `MessageDeliveryTaskRow` + `getMsgDeliveryTask_`: read `msg_sigs` + raw `msg_body` | diff --git a/plans/2026-03-29-desktop-text-selection.md b/plans/2026-03-29-desktop-text-selection.md new file mode 100644 index 0000000000..888000ab4c --- /dev/null +++ b/plans/2026-03-29-desktop-text-selection.md @@ -0,0 +1,432 @@ +# Desktop Text Selection Plan + +## Goal +Cross-message text selection on desktop (Compose Multiplatform): +1. Click+drag to select message text, with auto-scroll +2. Only message text is selectable (no timestamps, names, quotes, dates — like Telegram web) +3. Ctrl+C and copy button +4. Selection persists across scroll + +## Architecture + +### Selection State + +Selection is two endpoints in the item list: + +```kotlin +data class SelectionRange( + val startIndex: Int, // anchor — where drag began, immutable during drag + val startOffset: Int, // character offset within anchor item + val endIndex: Int, // focus — where pointer is now + val endOffset: Int // character offset within focus item +) +``` + +```kotlin +enum class SelectionState { Idle, Selecting, Selected } +``` + +SelectionManager holds: +```kotlin +var selectionState: SelectionState // mutableStateOf +var range: SelectionRange? // mutableStateOf, null in Idle +var focusWindowY by mutableStateOf(0f) // pointer Y in window coords +var focusWindowX by mutableStateOf(0f) // pointer X in window coords +``` + +No captured map. No eager text extraction. +Indices are stable across scroll. Text extracted at copy time from live data. + +### State Machine + +``` + drag threshold + Idle ─────────────────→ Selecting + ↑ │ + │ click │ pointer up + │ ▼ + ←──────────────────── Selected +``` + +### Pointer Handler (on LazyColumn Modifier) + +`SelectionHandler` composable (BoxScope extension) returns a Modifier for +LazyColumnWithScrollBar. Contains `pointerInput`, `onGloballyPositioned`, +`focusRequester`, `focusable`, `onKeyEvent`. + +On every pointer move during Selecting: +1. Updates `focusWindowY/X` +2. Uses `listState.layoutInfo.visibleItemsInfo` to find item at pointer Y → updates `range.endIndex` + +Index resolution uses LazyListState directly — no map, no registration. + +### Pointer Handler Behavior Per State + +Non-press events (hover, scroll) skipped: `return@awaitEachGesture`. +State captured at gesture start (`wasSelected`). + +**Idle**: Down not consumed. Links/menus work. Drag threshold → Selecting. +**Selecting**: Pointer move → update focusWindowY/X, resolve endIndex via listState. + Pointer up → Selected. +**Selected**: Down consumed (prevents link activation). Click → Idle. Drag → new Selecting. + +### Anchor Char Offset Resolution + +The anchor item knows it's the anchor: `range.startIndex == myIndex`. +Resolves char offset ONCE at selection start via LaunchedEffect: + +```kotlin +val isAnchor = remember(myIndex) { + derivedStateOf { manager.range?.startIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + val bounds = boundsState.value ?: return@LaunchedEffect + val layout = layoutResultState.value ?: return@LaunchedEffect + val offset = layout.getOffsetForPosition( + Offset(manager.focusWindowX - bounds.left, manager.focusWindowY - bounds.top) + ) + manager.setAnchorOffset(offset) +} +``` + +Fires once. No ongoing effect. + +### Focus Char Offset Resolution + +The focus item knows it's the focus: `range.endIndex == myIndex`. +Resolves char offset on every pointer move via snapshotFlow: + +```kotlin +val isFocus = remember(myIndex) { + derivedStateOf { manager.range?.endIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { manager.focusWindowY to manager.focusWindowX } + .collect { (py, px) -> + val bounds = boundsState.value ?: return@collect + val layout = layoutResultState.value ?: return@collect + val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) + manager.updateFocusOffset(offset) + } + } +} +``` + +- Starts when item becomes focus, cancels when focus moves to different item +- snapshotFlow fires on pointer move, but only in ONE item +- Uses item's own local TextLayoutResult — no shared map + +### Highlight Rendering (Per Item) + +Each item computes highlight via derivedStateOf: + +```kotlin +val highlightRange = remember(myIndex) { + derivedStateOf { highlightedRange(manager.range, myIndex) } +} +``` + +`highlightedRange` is a standalone function: +```kotlin +fun highlightedRange(range: SelectionRange?, index: Int): IntRange? { + val r = range ?: return null + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + if (index < lo || index > hi) return null + val forward = r.startIndex <= r.endIndex + val startOff = if (forward) r.startOffset else r.endOffset + val endOff = if (forward) r.endOffset else r.startOffset + return when { + index == lo && index == hi -> minOf(startOff, endOff) until maxOf(startOff, endOff) + index == lo -> startOff until Int.MAX_VALUE // clamped by MarkdownText + index == hi -> 0 until endOff + else -> 0 until Int.MAX_VALUE // clamped by MarkdownText + } +} +``` + +derivedStateOf only triggers recomposition when the RESULT changes for this item. +Middle items don't recompose as range extends. Only boundary items recompose. + +### Highlight Drawing + +`getPathForRange(range.first, range.last + 1)` in `drawBehind` on BasicText. +`range.last + 1` because IntRange.last is inclusive, getPathForRange end is exclusive. + +Gated on `selectionRange != null`: +- When null (Android, or desktop without selection): original `Text()` used, no drawBehind. +- When non-null: `SelectableText` (BasicText + drawBehind + onTextLayout) or + `ClickableText` with added drawBehind. + +### Reserve Space Exclusion + +MarkdownText's `buildAnnotatedString` appends invisible reserve text after message +content. A local `var selectableEnd` is set to `this.length` inside `buildAnnotatedString` +right before reserve is appended. Used to clamp `selectionRange` before passing +downstream to rendering: + +```kotlin +var selectableEnd = 0 +val annotatedText = buildAnnotatedString { + // ... content ... + selectableEnd = this.length + // ... typing indicator, reserve ... +} +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +// pass clampedRange to ClickableText/SelectableText +``` + +`selectableEnd` is local to MarkdownText. Not passed upstream. +`highlightedRange` uses `Int.MAX_VALUE` for open-ended ranges; +MarkdownText resolves them to the actual content boundary. + +### Copy + +#### `displayText` function + +Non-composable function placed right next to MarkdownText in TextItemView.kt. +Computes the displayed text from `formattedText`, handling only the few Format +types that change the displayed string. All other formats use `ft.text` unchanged. +Used only at copy time. + +```kotlin +// Must be coordinated with MarkdownText — same text transformations for: +// Mention, HyperLink, SimplexLink, Command +fun displayText( + ci: ChatItem, + linkMode: SimplexLinkMode, + sendCommandMsg: Boolean +): String { + val formattedText = ci.formattedText + if (formattedText == null) return ci.text + return formattedText.joinToString("") { ft -> + when (ft.format) { + is Format.Mention -> { /* resolve display name from ci.mentions */ } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { /* showText or description + viaHosts */ } + is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text + else -> ft.text + } + } +} +``` + +MarkdownText gets a corresponding comment noting these transformations must match. + +#### Copy text extraction + +On SelectionManager: +```kotlin +fun getSelectedText(items: List, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + val forward = r.startIndex <= r.endIndex + val startOff = if (forward) r.startOffset else r.endOffset + val endOff = if (forward) r.endOffset else r.startOffset + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + val text = displayText(ci, linkMode, sendCommandMsg = false) + when { + idx == lo && idx == hi -> text.substring( + startOff.coerceAtMost(text.length), + endOff.coerceAtMost(text.length) + ) + idx == lo -> text.substring(startOff.coerceAtMost(text.length)) + idx == hi -> text.substring(0, endOff.coerceAtMost(text.length)) + else -> text + } + }.joinToString("\n") +} +``` + +### Auto-Scroll + +Direction-aware: only the edge you're dragging toward. +After `scrollBy()`, re-resolve index from `listState.layoutInfo.visibleItemsInfo` +with same pointer Y. Different item may be under pointer → endIndex updates. +Indices don't shift on scroll. Focus item's snapshotFlow handles new charOffset. + +### Mouse Wheel During Drag + +Scroll event passes through to LazyColumn (not consumed by handler). +`snapshotFlow` on scroll offset fires → re-resolve index from listState → update endIndex. + +### Ctrl+C / Cmd+C + +`onKeyEvent` on LazyColumn modifier (inside SelectionHandler's returned Modifier). +Focus requested on selection start. When user taps compose box, focus moves there — +Ctrl+C goes to compose box handler. Copy button works regardless of focus. +Checks `isCtrlPressed || isMetaPressed`. + +### Copy Button + +Emitted by SelectionHandler in BoxScope. Visible in Selected state. +Copies without clearing. Click in chat clears selection. + +### Eviction Prevention + +`ChatItemsLoader.kt`: `allowedTrimming = !selectionActive` during selection. + +### Platform Gate + +All selection code gated on `appPlatform.isDesktop`. + +### Swipe-to-Reply + +Disabled on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier`. + +### RTL Text + +`getOffsetForPosition` and `getPathForRange` are bidi-aware. No direction assumptions. + +--- + +## Effects Summary + +### Idle State +Zero effects. Items don't check anything. `range` is null. + +### Selecting State + +| What | Scope | Fires when | +|------|-------|-----------| +| Pointer event handling | LazyColumn pointerInput (total: 1) | Every pointer event | +| Index resolution | Pointer handler via listState (total: 1) | Every pointer move + scroll | +| Anchor char offset | Anchor item LaunchedEffect (1 item) | Once at selection start | +| Focus char offset | Focus item snapshotFlow (1 item) | Every pointer move | +| Highlight derivedStateOf | Per item (passive) | Only when result changes (~2 items) | +| Auto-scroll | Coroutine in pointer handler (total: 0 or 1) | Near edge during drag | +| Scroll re-evaluation | snapshotFlow on scroll offset (total: 1) | On scroll during drag | + +### Selected State +Zero effects. Frozen range. Items render highlight from derivedStateOf (no recomposition +unless range changes, which it doesn't in Selected state). + +--- + +## Changes From Master + +### NEW: TextSelection.kt + +New file: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` + +Contains: +- `SelectionRange(startIndex, startOffset, endIndex, endOffset)` data class +- `SelectionState` enum (Idle, Selecting, Selected) +- `SelectionManager` — holds `selectionState`, `range`, `focusWindowY/X` (mutableStateOf), + methods: `startSelection`, `setAnchorOffset`, `updateFocusIndex`, `updateFocusOffset`, + `endSelection`, `clearSelection`, `getSelectedText(items, linkMode)` +- `highlightedRange(range, index)` standalone function +- `LocalSelectionManager` CompositionLocal +- `SelectionHandler` composable (BoxScope extension, returns Modifier for LazyColumn): + pointer input with state machine, auto-scroll, focus management, Ctrl+C/Cmd+C, copy button +- `SelectionCopyButton` composable +- `resolveIndexAtY` helper for pointer → item index via listState + +### TextItemView.kt + +**Add `displayText` function** right next to MarkdownText, with comment that it +must be coordinated with MarkdownText's text transformations. Takes `ChatItem`, +`linkMode`, `sendCommandMsg`. Used only by `getSelectedText` at copy time. + +**Add comment to MarkdownText** noting `displayText` must match its text transformations. + +**Add 2 parameters to MarkdownText**: +- `selectionRange: IntRange? = null` +- `onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null` + +**Inside MarkdownText** — local `var selectableEnd` set in both `buildAnnotatedString` +blocks (1 line each, right before typing indicator / reserve). Clamp selectionRange: +```kotlin +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +``` + +**Rendering** — gated on `clampedRange != null`: +- `Text()` call sites (2): `if (clampedRange != null) SelectableText(...) else Text(...)` + Original `Text(...)` call unchanged. +- `ClickableText` call: add `selectionRange = clampedRange`, + add `onTextLayout = { onTextLayoutResult?.invoke(it) }` + +**Add `selectionRange` parameter to `ClickableText`**, add `drawBehind` highlight +with `getPathForRange(range.first, range.last + 1)` before BasicText. + +**Add `SelectableText` private composable** — BasicText + drawBehind highlight + +onTextLayout. Used only when `selectionRange != null`. On Android, never reached. + +**MarkdownText is NOT restructured.** No code moved, no branches regrouped. + +### FramedItemView.kt — CIMarkdownText + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0 && !ci.meta.isLive`): +- `boundsState: MutableState` — from `onGloballyPositioned` on the Box +- `layoutResultState: MutableState` — from `onTextLayoutResult` +- `isAnchor` derivedStateOf + LaunchedEffect (resolves anchor offset once) +- `isFocus` derivedStateOf + LaunchedEffect with snapshotFlow (resolves focus offset) +- `highlightRange` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) }` + +**MarkdownText call**: add `selectionRange = highlightRange`, +`onTextLayoutResult = { layoutResultState.value = it }` + +### EmojiItemView.kt + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0`): +- `isAnchor`/`isFocus` LaunchedEffects (full-selection only: offset 0 / emojiText.length) +- `isSelected` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) != null }` +- Highlight via `Modifier.background(SelectionHighlightColor)` when selected + +### ChatView.kt + +- Create `SelectionManager`, provide via `LocalSelectionManager` +- `SelectionHandler` returns Modifier applied to LazyColumnWithScrollBar +- Pass `selectionIndex` from `itemsIndexed` through the call chain: + `ChatViewListItem` → `ChatItemViewShortHand` → `ChatItemView` (item/) → + `FramedItemView` → `CIMarkdownText`. Each gets `selectionIndex: Int = -1` param. +- Same for EmojiItemView path +- Gate SwipeToDismiss on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier` +- Sync `selectionState != Idle` to `chatState.selectionActive` via LaunchedEffect + +### ChatItemsLoader.kt + +- `removeDuplicatesAndModifySplitsOnBeforePagination`: add `selectionActive: Boolean = false` param +- `allowedTrimming = !selectionActive` +- Call site passes `chatState.selectionActive` + +### ChatItemsMerger.kt + +- `ActiveChatState`: add `@Volatile var selectionActive: Boolean = false` + +### ChatModel.kt — no change + +### MarkdownHelpView.kt — no change + +--- + +## Testing + +1. Single message partial character selection +2. Multi-message selection with highlights +3. Direction reversal past anchor +4. Selection shrinks on reverse (items unhighlight) +5. Selection persists after drag end and across scroll +6. Auto-scroll extends selection correctly +7. Auto-scroll loads items from DB +8. Mouse wheel during drag extends selection +9. Items scrolling out and back in retain highlight +10. Click on links works (Idle state) +11. Click in chat clears selection (Selected state) +12. Right-click behavior +13. Ctrl+C / Cmd+C copies selected text +14. Copy button works +15. Highlight stops before invisible reserve space +16. Copy produces clean text +17. RTL text +18. Emoji-only messages +19. Live messages excluded +20. Edited messages during selection diff --git a/plans/2026-03-29-initial-open-last-unread-block.md b/plans/2026-03-29-initial-open-last-unread-block.md new file mode 100644 index 0000000000..034b57118b --- /dev/null +++ b/plans/2026-03-29-initial-open-last-unread-block.md @@ -0,0 +1,199 @@ +# Initial chat open: jump to last unread block + +## Problem + +When opening a chat with unread messages, the app always scrolls to the oldest unread message (`minUnreadItemId`). For casual group members with hundreds of unreads, this forces them to scroll through the entire backlog to reach new messages. + +The bottom circle scrolls to the latest messages without marking all as read (by design — moderators use this to reply quickly, then return to the top circle to read sequentially). But the next time the chat opens, it jumps back to the oldest unread. + +Users want the initial open to skip old unreads and land on the "new" ones — messages that arrived after their last interaction. + +## Design + +Change `CPInitial` to use a different pivot for `getDirectChatAround'` / `getGroupChatAround'`. + +Currently the pivot is `minUnreadItemId` (absolute first unread). Instead, try `maxViewedItemId` (last non-unread item in sort order) first. If not found, fall back to `minUnreadItemId`. The `getAround'` function is unchanged — it always loads `CRBefore`/`CRAfter` around the pivot and includes it. + +**maxViewedItemId**: the last item in sort order that is not `CISRcvNew`. +- "Viewed" means received read or sent — any `item_status != CISRcvNew`. + +### Why include works for both pivots + +`getDirectChatAround'` always includes the pivot in the result. When the pivot is maxViewed (a read item), including it adds one extra read item — harmless. The client scrolls to the first unread in the loaded items, which is the first item in `afterCIs` (the new unreads after the gap). The include/exclude distinction is unnecessary. + +### Sort order + +- Groups: `(item_ts, chat_item_id)` +- Direct chats: `(created_at, chat_item_id)` + +### Cases + +Items in display order (left = oldest/top, right = newest/bottom). U = unread (`CISRcvNew`), R = not unread (read, sent, event). + +**Case 1: Unreads contiguous from bottom, no gap** +``` +R...R U...U + ↑ maxViewed (last R) used as pivot +``` +`afterCIs` = the unreads. Same as current behavior. + +**Case 2: Gap, then new unreads at bottom** +``` +R...R U...U R...R U...U + ↑ maxViewed used as pivot +``` +`afterCIs` = new unreads only. Skips old unreads. This is the desired improvement. + +**Case 3: Gap at bottom, no new unreads** +``` +R...R U...U R...R + ↑ maxViewed used as pivot +``` +`afterCIs` = empty. Items loaded are the latest. Old unreads reachable via top circle. + +**Case 4: All unread** +``` +U...U +``` +maxViewed = NULL. Fall back to `minUnreadItemId` as pivot. Current behavior. + +**Case 5: No unreads** + +`maxViewedItemId` returns some item but `minUnreadItemId` returns `Nothing` — no unreads exist. Handled by stats showing zero unreads. `getAround'` loads items around maxViewed, which are the latest items. + +Actually: maxViewed is always found when items exist (every chat has at least sent items or read items unless case 4). So the flow is: maxViewed found → load around it → stats show 0 unreads → client shows latest items. + +### No UI changes needed + +Only `CPInitial` backend logic changes. The top circle, unread counter, unread separator, and all pagination continue to use `minUnreadItemId` as before. + +### Out-of-order delivery + +A late-arriving group message with old `item_ts` but recent `created_at` sorts into the old unread block in display order and gets skipped on initial open. This is acceptable — the top circle still reaches it. + +### Notes (local chat) + +Notes (`getLocalChatInitial_`) have unread handling in code but it's dead — all items are sent, `CISRcvNew` never occurs. No change needed. + +### Open concern + +In case 2, `CRBefore(maxViewed)` loads items before the gap, which may include old unreads. The client's scroll logic finds the first unread in loaded items (`lastIndex(where: hasUnread)` in reversed list), which could be an old unread from `beforeCIs` rather than a new unread from `afterCIs`. To be validated during testing — if problematic, may need client-side adjustment or limiting `beforeCIs` count. + +## Implementation + +### Files to modify + +`src/Simplex/Chat/Store/Messages.hs` — all changes are here. + +### New functions + +#### Direct chats + +```haskell +-- max viewed item: received read or sent (any item_status != CISRcvNew) +getContactMaxViewedItemId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId) +``` + +Query: +```sql +SELECT chat_item_id +FROM chat_items +WHERE user_id = ? AND contact_id = ? AND item_status != ? +ORDER BY created_at DESC, chat_item_id DESC +LIMIT 1 +``` + +#### Groups + +```haskell +-- max viewed item: received read or sent (any item_status != CISRcvNew) +getGroupMaxViewedItemId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) +``` + +Mirrors `queryUnreadGroupItems` structure but with `item_status != ?` instead of `item_status = ?`. Handles the same 4-case scope/content filter dispatch. New function `queryViewedGroupItems`. + +Query (for the no-scope, no-content-filter case): +```sql +SELECT chat_item_id +FROM chat_items +WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL + AND item_status != ? +ORDER BY item_ts DESC, chat_item_id DESC +LIMIT 1 +``` + +### Modified functions + +#### `getDirectChatInitial_` + +Current: +```haskell +getDirectChatInitial_ db user ct contentFilter count = do + liftIO (getContactMinUnreadId_ db user ct) >>= \case + Just minUnreadItemId -> do + unreadCount <- liftIO $ getContactUnreadCount_ db user ct + let stats = emptyChatStats {unreadCount, minUnreadItemId} + getDirectChatAround' db user ct contentFilter minUnreadItemId count "" stats + Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" +``` + +New — only the pivot source changes, rest stays the same: +```haskell +getDirectChatInitial_ db user ct contentFilter count = do + liftIO (getContactMaxViewedItemId_ db user ct >>= maybe (getContactMinUnreadId_ db user ct) (pure . Just)) >>= \case + Just pivotId -> do + unreadCount <- liftIO $ getContactUnreadCount_ db user ct + minUnreadItemId <- fromMaybe 0 <$> liftIO (getContactMinUnreadId_ db user ct) + let stats = emptyChatStats {unreadCount, minUnreadItemId} + getDirectChatAround' db user ct contentFilter pivotId count "" stats + Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" +``` + +#### `getGroupChatInitial_` + +Same minimal change — only the pivot source: +```haskell +getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do + (getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter >>= maybe (getGroupMinUnreadId_ db user g scopeInfo_ contentFilter) (pure . Just)) >>= \case + Just pivotId -> do + stats <- getGroupStats_ db user g scopeInfo_ + getGroupChatAround' db user g scopeInfo_ contentFilter pivotId count "" stats + Nothing -> do + stats <- liftIO $ getStats 0 (0, 0) + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats + where + getStats minUnreadItemId (unreadCount, unreadMentions) = do + reportsCount <- getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} +``` + +### Summary of all affected functions + +| Function | Change | +|----------|--------| +| `getDirectChatInitial_` | Try maxViewed first, fall back to minUnread, same `getAround'` call | +| `getGroupChatInitial_` | Same | +| **New:** `getContactMaxViewedItemId_` | MAX non-unread by (created_at DESC, id DESC) | +| **New:** `getGroupMaxViewedItemId_` | MAX non-unread with scope/filter dispatch | +| **New:** `queryViewedGroupItems` | Like `queryUnreadGroupItems` but `item_status != ?` | + +Nothing else changes. `getDirectChatAround'`, `getGroupChatAround'`, `getDirectChatAround_`, `getGroupChatAround_`, `getContactMinUnreadId_`, `getGroupMinUnreadId_`, `getContactStats_`, `getGroupStats_`, `getChatItemIDs`, `NavigationInfo` computation, mark-read operations, UI code — all unchanged. + +### Performance + +- `maxViewedItemId` query: scans backward from the largest sort key, skipping unread items. Fast when there are recent read/sent items (the common case). Worst case: all items are unread — returns `Nothing` and falls back to minUnread. + +- All other queries are existing code, same performance. + +- Queries run only during `CPInitial` (chat open). No writes. + +### Testing + +1. Open chat with unreads, no prior interaction → same as current (case 1/4) +2. Open chat, jump to bottom (marks bottom screen read), close, reopen → lands on new unreads after the gap (case 2) +3. Open chat, jump to bottom, reply, close, new messages arrive, reopen → lands on new messages (case 2) +4. Open chat, jump to bottom, close, no new messages, reopen → loads around last viewed item at bottom (case 3) +5. Open chat, read from top (marks first screen read), close, reopen → lands on next unread after first screen, same as current (case 1) +6. Group with scope (member support chat) → same behavior with scope filter applied +7. Group with content filter (reports) → same behavior with content filter applied diff --git a/plans/2026-04-01-agent-sign-for-address.md b/plans/2026-04-01-agent-sign-for-address.md new file mode 100644 index 0000000000..c648574399 --- /dev/null +++ b/plans/2026-04-01-agent-sign-for-address.md @@ -0,0 +1,61 @@ +# Plan: Agent API — getConnLinkPrivKey + +**Date: 2026-04-01** + +## Context + +The chat relay test (`APITestChatRelay`) requires the relay to sign a challenge with its address private key (`ShortLinkCreds.linkPrivSigKey`). This key is stored in the agent's database on `RcvQueue` and is not accessible from the chat layer. A new agent API function is needed to retrieve it. + +The chat layer performs the signing itself with `C.sign'`. + +## API + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +- `ConnId` — the agent connection ID +- Returns — `Just linkPrivSigKey` if the connection has short link credentials, `Nothing` otherwise + +## Implementation + +**File: `simplexmq/src/Simplex/Messaging/Agent.hs`** + +1. Add to module exports: + ```haskell + getConnLinkPrivKey, + ``` + +2. Add public function (near `getConnShortLink`, ~line 427): + ```haskell + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c + {-# INLINE getConnLinkPrivKey #-} + ``` + +3. Add implementation (near `deleteConnShortLink'`, ~line 1089): + ```haskell + getConnLinkPrivKey' :: AgentClient -> ConnId -> AM (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey' c connId = do + SomeConn _ conn <- withStore c (`getConn` connId) + pure $ case conn of + ContactConnection _ rq -> linkPrivSigKey <$> shortLink rq + RcvConnection _ rq -> linkPrivSigKey <$> shortLink rq + _ -> Nothing + ``` + +## Design notes + +- Local operation (no network IO) — synchronous, fast +- No `withConnLock` — this is a pure read with no mutations; the lock would add latency for no benefit. Read-only agent operations like `getConn` don't require the conn lock. +- Returns `Maybe` — `Nothing` if connection has no short link credentials or is wrong type +- Handles both `ContactConnection` and `RcvConnection` (both have `RcvQueue` with `shortLink` field, Store.hs:159) +- Chat layer signs: `C.sign' privKey challenge` +- `linkPrivSigKey :: C.PrivateKeyEd25519` on `ShortLinkCreds` (Protocol.hs:1456) +- `shortLink :: Maybe ShortLinkCreds` on `StoredRcvQueue` (Store.hs:159) + +## Verification + +```bash +cd simplexmq && cabal build --ghc-options=-O0 +``` diff --git a/plans/2026-04-01-test-chat-relay-plan.md b/plans/2026-04-01-test-chat-relay-plan.md new file mode 100644 index 0000000000..905f7962c1 --- /dev/null +++ b/plans/2026-04-01-test-chat-relay-plan.md @@ -0,0 +1,813 @@ +# Plan: APITestChatRelay — Relay Liveness + Identity Verification + +**Date: 2026-04-01** + +## Context + +Channel owners configure relays by address but have no way to verify a relay is alive, authentic, or to discover its profile before creating a channel. A broken or impersonated relay means a broken channel. + +`APITestChatRelay` solves this by: +1. Fetching the relay's short link data (validates SMP server reachability + retrieves relay profile) +2. Running a challenge-response handshake (`XGrpRelayTest`) that proves the relay controls its address private key (`linkPrivSigKey`) +3. Returning the relay profile and test result to the UI + +The test can run before any `chat_relays` DB record exists — the UI uses the returned profile to populate the relay name field. + +No DB schema changes are needed — `name` column remains in `chat_relays`. The Haskell type `UserChatRelay` changes from `name :: Text` to `relayProfile :: RelayProfile`, wrapping the same DB column. + +--- + +## Data Flow + +``` +Owner SMP Server Relay + | | | + |--- getShortLinkConnReq ----------->| | + |<-- FixedLinkData{rootKey,cReq} ----| | + | + ConnLinkData{RelayAddressLinkData{relayProfile}} | + | | | + |--- joinConnection(XGrpRelayTest{challenge}) ---------------------->| + | | REQ with challenge | + | | relay signs challenge | + | | with linkPrivSigKey | + |<-- CONF(XGrpRelayTest{signature}) ----------------------------------| + | verify: C.verify' rootKey sig challenge | + | cleanup connections on both sides | +``` + +--- + +## Types + +### RelayProfile (Protocol.hs) + +```haskell +data RelayProfile = RelayProfile {name :: ContactName} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''RelayProfile) +``` + +Simpler than `Profile` — relay identity needs only a name. Can be extended later with image, description, etc. + +### RelayAddressLinkData (Protocol.hs) + +```haskell +data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile} + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayAddressLinkData) +``` + +Stored as `userData` in the relay's contact address short link data. Separate from `ContactShortLinkData` (which has irrelevant `message`/`business` fields) and `RelayShortLinkData` (per-group relay links). + +### XGrpRelayTest (Protocol.hs) + +```haskell +XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json +``` + +Single constructor used in both directions: +- **Owner → Relay** (in joinConnection connInfo): `XGrpRelayTest challenge Nothing` +- **Relay → Owner** (in acceptContact connInfo): `XGrpRelayTest challenge (Just signature)` + +The relay profile is NOT included — the owner already has it from `RelayAddressLinkData` in the short link's `userData` (retrieved in step 1 via `decodeLinkUserData`). + +JSON encoding (follows `(.=?)` chain pattern, e.g. `XGrpMemDel`): +```haskell +XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] +``` + +JSON parsing: +```haskell +XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ +``` + +Where `decodeSig` converts `B64UrlByteString` to `Parser (C.Signature 'C.Ed25519)` using `<$?>` (from `Simplex.Messaging.Util`, already imported in Protocol.hs): +```haskell +decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) +decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s +``` + +`(<$?>) :: MonadFail m => (a -> Either String b) -> m a -> m b` — converts `Either` errors into `MonadFail` failures. `JQ.Parser` has `MonadFail`. + +Note: `B64UrlByteString` is defined in `Types.hs:151` — add import to Protocol.hs if not already imported. + +### RelayTestError (Controller.hs) + +```haskell +data RelayTestStep + = RTSGetLink -- fetching short link data from SMP server + | RTSDecodeLink -- decoding RelayAddressLinkData from link userData + | RTSConnect -- preparing and joining connection + | RTSWaitResponse -- waiting for relay's signed response + | RTSVerify -- verifying relay's signature + deriving (Show) + +data RelayTestFailure = RelayTestFailure + { rtfStep :: RelayTestStep, + rtfDescription :: String + } + deriving (Show) +``` + +Pattern follows `ProtocolTestFailure {testStep, testError}` from simplexmq. + +### RelayTest (Controller.hs) + +```haskell +data RelayTest = RelayTest + { challenge :: ByteString, + rootKey :: C.PublicKeyEd25519, + result :: TMVar (Maybe RelayTestFailure) + } +``` + +- `challenge` — random bytes sent to relay +- `rootKey` — from `FixedLinkData`, used to verify relay's signature +- `result` — `Nothing` = success, `Just failure` = error + +### UserChatRelay type change (Operators.hs) + +`UserChatRelay'` changes `name :: Text` to `relayProfile :: RelayProfile`: + +```haskell +data UserChatRelay' s = UserChatRelay + { chatRelayId :: DBEntityId' s, + address :: ShortLinkContact, + relayProfile :: RelayProfile, -- was: name :: Text + domains :: [Text], + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } +``` + +`relayProfile` is non-optional — always present: +- Before testing: user provides name → `RelayProfile {name = userProvidedName}` +- After testing: relay's actual profile replaces the user-provided one + +No DB migration needed — `name TEXT` column stays in `chat_relays`. The `RelayProfile` wrapper is applied at the Haskell read/write boundary: + +**Constructors:** +```haskell +-- newChatRelay_ (Operators.hs:341): name parameter wraps into RelayProfile +newChatRelay_ preset enabled name domains !address = + UserChatRelay {chatRelayId = DBNewEntity, address, relayProfile = RelayProfile {name}, domains, ...} +``` + +**DB reads** — `toChatRelay` (Profiles.hs:636) and `toGroupRelay` (Groups.hs:1337): wrap `name` column value: +```haskell +-- toChatRelay: name from DB → RelayProfile + UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains = ..., ...} +``` + +**DB writes** — `insertChatRelay`, `updateChatRelay`, `undeleteRelay` (Profiles.hs): unwrap `RelayProfile` to get `name` for column: +```haskell +-- insertChatRelay: destructure relayProfile +insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {name}, ...} = do +``` + +**Validation** — `chatRelayErrs` (Operators.hs:546): uses `name` from `relayProfile` for duplicate checking: +```haskell +duplicateErrs_ (AUCR _ UserChatRelay {relayProfile = RelayProfile {name}, address}) = ... +allNames = map (\(AUCR _ UserChatRelay {relayProfile = RelayProfile {name}}) -> name) cRelays +``` + +**View** — `viewChatRelay` (View.hs:1581): uses `name` from `relayProfile`: +```haskell +viewChatRelay UserChatRelay {relayProfile = RelayProfile {name}, address, ...} = name <> ... +``` + +**`createRelayForOwner`** (Groups.hs:1342): uses `relayProfile` directly instead of `profileFromName name`: +```haskell +createRelayForOwner db vr gVar user gInfo UserChatRelay {relayProfile = RelayProfile {name}} = do + let memberProfile = profileFromName name + ... +``` + +**JSON** — `deriveJSON` on `UserChatRelay'` picks up the field rename automatically. The JSON changes from `"name": "bob"` to `"relayProfile": {"name": "bob"}`. Mobile apps need to update their model types accordingly. + +### ChatController field + +```haskell +chatRelayTests :: TMap ConnId RelayTest, +``` + +### ChatCommand + +```haskell +| APITestChatRelay UserId ShortLinkContact +| TestChatRelay ShortLinkContact +``` + +Takes a `ShortLinkContact` (`ConnShortLink 'CMContact`) — relay addresses are always short links. This matches `UserChatRelay.address :: ShortLinkContact` and is directly accepted by `getShortLinkConnReq :: ... -> ConnShortLink m -> ...`. + +### ChatResponse + +```haskell +| CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} +``` + +- On success: `relayProfile = Just p, testFailure = Nothing` +- On failure at link fetch/decode: `relayProfile = Nothing, testFailure = Just err` (profile not yet available) +- On failure at connect/verify: `relayProfile = Just p, testFailure = Just err` (profile from link data) + +--- + +## Implementation + +### Phase 1: Protocol — XGrpRelayTest + RelayAddressLinkData + RelayProfile + +**File: `src/Simplex/Chat/Protocol.hs`** + +1. Add `RelayProfile` type (near `RelayShortLinkData`, ~line 1444): + - `data RelayProfile = RelayProfile {name :: ContactName}` + - `deriveJSON` + +2. Add `RelayAddressLinkData` type (after `RelayShortLinkData`): + - `data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile}` + - `deriveJSON` + +3. Add `XGrpRelayTest` constructor (after `XGrpRelayAcpt`, ~line 438): + - `XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json` + +4. Add event tag `XGrpRelayTest_` (after `XGrpRelayAcpt_`, ~line 966) + +5. Add tag string `"x.grp.relay.test"` (after `"x.grp.relay.acpt"`, ~line 1022) + +6. Add tag parsing (after `XGrpRelayAcpt_` parse, ~line 1079) + +7. Add event-to-tag mapping (after `XGrpRelayAcpt` mapping, ~line 1132): + - `XGrpRelayTest {} -> XGrpRelayTest_` + +8. Add JSON parsing (~line 1284): + ```haskell + XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ + ``` + Where: + ```haskell + decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) + decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s + ``` + +9. Add JSON encoding (~line 1351): + ```haskell + XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] + ``` + +### Phase 2: UserChatRelay type change + +**Files: `src/Simplex/Chat/Operators.hs`, `src/Simplex/Chat/Store/Profiles.hs`, `src/Simplex/Chat/Store/Groups.hs`, `src/Simplex/Chat/View.hs`** + +Change `UserChatRelay'` field `name :: Text` → `relayProfile :: RelayProfile` and update all 10 use sites as described in the Types section above. No DB migration — `name` column stays, `RelayProfile` wraps/unwraps at read/write boundary. + +### Phase 3: Controller types — RelayTest, RelayTestFailure, commands, response + +**File: `src/Simplex/Chat/Controller.hs`** + +1. Add `RelayTestStep` and `RelayTestFailure` types (near `ProtocolTestFailure` usage) + +2. Add `RelayTest` type + +3. Add `chatRelayTests :: TMap ConnId RelayTest` field to `ChatController` (after `relayRequestWorkers`, ~line 252) + +4. Uncomment and update `APITestChatRelay` (lines 401-403): + ```haskell + | APITestChatRelay UserId ShortLinkContact + | TestChatRelay ShortLinkContact + ``` + +5. Add `CRChatRelayTestResult` to `ChatResponse` (after `CRServerTestResult`, ~line 667): + ```haskell + | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} + ``` + +**File: `src/Simplex/Chat.hs`** + +6. Initialize `chatRelayTests` in `newChatController` (after `relayRequestWorkers`, ~line 175): + ```haskell + chatRelayTests <- TM.emptyIO + ``` + Add `chatRelayTests` to the record construction (~line 218). + +### Phase 4: Agent API — getConnLinkPrivKey (simplexmq change) + +The relay needs to sign the challenge with `ShortLinkCreds.linkPrivSigKey`, which is stored in the agent's DB on `RcvQueue`. The chat layer has no direct access to the key. + +**New agent API function in `simplexmq/src/Simplex/Messaging/Agent.hs`:** + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +Implementation: +1. Look up `SomeConn` by `ConnId` via `withStore c getConn` +2. Pattern match on `ContactConnection _ rq` or `RcvConnection _ rq` +3. Return `linkPrivSigKey <$> shortLink rq` (returns `Nothing` if no short link creds) + +The chat layer then signs: `C.sign' privKey challenge`. + +This is a local operation (no network IO), so it's synchronous. + +**Separate plan file:** `plans/agent-sign-for-address.md` + +### Phase 5: Commands.hs — APITestChatRelay handler + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add `import System.Timeout (timeout)`. + +Add handler after `APITestProtoServer` (~line 1491): + +```haskell +APITestChatRelay userId address -> withUserId userId $ \user -> do + -- Step 1: Fetch link data (validates SMP server + gets profile) + let failAt step desc = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step desc) + r <- tryAllErrors $ getShortLinkConnReq nm user address + case r of + Left e -> failAt RTSGetLink (show e) + Right (FixedLinkData {rootKey, linkConnReq = cReq}, cData) -> do + -- Step 2: Decode relay profile from link data + relayProfile_ <- liftIO $ decodeLinkUserData cData + case relayProfile_ of + Nothing -> failAt RTSDecodeLink "no relay address link data" + Just RelayAddressLinkData {relayProfile} -> do + let failWithProfile step desc = + pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step desc) + -- Step 3: Generate challenge + prepare connection + gVar <- asks random + challenge <- liftIO $ atomically $ C.randomBytes 32 gVar + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> failWithProfile RTSConnect "invalid connection request" + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn@Connection {connId = dbConnId} <- withFastStore $ \db -> + createRelayTestConnection db vr user connId ConnPrepared chatV subMode + -- Register test in TMap + testVar <- newEmptyTMVarIO + let acId = aConnId conn + relayTest = RelayTest {challenge, rootKey, result = testVar} + chatRelayTests_ <- asks chatRelayTests + atomically $ TM.insert acId relayTest chatRelayTests_ + -- Join with challenge, wrapped in tryAllErrors for cleanup safety + testResult <- tryAllErrors $ do + dm <- encodeConnInfo $ XGrpRelayTest challenge Nothing + void $ withAgent $ \a -> joinConnection a nm (aUserId user) acId True cReq dm PQSupportOff subMode + liftIO $ timeout 40_000_000 $ atomically $ takeTMVar testVar + -- Cleanup always (even on error) + atomically $ TM.delete acId chatRelayTests_ + withFastStore' $ \db -> deleteConnectionRecord db user dbConnId + deleteAgentConnectionAsync acId + case testResult of + Left e -> failWithProfile RTSConnect (show e) + Right Nothing -> failWithProfile RTSWaitResponse "timeout" + Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing + Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) +TestChatRelay address -> withUser $ \User {userId} -> + processChatCommand vr nm $ APITestChatRelay userId address +``` + +Also add CLI parsing for `TestChatRelay` in the command parser. + +Key points: +- `address :: ShortLinkContact` — passes directly to `getShortLinkConnReq` (no type mismatch) +- `conn@Connection {connId = dbConnId}` — explicit pattern match avoids `DuplicateRecordFields` ambiguity +- `tryAllErrors` wraps only the join+wait block; cleanup runs unconditionally after it +- `tryAllErrors` (from `Simplex.Messaging.Util`) catches ALL exceptions via `UE.catch`, not just `ChatError` +- `void $ withAgent $ \a -> joinConnection ...` — discards `(SndQueueSecured, Maybe ClientServiceId)` return + +### Phase 6: Subscriber.hs — Event handlers + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +#### Owner side: processDirectMessage CONF handler (contact_ = Nothing) + +Modify the CONF handler at lines 407-417. Before the existing flow, check if this connection is a relay test: + +```haskell +Nothing -> case agentMsg of + CONF confId pqSupport _ connInfo -> do + -- Check if this is a relay test connection + chatRelayTests_ <- asks chatRelayTests + relayTest_ <- atomically $ TM.lookup agentConnId chatRelayTests_ + case relayTest_ of + Just RelayTest {challenge, rootKey, result = testVar} -> do + -- Parse response + r <- tryAllErrors $ do + ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + case chatMsgEvent of + XGrpRelayTest _challenge sig_ -> + case sig_ of + Just sig + | C.verify' rootKey sig challenge -> + atomically $ putTMVar testVar Nothing -- success + | otherwise -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "invalid signature") + Nothing -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "no signature in response") + _ -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse "unexpected message type") + case r of + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse (show e)) + Right () -> pure () + Nothing -> do + -- Existing flow (unchanged) + conn' <- processCONFpqSupport conn pqSupport + (conn'', gInfo_) <- saveConnInfo conn' connInfo + ... +``` + +Note: `agentConnId` is in scope from the `processAgentMessageConn` closure (Subscriber.hs:354). + +#### Relay side: processContactConnMessage REQ handler + +Add `XGrpRelayTest` case after `XGrpRelayInv` at line 1247: + +```haskell +XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge +``` + +Add `xGrpRelayTest` function near `xGrpRelayInv` (~line 1450): + +```haskell +xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () +xGrpRelayTest invId chatVRange challenge = do + -- Retrieve private key from address connection's short link creds, sign in chat layer + privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) + case privKey_ of + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` chatVRange + void $ agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV +``` + +Note: `conn` is the user contact address connection (from `processContactConnMessage` closure). Its `aConnId` is the agent `ConnId` that holds `ShortLinkCreds` with `linkPrivSigKey`. The agent returns `Maybe` — `Nothing` if the connection has no short link credentials (shouldn't happen for a properly configured relay, but handled gracefully — owner will timeout with `RTSWaitResponse`). + +### Phase 7: Store — createRelayTestConnection + +**File: `src/Simplex/Chat/Store/Direct.hs`** + +Add function to create a ConnContact connection without entity: + +```haskell +createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnContact) + :. (chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId +``` + +Pattern: same as `createRelayConnection` (Store/Groups.hs:1388) but `ConnContact` type with no `group_member_id`. + +The resulting row has `contact_id = NULL`, `contact_conn_initiated = 0` (column default), `xcontact_id = NULL`, `via_contact_uri = NULL`. This distinguishes it from `createConnReqConnection` rows which always set `contact_conn_initiated = 1`, `xcontact_id`, and `via_contact_uri`. + +### Phase 8: APICreateMyAddress — Use RelayAddressLinkData + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Update `APICreateMyAddress` (~line 2162-2176) for relay users: + +```haskell +-- Current code (line 2168-2169): +-- TODO [relays] relay: add relay profile, identity, key to link data? +let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing + +-- New code for relay users: +let userData = if isTrue userChatRelay + then encodeShortLinkData $ RelayAddressLinkData + { relayProfile = RelayProfile {name = displayName (fromLocalProfile $ profile' user)} + } + else contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing +``` + +### Phase 9: Test connection cleanup + +Test connections are `ConnContact` with no entity (`contact_id = NULL`). They should be cleaned up if the test API handler crashes or times out without cleanup. + +Add `cleanupStaleRelayTestConns` step to `cleanupUser` in `cleanupManager` (after `cleanupInProgressGroups`, ~line 4500): + +```haskell +cleanupStaleRelayTestConns user `catchAllErrors` eToView +liftIO $ threadDelay' stepDelay +``` + +Implementation: +```haskell +cleanupStaleRelayTestConns user = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-300) ts -- 5 minutes + staleConns <- withStore' $ \db -> getStaleRelayTestConns db user cutoffTs + forM_ staleConns $ \acId -> do + deleteAgentConnectionAsync acId + withStore' $ \db -> deleteConnectionByAgentConnId db user acId +``` + +Where `getStaleRelayTestConns` queries: +```sql +SELECT agent_conn_id FROM connections +WHERE user_id = ? AND conn_type = 'contact' AND contact_id IS NULL + AND conn_status = 'prepared' AND contact_conn_initiated = 0 + AND created_at < ? +``` + +This uniquely identifies stale test connections. The `contact_conn_initiated = 0` discriminator is critical because `createConnReqConnection` (Store/Direct.hs:164) also creates `ConnContact` rows with `contact_id = NULL` and `conn_status = ConnPrepared`, but it always sets `contact_conn_initiated = True` (line 175). Test connections from `createRelayTestConnection` inherit the column default of 0. + +**No new DB column needed.** + +### Phase 10: Views (iOS + Android/Desktop) + +**iOS:** +- `apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` +- `apps/ios/Shared/Views/NewChat/AddChannelView.swift` + +**Android/Desktop:** +- `apps/multiplatform/.../ChatRelayView.kt` +- `apps/multiplatform/.../AddChannelView.kt` + +Changes: +1. Add "Test" button next to relay address that calls `APITestChatRelay address` +2. On success: show relay profile name, optionally auto-fill name field +3. On failure: show error description from `RelayTestFailure` +4. Show relay status indicator: untested / tested-ok / tested-failed + +### Phase 11: View — CRChatRelayTestResult + +**File: `src/Simplex/Chat/View.hs`** + +Add `CRChatRelayTestResult` case after `CRServerTestResult` (~line 127): + +```haskell +CRChatRelayTestResult u relayProfile_ testFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ testFailure_ +``` + +Add `viewRelayTestResult` function near `viewServerTestResult` (~line 1600): + +```haskell +viewRelayTestResult :: Maybe RelayProfile -> Maybe RelayTestFailure -> [StyledString] +viewRelayTestResult relayProfile_ = \case + Just RelayTestFailure {rtfStep, rtfDescription} -> + ["relay test failed at " <> plain (show rtfStep) <> ", error: " <> plain rtfDescription] + Nothing -> case relayProfile_ of + Just RelayProfile {name} -> ["relay test passed, profile: " <> plain (T.unpack name)] + Nothing -> ["relay test passed"] +``` + +Output examples: +- Success: `relay test passed, profile: bob` +- Decode failure: `relay test failed at RTSDecodeLink, error: no relay address link data` +- Link failure: `relay test failed at RTSGetLink, error: ...` + +### Phase 12: CLI parsing — TestChatRelay + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add CLI parser after `/relays` (~line 4771): + +```haskell +"/relay test " *> (TestChatRelay <$> strP), +``` + +### Phase 13: Tests + +**File: `tests/ChatTests/ChatRelays.hs`** + +Add to `chatRelayTests`: +```haskell +describe "configure chat relays" $ do + ... + it "test chat relay" testChatRelayTest +``` + +#### Test: `testChatRelayTest` + +Single test function covering three scenarios sequentially. Uses alice (owner), bob (relay), and cath (normal user). + +```haskell +testChatRelayTest :: HasCallStack => TestParams -> IO () +testChatRelayTest ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + -- Setup: bob (relay) creates address + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + -- Setup: cath (normal user) creates address + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Scenario 1: Happy path — test relay address succeeds + -- Concurrent because alice's test command blocks while bob processes REQ + concurrentlyN_ + [ do + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob", + -- Bob's side is automatic (subscriber handles XGrpRelayTest) + -- but we need to consume any potential output on bob's side + pure () + ] + + -- Scenario 2: Non-relay address — cath is not a relay user, + -- her address has ContactShortLinkData, not RelayAddressLinkData + alice ##> ("/relay test " <> cathSLink) + alice <## "relay test failed at RTSDecodeLink, error: no relay address link data" + + -- Scenario 3: Deleted address — bob deletes his address + bob ##> "/da" + bob <## "Your chat address is deleted - accepted contacts will remain connected." + alice ##> ("/relay test " <> bobSLink) + -- Exact error message depends on SMP server response, match prefix + alice <## startsWith "relay test failed at RTSGetLink, error: " +``` + +**Key design decisions:** + +1. **One test, three scenarios** — avoids repeating setup (creating users, addresses) across three separate tests while covering happy path + two failure modes. + +2. **`concurrentlyN_` for happy path** — alice's `TestChatRelay` command blocks on a TMVar waiting for the relay's response. Bob's subscriber processes the REQ automatically via `xGrpRelayTest`, but the test framework needs both sides to run concurrently. The relay side may produce no visible CLI output (the `xGrpRelayTest` handler doesn't emit events to the view), so the relay branch is `pure ()`. + +3. **No concurrency for failure scenarios** — both fail before establishing a connection (at link fetch or decode step), so alice returns immediately with an error. + +4. **`startsWith` for SMP error** — the exact SMP error message may vary (network error, connection refused, etc.), so we match only the prefix `"relay test failed at RTSGetLink, error: "`. + +5. **Bob's output during happy path** — the relay's subscriber handles `XGrpRelayTest` silently (no `toView` call on success). After accepting, the agent creates a new connection whose subsequent events (JOINED, etc.) hit `getConnectionEntity` → `SEConnectionNotFound` → logged via `eToView`. This log noise may or may not appear as a test output line. If it does, we'd need to consume it in the `concurrentlyN_` bob branch. This needs to be verified during implementation — if bob produces output, add `bob <## ...` to consume it. + +**Helper needed:** `startsWith` — matches output lines by prefix. Check if this already exists in test utils: + +```haskell +startsWith :: String -> String -> Bool +startsWith = isPrefixOf +``` + +Or use an existing pattern like `<##.` if available. + +#### Scenarios NOT tested (and why): + +- **Signature verification failure (`RTSVerify`)** — would require the relay to sign with a wrong key. No mechanism to inject that without modifying the relay's behavior (e.g., a test-only flag). Not worth the complexity. +- **Timeout (`RTSWaitResponse`)** — would require the relay to not respond (e.g., by stopping the relay process). The test would take 40 seconds and be fragile. Not practical for a unit test. +- **Connection error (`RTSConnect`)** — would require the SMP server to be reachable (link data returned) but the connection request to fail. Hard to construct reliably. + +Existing relay config tests (`testGetSetChatRelays`, etc.) need updating for the `relayProfile` type change — CLI output changes from `bob_relay: ` to the same (the `name` field is now accessed via `relayProfile`), but the CLI command syntax stays the same (`/relays name=bob_relay `). + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Protocol.hs` | `RelayProfile`, `RelayAddressLinkData`, `XGrpRelayTest` + tags + parsing + encoding | +| `src/Simplex/Chat/Operators.hs` | `UserChatRelay'`: `name` → `relayProfile :: RelayProfile`; update `newChatRelay_`, validation | +| `src/Simplex/Chat/Controller.hs` | `RelayTestStep`, `RelayTestFailure`, `RelayTest`, `chatRelayTests`, `APITestChatRelay`, `CRChatRelayTestResult` | +| `src/Simplex/Chat.hs` | Initialize `chatRelayTests` in `newChatController` | +| `src/Simplex/Chat/Library/Commands.hs` | `APITestChatRelay` handler, `APICreateMyAddress` relay link data, CLI parsing, `cleanupManager` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Owner CONF handler pre-check, relay REQ handler `XGrpRelayTest` | +| `src/Simplex/Chat/Store/Direct.hs` | `createRelayTestConnection` | +| `src/Simplex/Chat/Store/Groups.hs` | `toGroupRelay`, `createRelayForOwner`: wrap/unwrap `RelayProfile` | +| `src/Simplex/Chat/Store/Profiles.hs` | `toChatRelay`, `insertChatRelay`, `updateChatRelay`, `undeleteRelay`: wrap/unwrap `RelayProfile`; `getStaleRelayTestConns` | +| `src/Simplex/Chat/View.hs` | `viewChatRelay`: use `relayProfile`; `CRChatRelayTestResult` + `viewRelayTestResult` | +| `apps/ios/.../ChatRelayView.swift` | `UserChatRelay` model update, test button + result display | +| `apps/ios/.../AddChannelView.swift` | Test integration | +| `apps/multiplatform/.../ChatRelayView.kt` | `UserChatRelay` model update, test button + result display | +| `apps/multiplatform/.../AddChannelView.kt` | Test integration | +| `tests/ChatTests/ChatRelays.hs` | `testChatRelayTest` | + +**Separate simplexmq change:** +| `simplexmq/src/Simplex/Messaging/Agent.hs` | `getConnLinkPrivKey` API | + +--- + +## Key Functions Reused + +- `getShortLinkConnReq` (Internal.hs:1339) — fetch link data + validate SMP + get connReq +- `decodeLinkUserData` (Internal.hs:1361) — decode `RelayAddressLinkData` from `ConnLinkData` +- `encodeShortLinkData` (Internal.hs:1351) — encode `RelayAddressLinkData` for link userData +- `prepareConnectionToJoin` (agent) — prepare agent connection for joining +- `joinConnection` (agent) — join relay's contact address +- `encodeConnInfo` (Internal.hs:1929) — encode `XGrpRelayTest` as connInfo +- `parseChatMessage` (Internal.hs:1563) — parse connInfo in CONF handler +- `agentAcceptContactAsync` (Internal.hs:2421) — relay accepts test connection +- `deleteAgentConnectionAsync` (Internal.hs:2428) — cleanup connections +- `deleteConnectionRecord` (Store/Shared.hs:895) — cleanup DB connection record (takes `Int64` DB connection_id) +- `getConnLinkPrivKey` (agent, new) — retrieve `linkPrivSigKey` from connection's short link creds +- `C.verify'` (simplexmq Crypto:1270) — `PublicKey a -> Signature a -> ByteString -> Bool` +- `C.sign'` (simplexmq Crypto:1175) — `PrivateKey a -> ByteString -> Signature a` +- `C.randomBytes` (simplexmq Crypto:1401) — `Int -> TVar ChaChaDRG -> STM ByteString` +- `eToView` (Controller.hs:1537) — `ChatError -> CM ()` — report error to view + +--- + +## Verification + +### Build +```bash +cabal build --ghc-options=-O0 +``` + +### Test +```bash +cabal test simplex-chat-test --test-options='-m "channels"' +cabal test simplex-chat-test --test-options='-m "chat relays"' +``` + +### Manual verification +1. Start relay user, set as chat relay, create address +2. Start owner user +3. Owner tests relay address → verify CRChatRelayTestResult with profile, no failure +4. Owner tests invalid address → verify failure at RTSGetLink +5. Kill owner during test → verify cleanup by cleanupManager after 5 min + +--- + +## Adversarial Self-Review + +### Pass 1 + +**Issue: Signature type in JSON** — `C.Signature 'C.Ed25519` is a GADT constructor. Need to verify it has JSON/Encoding instances and can be transmitted in a JSON chat message. +**Analysis:** `Signature` has no native JSON instance. For JSON, encode as base64 ByteString using `B64UrlByteString . C.signatureBytes`. For parsing, decode `B64UrlByteString` then `C.decodeSignature :: ByteString -> Either String (Signature 'C.Ed25519)` (Crypto.hs:849). The `(.=?)` pattern handles `Maybe` — only included when `Just`. +**Fix:** Encoding uses `B64UrlByteString . C.signatureBytes <$> sig_`. Parsing uses `traverse decodeSig =<< opt "signature"` where `decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s` (returns `JQ.Parser`, not `Either String`). No relay profile in message — owner gets it from link data. + +**Issue: `DuplicateRecordFields` on `connId`** — `connId :: Int64` appears on `Connection`, `PendingContactConnection`, and `UserContactRequest`. With `DuplicateRecordFields` enabled, `connId conn` won't compile as a field selector. +**Analysis:** Must use pattern matching. The handler uses `conn@Connection {connId = dbConnId}`. +**Fix:** Already applied in Phase 5 handler code. + +**Issue: `getConnLinkPrivKey` conn access** — In `xGrpRelayTest`, we call `getConnLinkPrivKey a (aConnId conn)` where `conn` is the user contact address connection. Does the agent's `getConn` find it by the correct ConnId? +**Analysis:** `processContactConnMessage` receives `conn :: Connection` which is the chat-layer connection record. `aConnId conn` gives the agent's `ConnId`. The agent stores `ShortLinkCreds` on the `RcvQueue` of the `ContactConnection` for this `ConnId`. The agent function pattern-matches on `ContactConnection _ rq` and returns `linkPrivSigKey <$> shortLink rq`. This is correct. +**Fix:** No fix needed. + +**Issue: `getConnLinkPrivKey` returns Nothing** — If the relay's address connection has no short link credentials, the relay-side handler logs an error via `eToView` and does not accept the test connection. +**Analysis:** This shouldn't happen for a properly configured relay (creating the address creates short link creds via `createConnection` in the agent). Handled gracefully — the owner will timeout with `RTSWaitResponse`. +**Fix:** No fix needed. + +**Issue: Test connection routing on relay side** — After the relay accepts the test via `agentAcceptContactAsync`, the agent creates a new connection. Future events on this connection (JOINED, etc.) arrive at `processAgentMessageConn`. Since there's no DB connection record, `getConnectionEntity` will fail with `SEConnectionNotFound`, producing error in `eToView`. This is log noise. +**Analysis:** Acceptable for MVP. The agent will eventually GC the connection. The error is harmless and happens for the relay only. The owner's connection is cleaned up by the handler. +**Fix:** Document as known behavior. + +**Issue: `tryAllErrors` behavior** — Does `tryAllErrors` catch all exceptions or just `ChatError`? +**Analysis:** `tryAllErrors` (Util.hs:249) uses `UE.catch` which catches `SomeException` — ALL exceptions, not just `ChatError`. It converts via `fromSomeException` into the error type. This is important: if `joinConnection` throws an IO exception, it's still caught and the cleanup runs. +**Fix:** No fix needed — the behavior is correct. + +**Issue: Multiple CONFs** — Could the owner receive multiple CONF events for the same connection? If yes, the second `putTMVar` would block. +**Analysis:** The SMP protocol sends exactly one CONF per connection. Multiple CONFs would be a protocol violation. +**Fix:** No fix needed. + +**Issue: Cleanup on timeout** — If the timeout fires (40s), the handler deletes the DB connection and agent connection. But the relay's response might arrive AFTER cleanup. +**Analysis:** After timeout, the TMap entry is deleted. A late CONF arriving at the subscriber finds no TMap entry, falls through to the existing flow, fails at `getConnectionEntity` (connection deleted). Harmless — `catchAllErrors eToView` absorbs it. +**Fix:** No fix needed. The cleanup sequence (delete TMap → delete DB → delete agent) is safe in all interleavings. + +### Pass 2 + +**Issue: `decodeLinkUserData cData`** — For relay addresses, `cData` is `ContactLinkData vr UserContactData{..}`. Does `decodeLinkUserData` decode the right field? +**Analysis:** `decodeLinkUserData` (Internal.hs:1361) is polymorphic — uses `JQ.decode` on the `userData` bytes from `UserContactData`. The caller constrains the type via the binding `Just RelayAddressLinkData {relayProfile}`. The `FromJSON` instance is provided by `deriveJSON`. +**Fix:** No fix needed. + +**Issue: `encodeShortLinkData`** — Will it work for `RelayAddressLinkData`? +**Analysis:** `encodeShortLinkData` (Internal.hs:1351) is polymorphic — `J.ToJSON a => a -> UserLinkData`. Uses `J.encode` and wraps in `UserLinkData`. Works for any type with `ToJSON`. +**Fix:** No fix needed. + +**Issue: Cleanup identification query safety** — `getStaleRelayTestConns` uses: `ConnContact + contact_id IS NULL + ConnPrepared + contact_conn_initiated = 0 + old created_at`. Could this match non-test connections? +**Analysis:** All code paths that create `ConnContact` with `contact_id = NULL`: +- `createConnReqConnection` (Direct.hs:158): sets `ConnPrepared` (line 164) BUT also sets `contact_conn_initiated = True` (line 175, `BI True`), `xcontact_id`, and `via_contact_uri`. The `contact_conn_initiated = 0` condition excludes these. +- `createRelayTestConnection` (new): sets `ConnPrepared`, inherits `contact_conn_initiated = 0` default. Matches the query. +- No other code path creates `ConnContact` with `contact_id = NULL` and `contact_conn_initiated = 0`. +**Fix:** The query is safe with the `contact_conn_initiated = 0` discriminator. + +**Issue: Partial failure cleanup** — If `prepareConnectionToJoin` succeeds but the `withFastStore` for `createRelayTestConnection` fails, the agent connection leaks. +**Analysis:** The `prepareConnectionToJoin` call happens before the `tryAllErrors` block. If `createRelayTestConnection` throws, we never reach cleanup. The agent connection from `prepareConnectionToJoin` would leak until restart. However, `createRelayTestConnection` is a simple INSERT — it's unlikely to fail. And if it does, `cleanupManager` won't catch it because no DB row was created. The agent-level connection will be cleaned up on agent restart. +**Fix:** Acceptable for MVP. Could wrap in a broader try-catch, but the failure mode is extremely unlikely and the consequence (one leaked agent connection) is minor. + +**Issue: `void $ withAgent $ \a -> joinConnection ...`** — The return type of `joinConnection` is `AE (SndQueueSecured, Maybe ClientServiceId)`. Using `void` discards both values. +**Analysis:** For the test connection, we don't need `SndQueueSecured` or `ClientServiceId`. The `addRelay` function (Commands.hs:3776) uses the return value to update connection status, but the test connection is deleted immediately anyway. +**Fix:** No fix needed. + +Both passes clean. No further issues found. diff --git a/plans/2026-04-02-desktop-voice-recording.md b/plans/2026-04-02-desktop-voice-recording.md new file mode 100644 index 0000000000..e29af72f34 --- /dev/null +++ b/plans/2026-04-02-desktop-voice-recording.md @@ -0,0 +1,39 @@ +# Desktop Voice Recording + +## Overview + +Implement voice recording on desktop using vlcj (already a dependency). The `RecorderNative` class is currently a stub. All UI is already in common code. + +## Files to modify + +1. `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt` — implement `RecorderNative` +2. `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` — remove desktop "in development" guard (line 317-318) +3. `apps/multiplatform/desktop/build.gradle.kts` — add `NSMicrophoneUsageDescription` to macOS Info.plist + +## RecorderNative implementation + +Uses `MediaPlayerFactory` + `MediaPlayer` to capture from default microphone and transcode to AAC/m4a via VLC's sout chain. + +Platform-specific capture MRLs: +- macOS: `qtsound://` +- Linux: `pulse://` +- Windows: `dshow://` with `:dshow-vdev=none :dshow-adev=` + +Transcode options: `vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000` — matches Android (mono, 16kHz, 32kbps AAC). + +Factory requires `--sout-avcodec-strict=-2` to enable FFmpeg's native AAC encoder. + +Progress tracked via elapsed time (VLC capture has no position API). Duration read via `AudioPlayer.duration()` after stop. + +Max duration: enforced by stopping recording after `MAX_VOICE_MILLIS_FOR_SENDING` (300,000 ms) in the progress coroutine. + +## macOS permission + +Add `NSMicrophoneUsageDescription` to Info.plist via Gradle `infoPlist` block. + +## What does NOT change + +- `RecorderInterface` (common) +- `ComposeView.kt`, `ComposeVoiceView` — already handle voice preview/sending +- Audio format — `.m4a` (matches Android) +- All voice recording UI — already in common code diff --git a/plans/channel_message_bugs_fix_plan.md b/plans/channel_message_bugs_fix_plan.md new file mode 100644 index 0000000000..c50b5ed7ff --- /dev/null +++ b/plans/channel_message_bugs_fix_plan.md @@ -0,0 +1,321 @@ +# Plan: Channel Message Bugs Fix + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Bug 1: Delivery Context Flag](#bug-1-delivery-context-flag) +3. [Bug 2: Reaction Attribution](#bug-2-reaction-attribution) +4. [Bug 3: Update Fallback Default](#bug-3-update-fallback-default) +5. [Bug 4: Forward API Parameter](#bug-4-forward-api-parameter) +6. [Bug 5: CLI Forward Hardcode](#bug-5-cli-forward-hardcode) +7. [Test Plan](#test-plan) +8. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +**5 bugs identified** in channel message handling: + +| # | Location | Bug | Severity | +|---|----------|-----|----------| +| 1 | Subscriber.hs:935-945 | Events use `isChannelOwner` instead of item's `showGroupAsSender` | Critical | +| 2 | Subscriber.hs:1818-1842 | Reactions allow `m_=Nothing` and fall back to membership | High | +| 3 | Subscriber.hs:1950-1969 | Update fallback creates item without correct sendAsGroup flag | Medium | +| 4 | Commands.hs:930,944 | Forward API ignores `_sendAsGroup` parameter | High | +| 5 | Commands.hs:2191,2196,2201,4633 | CLI forward hardcodes False | Medium | + +--- + +## Bug 1: Delivery Context Flag + +### Current Code (Subscriber.hs:935-945) +```haskell +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> isChannelOwner -- BUG: should use item's flag + XMsgDel {} -> isChannelOwner -- BUG + XMsgReact {} -> isChannelOwner -- BUG + XMsgFileDescr {} -> isChannelOwner -- BUG + XFileCancel {} -> isChannelOwner -- BUG + _ -> False +``` + +### Problem +Events referencing existing items (update, delete, react, file) compute `showGroupAsSender'` from **current sender role** (`isChannelOwner`) instead of **item's stored `showGroupAsSender` flag**. + +### Fix +Extract `showGroupAsSender` from the chat item being referenced: + +```haskell +showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> itemShowGroupAsSender ci -- from item lookup + XMsgDel {} -> itemShowGroupAsSender ci + XMsgReact {} -> itemShowGroupAsSender ci + XMsgFileDescr {} -> itemShowGroupAsSender ci + XFileCancel {} -> itemShowGroupAsSender ci + _ -> False +``` + +**Note:** Use `chatDir` from ChatItem and pattern match on `CIChannelRcv` to determine sendAsGroup flag. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 935-945 + +--- + +## Bug 2: Reaction Attribution + +### Current Code (Subscriber.hs:1818-1842) +```haskell +groupMsgReaction :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) +groupMsgReaction g m_ sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + ... + where + GroupInfo {membership} = g + reactor = fromMaybe membership m_ -- BUG (line 1842): uses membership when m_ is Nothing + ciDir = maybe CIChannelRcv CIGroupRcv m_ +``` + +### Problem +When `m_` is `Nothing`, reactor incorrectly falls back to `membership` (user's own member record). However, reactions should always come from an identifiable member - the `m_` parameter should never be `Nothing` for reactions. + +### Fix +Reactions can only come from members (including owners), never from channels. XMsgReact handler must be reworked to require `GroupMember` instead of `Maybe GroupMember`. The `m_` parameter should not be optional for reactions. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1818-1842 + +--- + +## Bug 3: Update Fallback Default + +### Current Code (Subscriber.hs:1950-1969) +```haskell +updateRcvChatItem `catchCINotFound` \_ -> do + (chatDir, mentions', scopeInfo) <- case m_ of + Just m -> ... + Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing) -- BUG: no sendAsGroup info + (ci, cInfo) <- saveRcvChatItem' user chatDir msg ... +``` + +### Problem +When `x.msg.update` arrives for a locally-deleted item in a channel (`m_` is `Nothing`), the fallback creates a new item with `CDChannelRcv gInfo Nothing` but doesn't know the original item's `sendAsGroup` flag. + +### Fix (Option B: Require sender to include flag in the event) +Add `asGroup` field to `XMsgUpdate` message format. + +**Rationale:** We don't know what owner wants otherwise - it may send as channel or it may send as owner, and different members must have the same view (e.g. when multiple relays are used, it would be random). + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1950-1969 +- Protocol message format (XMsgUpdate) + +--- + +## Bug 4: Forward API Parameter + +### Current Code (Commands.hs:930,944) +```haskell +APIForwardChatItems ... _sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope (sendAsGroup' gInfo) False itemTTL cmrs' + -- ^^^^^^^^^^^^^^^^^^^ BUG: ignores _sendAsGroup +``` + +### Problem +The `_sendAsGroup` parameter is received but ignored. The function computes its own `sendAsGroup' gInfo` instead. + +### Fix +```haskell +APIForwardChatItems ... sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Line 930 (rename parameter), Line 944 (use parameter) + +--- + +## Bug 5: CLI Forward Hardcode + +### Current Code (Commands.hs) +```haskell +-- Line 2191 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2196 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2201 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 4633 +"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP <*> pure False), +``` + +### Problem +All CLI forward commands hardcode `False` for `sendAsGroup` instead of computing based on destination. + +### Fix +Compute `sendAsGroup` before calling API based on destination group's channel status: + +```haskell +-- Lines 2191, 2196, 2201: Need to determine sendAsGroup based on toChatRef +-- If toChatRef is a channel and user is owner, sendAsGroup should default to True + +-- Line 4633: Parser should accept optional flag (parser cannot know context) +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Lines 2191, 2196, 2201, 4633 + +--- + +## Test Plan + +### New Tests (8 total) + +Tests 1-4 cover Bug 1 (delivery context flag). Each tests a specific event type where the owner sends as member (sendAsGroup=False). Existing tests already cover the "sends as channel" (sendAsGroup=True) case; these tests verify that the delivery context correctly uses the item's stored sendAsGroup=False flag rather than recomputing from the owner's current role. + +#### Test 1: `testChannelOwnerUpdateAsMember` +**Objective:** Verify x.msg.update uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner updates message +4. Verify update delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 2: `testChannelOwnerDeleteAsMember` +**Objective:** Verify x.msg.del uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner deletes message +4. Verify delete delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 3: `testChannelOwnerFileTransferAsMember` +**Objective:** Verify file delivery (including x.msg.file.descr) uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Verify file delivery uses sendAsGroup=False from the item, not recomputed from owner role + +**Note:** x.msg.file.descr is part of file delivery, not a separate event to test independently. + +**Coverage:** Bug 1 + +--- + +#### Test 4: `testChannelOwnerFileCancelAsMember` +**Objective:** Verify x.file.cancel uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Owner cancels file +4. Verify cancel delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 5: `testChannelReactionAttribution` +**Objective:** Verify reactions require a member sender (not optional). + +**Scenario:** +1. Owner sends channel message +2. Owner adds reaction (as member, not as channel) +3. Verify reaction is attributed to owner's member record +4. Member adds reaction to channel message +5. Verify member reaction is attributed correctly +6. Verify channel cannot send reactions (m_ must be Just) + +**Coverage:** Bug 2 + +--- + +#### Test 6: `testChannelUpdateFallbackSendAsGroup` +**Objective:** Verify update on deleted item creates correct sendAsGroup from protocol field. + +**Scenario:** +1. Owner sends channel message (sendAsGroup=True) +2. Member receives and locally deletes +3. Owner updates message (XMsgUpdate includes asGroup=True) +4. Verify member's recreated item has sendAsGroup=True +5. Owner sends message as member (sendAsGroup=False) +6. Member receives and locally deletes +7. Owner updates message (XMsgUpdate includes asGroup=False) +8. Verify member's recreated item has sendAsGroup=False + +**Coverage:** Bug 3 + +--- + +#### Test 7: `testForwardAPIUsesParameter` +**Objective:** Verify Forward API respects sendAsGroup parameter. + +**Scenario:** +1. Create channel with owner +2. Forward message to channel with sendAsGroup=True +3. Verify message sent as channel +4. Forward message with sendAsGroup=False +5. Verify message sent as member + +**Coverage:** Bug 4 + +--- + +#### Test 8: `testForwardCLISendAsGroup` +**Objective:** Verify CLI forward commands compute sendAsGroup correctly. + +**Scenario:** +1. Create channel with owner +2. Use `/forward` to forward to channel +3. Verify sendAsGroup computed correctly (True for owner in channel) + +**Coverage:** Bug 5 + +--- + +## Implementation Order + +### Phase 1: Critical Fix (Bug 1) +1. Fix delivery context in Subscriber.hs +2. Add Tests 1-4 (`testChannelOwnerUpdateAsMember`, `testChannelOwnerDeleteAsMember`, `testChannelOwnerFileTransferAsMember`, `testChannelOwnerFileCancelAsMember`) + +### Phase 2: API Fixes (Bugs 4, 5) +1. Fix Forward API parameter usage +2. Fix CLI forward hardcodes +3. Add Tests 7 and 8 (`testForwardAPIUsesParameter`, `testForwardCLISendAsGroup`) + +### Phase 3: Behavior Fixes (Bugs 2, 3) +1. Rework XMsgReact handler to require GroupMember (not Maybe GroupMember) +2. Add asGroup field to XMsgUpdate protocol message +3. Add Tests 5 and 6 (`testChannelReactionAttribution`, `testChannelUpdateFallbackSendAsGroup`) + +--- + +## Files Summary + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Library/Subscriber.hs` | Lines 935-945 (Bug 1), 1818-1842 (Bug 2), 1950-1969 (Bug 3) | +| `src/Simplex/Chat/Library/Commands.hs` | Lines 930,944 (Bug 4), 2191,2196,2201,4633 (Bug 5) | +| Protocol message types | Add asGroup field to XMsgUpdate (Bug 3) | +| `tests/ChatTests/Groups.hs` | Add 8 new tests | diff --git a/plans/chat-relays-mvp-launch-plan.md b/plans/chat-relays-mvp-launch-plan.md new file mode 100644 index 0000000000..64af3c7d42 --- /dev/null +++ b/plans/chat-relays-mvp-launch-plan.md @@ -0,0 +1,293 @@ +# Chat Relays MVP — Launch Plan + +## Contents +- [Executive Summary](#executive-summary) +- [What's Done](#whats-done) +- [What's Remaining](#whats-remaining): Protocol & Crypto | Relay Protocol | Member Connection | UI | Testing | Polish | Directory +- [Dependency Summary](#dependency-summary) +- [Risk Register](#risk-register) +- [Decisions Made](#decisions-made) +- [Post-MVP Backlog](#post-mvp-backlog) + +--- + +## Executive Summary + +Chat Relays enable large public channels where messages flow owner → relay → members, replacing N-to-N connections. This plan covers what remains for MVP launch. + +**Current state**: Core backend ~75% done (delivery system, forwarding, deduplication, relay invitation/acceptance, group creation with relays all working). UI ~15%. Key remaining work: member key signatures, relay identity validation, forward envelope protocol, UI on both platforms. + +**MVP delivers**: Owners create channels with preset relays. Relays validate and serve groups. Members join via links, receive relay-forwarded messages signed by owners. UI differentiates channels from groups. + +**Out of scope**: Relay removal/recovery, periodic relay health monitoring, relay-to-relay sync, history navigation, e2e encryption in support chats, multi-owner support, reaction/comment batching. See [Post-MVP](#post-mvp-backlog). + +--- + +## What's Done + +- Single-roundtrip group creation with relays (`APINewPublicGroup` → `prepareConnectionLink` → `createConnectionForLink` — Agent API complete) +- Relay invitation/acceptance protocol (`XGrpRelayInv`, `XGrpRelayAcpt`) and relay request worker +- Async delivery task/job system with cursor-paginated member delivery +- `FwdChannel` / `FwdMember` forwarding modes, `ShowGroupAsSender` through full pipeline +- Message deduplication on member side +- Binary batch encoding (`=` prefix) in `Messages/Batch.hs` and `Protocol.hs` +- DB schema: `chat_relays`, `group_relays`, `group_members.relay_link`, key columns on `groups`/`group_members` +- Preset relay configuration framework (3 placeholder relays in `Presets.hs`) +- `CIChannelRcv` chat item direction in backend +- Observer role UI already works on both platforms (compose bar hidden, reactions only) + +## What's Remaining + +Organized by architecture layer, not work streams. Items within each section are roughly ordered by dependency. + +--- + +### 1. Protocol & Cryptography + +#### 1.1 Binary Forward Envelope (`F` prefix) +New top-level binary format replacing `XGrpMsgForward` for relay groups. Wraps original sender bytes verbatim — preserves signatures through relay forwarding without re-encoding. + +Format: `F` (see member-keys-plan.md §8). + +Old groups keep `XGrpMsgForward` (JSON). New relay groups use `F` envelope. Parser accepts both. + +**Files**: `Protocol.hs` (parse/encode), `Batch.hs` (batching), `Subscriber.hs` (forwarding handler replacement) + +#### 1.2 Key Generation & Storage +Generate Ed25519 key pairs on group creation/join. Populate existing DB columns: `root_priv_key`/`root_pub_key` on `groups`, `member_priv_key` on `groups`, `member_pub_key` on `group_members`. + +Consider adding to current `M20260222_chat_relays` migration (unreleased) rather than creating a new one. + +**Files**: `Store/Groups.hs`, `Store/Profiles.hs`, `Commands.hs` (creation flow) + +#### 1.3 Message Signing +Sign roster-modifying messages (`XGrpRelayInv`, `XGrpMemNew`, `XGrpMemRole`, `XGrpMemDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpDel`) with owner's member key. + +**Files**: `Internal.hs` (signChatMessage), `Commands.hs` (sendGroupMessage integration) + +#### 1.4 Signature Verification +Verify signatures on received roster messages. Hard fail for missing/invalid signatures in new-version groups. + +**Files**: `Internal.hs` (verifyChatMessage), `Subscriber.hs` (reception) + +#### 1.5 OwnerAuth Chain +Owner authorization signed by root key, stored in group link's `UserContactData.owners`. Members verify owner identity via chain. Type exists; integration TODO. + +**Files**: `Protocol.hs`, `Commands.hs`, `Subscriber.hs` + +#### 1.6 Version Gating +Chat relays is a new feature — relay groups only joinable by clients of the new version. Add `chatRelaysVersion` to version range. No backward compat needed for relay groups themselves (they don't exist in older versions). + +**Files**: `Types.hs` (version constant), `Commands.hs` (gating) + +--- + +### 2. Relay Protocol + +#### 2.1 Relay Address Link Data +On relay address creation, set link data: relay identity (profile, certificate, relay identity key). Members validate this when connecting. + +**Files**: `Commands.hs` (relay address creation), `Protocol.hs` (relay link data structure) + +#### 2.2 Group Profile Validation by Relay +Before accepting to serve group, relay validates group profile, verifies owner's signature, and checks `shared_group_id` in immutable link data (prevents redirect to wrong group). + +**Files**: `Subscriber.hs` (`runRelayRequestWorker` — stub exists, validation logic TODO) + +#### 2.3 Relay Link Data on Acceptance +When accepting, relay sets: relay identity, relay key for group, group ID in immutable part of relay link data. + +**Files**: `Subscriber.hs` (relay link creation) + +#### 2.4 Relay Key/Identity Validation by Members +When member connects to relay, validate relay link data (identity, key, group ID) matches group link data. This is part of the same signature/identity verification work as §1.4. + +**Files**: `Commands.hs` (`connectToRelay`), `Subscriber.hs` + +#### 2.5 Test Chat Relay Command +`APITestChatRelay` / `TestChatRelay` — channel owners need to verify relay connectivity before creating channels. + +**Files**: `Commands.hs` (new command) + +#### 2.6 Real Relay Addresses in Presets +Replace placeholder URLs in `simplexChatRelays`. Depends on relay server deployment. + +**Files**: `Operators/Presets.hs` + +#### 2.7 Channel-Only Behavior Enforcement +In channel groups (`useRelays = True`), the API supports sending both as channel (`asGroup=True`) and as member. For MVP, UI always passes `asGroup=True`. Backend does not enforce — owners retain the API option to send as member for future use. Non-owner/non-admin members can only send reactions (observer role enforced by existing role system). + +**Files**: UI-only enforcement for MVP (both platforms pass `asGroup=True` in compose) + +--- + +### 3. Member Connection Flow + +#### 3.1 Support `/c` API for Relay Groups +Automate `APIPrepareGroup` → `APIConnectPreparedGroup` flow when using `/c` command with a relay group link. Currently requires manual two-step call. + +**Files**: `Commands.hs` (`connectWithPlan`) + +#### 3.2 Relay Connection State Response Type +New response type/events showing per-relay connection state (connecting, connected, temporary error, permanent error). Needed for both member join and owner creation UX. + +**Files**: `Controller.hs` (new ChatResponse variants), `Commands.hs` (emit events) + +#### 3.3 Member Count for Channels +Existing member count display uses loaded member list — won't work for channels, where members only have records for owners and relays. Relays must communicate real member counts (excluding relays themselves) to members and owners. Needs protocol extension for relay → member count communication. + +**Files**: `Protocol.hs` (new event or extension), `Subscriber.hs` (relay reporting), UI (display) + +--- + +### 4. UI — Both Platforms (iOS + Android/Desktop) + +All UI items must be completed on both platforms for MVP. + +#### 4.1 Channel Visual Distinction +Different icon/badge for channels in chat list. "Channel" label. Key off `useRelays` flag in `GroupInfo`. + +No backend dependency — can start immediately. + +#### 4.2 "Message from Channel" Display +`CIChannelRcv` direction NOT yet handled in either platform's UI. Must add to message rendering pipeline. `showGroupAsSender` message rendering. + +Backend complete. No backend dependency. + +#### 4.3 Channel Creation Flow +"Create Channel" button in new chat menu → name/description → relay selection → creation with relay status feedback (invited → accepted → active). Backend `APINewPublicGroup` exists. + +Depends on: §3.2 (relay connection state type) + +#### 4.4 Relay Management (User Settings) +List of configured relays; add/remove/edit; test connectivity. Follow existing SMP server management pattern. + +Depends on: §2.5 (`APITestChatRelay`) + +#### 4.5 Show Relays in Channel Info +Relay list with status and identity in channel info screen. + +#### 4.6 Relay Connection State During Join +Progress feedback when joining: "Connecting to relays..." → per-relay status → "Connected". + +Depends on: §3.2 (relay connection state type) + +#### 4.7 Owner Posting UI +Compose mode always sends as channel (`asGroup=True`). No toggle for MVP. + +#### 4.8 API Type Updates +- **iOS**: Add `apiNewPublicGroup` to `ChatCommand` enum; add `ChatRelay`, `RelayStatus`, `GroupRelay`, `CIChannelRcv` types +- **Android**: Add corresponding types to Kotlin model layer +- Both: relay connection state event types + +--- + +### 5. Testing + +- Delivery loop restored after restart +- Delivery in support scopes inside channels +- Connect plans for relay groups +- Cancellation on failure to create relay group +- Async retry connecting to relay (members) +- Relay privileges +- Binary forward envelope encode/decode round-trips +- Message signing and verification flow +- Relay signature validation in invitation flow +- Backward compat: old clients cannot join relay groups (version gated) + +--- + +### 6. Polish & Edge Cases + +- Create missing service chat items ("relays updated" for owner, "group invite accepted" for relay) +- Disable link data output in CLI (`View.hs` — currently enabled for manual testing, cleanup) +- When deleting chat relay from user config, check `group_relays` references and mark as deleted instead +- Single file description for all recipients (performance) + +--- + +### 7. Directory Service Verification + +Directory service currently has no channel/relay awareness — it only lists regular groups. Needs verification how channels should appear in directory and what integration work is required. Some adaptation may be needed. + +--- + +## Dependency Summary + +``` +Can start immediately (no dependencies): + §1.2 Key Storage, §1.3-1.5 Signing/Verification, §1.6 Version Gating + §2.1 Relay Address Data, §2.7 Channel Enforcement (UI-only) + §4.1 Channel Visual Distinction, §4.2 "Message from Channel" Display + +Needs §1.3-1.5 (signing): + §2.2 Group Profile Validation, §2.3 Relay Link Data + +Needs §2.1+2.3: + §2.4 Relay Key Validation by Members + +Needs §3.2 (relay state type): + §4.3 Channel Creation UI, §4.6 Join State UI + +Needs §2.5 (test command): + §4.4 Relay Management UI + +Late phase: + §5 Testing (needs most backend complete) + §2.6 Real Relay Addresses (needs server deployment) + §7 Directory Verification +``` + +**Critical path**: §1.1 (Forward Envelope) + §1.2-1.5 (Keys/Signing) → §2.2-2.3 (Relay Validation) → §5 (Testing) → Launch + +**Early UI wins**: §4.1, §4.2 can start in Phase 1. + +--- + +## Risk Register + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Forward envelope (`F`) version mismatch relay↔member | High | Version gating — relay groups require new version on all participants | +| Relay server instability under load | High | Load test early; multi-relay redundancy | +| UI on 2 platforms takes longer than expected | Medium | Both required for MVP; start UI early (§4.1, §4.2 have no backend deps) | +| Member count protocol extension complexity | Medium | Can ship without count initially; add in fast-follow | +| Stale relay "Active" status (no health monitoring) | Low | Multi-relay redundancy; manual `APITestChatRelay`; monitoring post-MVP | + +--- + +## Decisions Made + +- **Single-owner channels**: Allowed without warning (sender identity is clear for "messages from channel"). Single-owner is the main MVP case; "from channel" UX is valuable regardless. Revisit with multi-owner support. +- **Channel-only enforcement**: UI-only for MVP (`asGroup=True` always passed). Backend retains API flexibility for future "send as member" option. +- **Default member role**: Observer by default for channels. No additional owner→relay communication of role/rejection rules for MVP. +- **Contact connection refactoring**: Deferred to post-MVP. Current flow works. +- **Member rejection by relay**: Deferred. MemberId clash unlikely; rejection rules postponed. +- **Relay profiles**: Consider for MVP vs post-MVP. Members and owners see relay profiles in group already; linking to single per-config profile is nice-to-have. +- **Chat relay user filtering**: Post-MVP. Relay user will be visible in client for now. + +--- + +## Post-MVP Backlog + +1. Relay removal and group recovery — owner removes relay, members reconnect via updated link +2. Periodic relay health checks — relay verifies link presence in group link data +3. Relay-to-relay synchronization +4. Managing relays in existing group — add/remove relays post-creation +5. Default member role and rejection rules communication owner→relay +6. Member rejection by relay (duplicate member ID, rule violations) +7. Contact connection flow refactoring (`connectViaContact` simplification) +8. Deduplication highlighting — show differences between relay-forwarded messages +9. History navigation — request older messages from channel +10. E2E encryption in admin/support chats +11. Reaction/comment count batching +12. Priority connections — separate queues for messages vs admin requests +13. Member profile delivery optimization +14. Private relays with password +15. Channel content moderation +16. Indefinite file storage for relays +17. Message revocation from history +18. Channel discovery/directory integration (verify and extend) +19. Advanced forwarding envelope — include channel link in forwarded message metadata for distribution +20. Relay profiles linked to single per-config record +21. Chat relay user filtering/separate UI diff --git a/plans/deduplication-channel-messages.md b/plans/deduplication-channel-messages.md new file mode 100644 index 0000000000..0d09d00528 --- /dev/null +++ b/plans/deduplication-channel-messages.md @@ -0,0 +1,256 @@ +# Deduplication Plan: Channel Message Functions + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Findings by File](#findings-by-file) +3. [Architectural Note: CIChannelRcv Constructor](#architectural-note) +4. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +The PR introduces channel message support by creating parallel channel-specific functions that duplicate 60-80% of existing group functions. The core pattern: channel messages are group messages without a member sender. Most channel functions are the group function with `Just member` → `Nothing`, `CIGroupRcv m` → `CIChannelRcv`, and moderation/blocking guards removed. + +**High-value deduplication targets** (ordered by impact): + +| # | Candidate | Feasibility | Shared code | +|---|-----------|-------------|-------------| +| 1 | `channelMessageUpdate_` → merge into `groupMessageUpdate` | HIGH | ~36 lines | +| 2 | `fwdChannelReaction` → extract shared helper with `groupMsgReaction` | MEDIUM | ~15 lines inner function | +| 3 | `newChannelContentMessage_` → parameterize `newGroupContentMessage` | MEDIUM | ~12 lines happy path | +| 4 | `processForwardedChannelMsg` → merge into `processForwardedMsg` | MEDIUM | depends on 1-3 | +| 5 | `getGroupCIBySharedMsgId'` → parameterize `getGroupChatItemBySharedMsgId` | HIGH | eliminates function | +| 6 | `channelMessageDelete` → parameterize `groupMessageDelete` | LOW | ~5 lines; group has 60+ lines moderation | +| 7 | `saveRcvChatItem'` CDChannelRcv branches | HIGH | ~14 lines across 3 spots | +| 8 | `processContentItem` CIChannelRcv branch | HIGH | ~3 lines | +| 9 | View.hs/Store/Internal pattern match branches | DEFERRED | ~24 branches; requires constructor change | + +--- + +## Findings by File + +### Subscriber.hs + +**D1: `channelMessageUpdate_` vs `groupMessageUpdate`** + +The `updateRcvChatItem` inner function is nearly line-for-line identical between both (~36 shared lines). Differences: +- Lookup: `getGroupChatItemBySharedMsgId` (by member) vs `getGroupCIBySharedMsgId'` (no member) — parameterizable by `Maybe GroupMemberId` (see D5) +- Pattern match: `CIGroupRcv m'` with `sameMemberId` check vs `CIChannelRcv` — branch on `Maybe GroupMember` +- `getGroupCIReactions`: `Just memberId` vs `Nothing` — already parameterized +- Chat direction in fallback: `CDGroupRcv` vs `CDChannelRcv` — branch on `Maybe GroupMember` +- `channelMessageUpdate_` has explicit `forwarded` param; `groupMessageUpdate` always uses `rcvGroupCITimed gInfo ttl_` — the merged function needs to accept `forwarded :: Bool` (or always `False` from the non-forwarded path) +- `groupMessageUpdate` has `prohibitedSimplexLinks` and `blockedMemberCI` guards — skip when member is `Nothing` +- Mentions handling: `groupMessageUpdate` has `mentions' = if memberBlocked m then [] else mentions`; `channelMessageUpdate_` passes `mentions` directly — when member is `Nothing`, use `mentions` directly (no blocking check needed) + +**Solution:** Extend `groupMessageUpdate` to take `Maybe GroupMember`. When `Nothing`: skip prohibited links check, skip blocked member CI, use `CDChannelRcv`, use `getGroupChatItemBySharedMsgId` with `Nothing`, pass mentions directly. Delete `channelMessageUpdate_`. + +--- + +**D2: `fwdChannelReaction` vs `groupMsgReaction`** + +These functions share the `updateChatItemReaction` inner function shape (~15 lines), but are **structurally different** in their outer logic: + +- **Parameter types**: `groupMsgReaction` takes a concrete `GroupMember` + `Maybe MemberId` (item member) + `Maybe MsgScope`; `fwdChannelReaction` takes `Maybe GroupMember` (reactor) and always passes `Nothing` as item member +- **Return type**: `groupMsgReaction` returns `CM (Maybe DeliveryJobScope)` — used by the main dispatch for delivery job routing; `fwdChannelReaction` returns `CM ()` — forwarded context doesn't need delivery jobs +- **CIReaction constructor**: `groupMsgReaction` always uses `CIGroupRcv m`; `fwdChannelReaction` uses `maybe CIChannelRcv CIGroupRcv reactor_` — semantically different when reactor is `Nothing` +- **catchCINotFound fallback**: `groupMsgReaction` has scope-aware delivery job logic; `fwdChannelReaction` does bare `setGroupReaction` +- **Reactor**: `groupMsgReaction` uses `m` directly; `fwdChannelReaction` computes `fromMaybe membership reactor_` + +`fwdChannelReaction` is NOT a rename of `groupMsgReaction`. Calling `void $ groupMsgReaction` from forwarded contexts would be **semantically wrong**: it would attribute channel reactions to the membership member via `CIGroupRcv` instead of showing them as `CIChannelRcv`, and would trigger unnecessary delivery job scope logic. + +**Solution:** Extract the shared `updateChatItemReaction` body (~15 lines) into a helper parameterized by the `CIReaction` constructor and reactor member. Both `groupMsgReaction` and `fwdChannelReaction` call this helper with their respective parameters. This preserves the distinct outer logic while eliminating the inner body duplication. + +--- + +**D3: `newChannelContentMessage_` vs `newGroupContentMessage`** + +The channel version is the "happy path" of the group version with all member-specific guards removed: +- No `blockedByAdmin` check +- No `prohibitedGroupContent` check +- No `getCIModeration` / moderation logic (~40 lines) +- No scope resolution (`mkGetMessageChatScope`) +- No `blockedMemberCI` +- No member-conditional mentions filtering / autoAcceptFile guard + +The shared "save-view-react-accept" core is ~12 lines. + +**Solution:** Extract a shared `saveGroupContentItem` helper containing: process file invitation, save chat item, get reactions, view, auto-accept, return scope. `newGroupContentMessage` calls it after its checks; `newChannelContentMessage_` calls it directly. This keeps `newGroupContentMessage`'s complex flow intact while eliminating the body duplication. + +Alternatively: extend `newGroupContentMessage` to take `Maybe GroupMember`. When `Nothing`: skip all member-specific guards and use `CDChannelRcv`. This is cleaner but changes the function's signature and control flow significantly. + +--- + +**D4: `processForwardedChannelMsg` vs `processForwardedMsg`** + +These are dispatch tables with identical structure. Each event arm calls the group or channel variant: + +``` +processForwardedMsg author: processForwardedChannelMsg: + XMsgNew → newGroupContentMessage XMsgNew → newChannelContentMessage_ + XMsgFileDescr → groupMessageFileDescription XMsgFileDescr → channelMessageFileDescription + XMsgUpdate → groupMessageUpdate XMsgUpdate → channelMessageUpdate_ + ... ... +``` + +If the underlying functions (D1-D3) are parameterized by `Maybe GroupMember`, this dispatch unifies automatically. The extra group-management events (`XInfo`, `XGrpMemNew`, etc.) are guarded by `Just author`. + +**Subtlety: `XMsgReact` handling.** The `XMsgReact` arm has a three-way split: +- `processForwardedMsg` with `Just memId` → `groupMsgReaction` (member reaction with scope/delivery-job logic) +- `processForwardedMsg` with `Nothing` memId → `fwdChannelReaction gInfo (Just author)` (channel reaction from known author) +- `processForwardedChannelMsg` → `fwdChannelReaction gInfo Nothing` (channel reaction, no author) + +This three-way split needs careful handling in the merged function, since `fwdChannelReaction` differs structurally from `groupMsgReaction` (see D2). + +**Solution:** After D1-D3, merge into `processForwardedMsg` taking `Maybe GroupMember`. When `Nothing`, skip group-management events. The `XMsgReact` arm passes the author to `fwdChannelReaction` when in channel mode. Delete `processForwardedChannelMsg`. + +--- + +**D5: `channelMessageDelete` vs `groupMessageDelete`** + +`groupMessageDelete` has ~60 lines of moderation logic (moderate, checkRole, archiveMessageReports, CIModeration creation) that `channelMessageDelete` does not need. The shared portion is only ~5-7 lines (delete/mark-deleted + view). Additionally, the lookup functions differ: `channelMessageDelete` uses `getGroupCIBySharedMsgId'` (no member); `groupMessageDelete` uses `getGroupMemberCIBySharedMsgId` (JOINs group_members by MemberId). The delete condition also differs: `groupFeatureAllowed` vs `groupFeatureMemberAllowed`. + +**Solution:** LOW priority. The functions are architecturally different enough that forced unification would harm readability. If desired, extend `groupMessageDelete` with a `Maybe GroupMember` parameter where `Nothing` takes the simple "channel delete" path early. But the code clarity cost may exceed the deduplication benefit. + +--- + +### Store/Messages.hs + +**D6: `getGroupCIBySharedMsgId'` vs `getGroupChatItemBySharedMsgId`** + +`getGroupChatItemBySharedMsgId` filters by `group_member_id = ?`. +`getGroupCIBySharedMsgId'` omits the `group_member_id` filter entirely (matches any row regardless of member). + +Channel items store `group_member_id = NULL`. Parameterizing with `Maybe GroupMemberId` and `IS NOT DISTINCT FROM` would: +- `Just gmId` → only that member (existing behavior) +- `Nothing` → only NULL rows (channel items) + +This is **stricter** than `getGroupCIBySharedMsgId'`'s current behavior (which matches any member's items too), but this is actually a correctness improvement — all four callers (Subscriber.hs lines 1846, 1962, 1988, 3233) are channel-specific contexts where items have `group_member_id = NULL`. + +**Solution:** Change `getGroupChatItemBySharedMsgId` to take `Maybe GroupMemberId`. SQL becomes: +```sql +WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? +``` +Delete `getGroupCIBySharedMsgId'`. Update all callers to pass `Just gmId` or `Nothing`. + +**Note:** `getGroupMemberCIBySharedMsgId` is a different function (takes `MemberId`, JOINs `group_members` to resolve). It is NOT a duplicate and should be kept. + +**Additional Store/Messages.hs duplications** (minor, collapse with constructor change): +- `createNewRcvChatItem` quoteRow (lines 560-563): `CDGroupRcv` and `CDChannelRcv` branches are verbatim identical +- `getChatItemQuote_` (lines 649-654): `CDChannelRcv` branch is a subset of `CDGroupRcv` (missing sender-specific case) +- `createNewChatItem_` idsRow/groupScope: `CDChannelRcv` branches repeat `CDGroupSnd`-like tuples + +These are inherent to the separate constructor and collapse automatically with the architectural change (see note below). Not worth addressing independently. + +--- + +### Library/Internal.hs + +**D7: `saveRcvChatItem'` CDChannelRcv branches** + +Three duplicate spots within this function, all verbatim copies of CDGroupRcv branches: + +1. **Mentions/userMention computation** (~7 lines): `getRcvCIMentions`, `userReply` via `cmToQuotedMsg`, `userMention'` via membership check. Verbatim identical between CDGroupRcv and CDChannelRcv. + +2. **createGroupCIMentions** (~2 lines): Both branches call `createGroupCIMentions db g ci mentions'` guarded by `not (null mentions')`. Identical. + +3. **memberChatStats / memberAttentionChange** (~3 lines): Only difference is `Just m` vs `Nothing` passed to `memberAttentionChange`. + +Total: ~14 lines of duplication across 3 spots. + +**Solution:** Extract `GroupInfo` and `Maybe GroupMember` from either constructor at the top: +```haskell +case cd of + CDGroupRcv g _s m -> (g, Just m) + CDChannelRcv g _s -> (g, Nothing) +``` +Then use the extracted values for all three spots. The `memberAttentionChange` call already takes `Maybe GroupMember`. + +--- + +**D8: `processContentItem` CIChannelRcv branch** + +Near-duplicate of `CIGroupRcv` branch (lines 1196-1199 vs 1200-1202). Only difference: no `blockedByAdmin` guard, passes `Nothing` instead of `Just sender`. + +**Solution:** Merge the two branches: +```haskell +(CChatItem SMDRcv ci@ChatItem {chatDir, content = CIRcvMsgContent mc, file}) + | maybe True (not . blockedByAdmin) sender_ -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender_ ci mc fInvDescr_ + where sender_ = case chatDir of CIGroupRcv m -> Just m; CIChannelRcv -> Nothing; _ -> Nothing +``` + +**Additional Internal.hs duplication** (minor): +- `quoteData` (lines 228-229): `CIGroupRcv m` returns `(qmc, CIQGroupRcv $ Just m, False, Just m)`, `CIChannelRcv` returns `(qmc, CIQGroupRcv Nothing, False, Nothing)`. Two one-liners differing only in `Just m` vs `Nothing`. Trivial but noted. + +--- + +### View.hs + +**D9: View.hs pattern match duplication** + +The actual count of `CIChannelRcv` pattern match branches: +- **View.hs**: 6 branches (chatDirNtf, viewChatItem new, viewChatItem updated, reaction display, sentByMember', fileFrom) +- **Terminal/Output.hs**: 1 branch +- **Commands.hs**: 2 branches (itemDeletable, itemsMsgMemIds) +- **Internal.hs**: 2 branches (quoteData, processContentItem) +- **Subscriber.hs**: ~6 branches (scattered) +- **Store/Messages.hs**: ~4 branches (toGroupChatItem, createNewRcvChatItem, createNewChatItem_, getChatItemQuote_) + +Total: **~24 pattern match sites** across all files (~17 `CIChannelRcv` + ~7 `CDChannelRcv`). Each mirrors the corresponding `CIGroupRcv m` / `CDGroupRcv` branch passing `Nothing` instead of `Just m`. + +The `ttyFromGroup*` family of functions in View.hs was correctly generalized to take `Maybe GroupMember` — the duplication is at the call sites, not in the helper functions. + +**Solution:** This duplication is **inherent to the separate constructor choice** and can only be eliminated by the architectural change (merging `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)`). Without that change, the branches must remain. Extracting local helpers at each call site would add complexity without reducing total code. + +--- + +### Other Files (no significant deduplication needed) + +- **Commands.hs:** Parameter threading (`ShowGroupAsSender`, `SRGroup`). Clean, no duplication. +- **Protocol.hs:** Wire protocol changes (`ExtMsgContent.asGroup`, `XGrpMsgForward Maybe MemberId`). Necessary. +- **Delivery.hs:** `FwdSender` type replaces separate fields. Could be `Maybe (MemberId, ContactName)` but not a priority. +- **Store/Files.hs:** `createRcvGroupFileTransfer` takes `Maybe GroupMember`. Clean parameterization. +- **Store/Groups.hs:** `createPreparedGroup` returns `Maybe GroupMember`. Necessary for relay groups. +- **Types.hs:** `sendAsGroup'`, `groupId'` utilities. Minor. + +--- + +## Architectural Note: CIChannelRcv Constructor {#architectural-note} + +The deepest source of duplication is the choice to add `CIChannelRcv` / `CDChannelRcv` as separate constructors rather than parameterizing `CIGroupRcv :: Maybe GroupMember -> CIDirection 'CTGroup 'MDRcv` and `CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> ChatDirection 'CTGroup 'MDRcv`. + +This creates ~24 pattern match branches across the codebase, almost all passing `Nothing` where `CIGroupRcv` passes `Just m`. The `chatItemMember` function already returns `Maybe GroupMember`, confirming the abstraction is correct. + +**However**, changing these constructors is a large cross-cutting refactor affecting Messages.hs, View.hs, Commands.hs, Internal.hs, Subscriber.hs, Store/Messages.hs, and tests. It may be better suited as a follow-up PR. + +**Decision needed from user:** Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` in this PR, or defer? + +--- + +## Implementation Order + +### Phase 1: Store layer (D6) +1. Parameterize `getGroupChatItemBySharedMsgId` with `Maybe GroupMemberId` + `IS NOT DISTINCT FROM` +2. Delete `getGroupCIBySharedMsgId'` +3. Update all callers (pass `Just gmId` or `Nothing`) + +### Phase 2: Subscriber.hs function merges (D1, D2, D3) +4. Merge `channelMessageUpdate_` into `groupMessageUpdate` (takes `Maybe GroupMember`) +5. Extract shared `updateChatItemReaction` helper from `groupMsgReaction` and `fwdChannelReaction` +6. Merge `newChannelContentMessage_` into `newGroupContentMessage` (extract shared save-view helper or take `Maybe GroupMember`) + +### Phase 3: Dispatch unification (D4) +7. Merge `processForwardedChannelMsg` into `processForwardedMsg` (takes `Maybe GroupMember`; handle `XMsgReact` three-way split) + +### Phase 4: Internal cleanup (D7, D8) +8. Deduplicate `saveRcvChatItem'` CDChannelRcv branches (3 spots) +9. Merge `processContentItem` CIChannelRcv branch + +### Phase 5 (deferred unless approved): Constructor change (D9) +10. Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` — eliminates ~24 pattern match branches across all files + +### Phase 6 (optional): channelMessageDelete (D5) +11. Only if user wants it — extend `groupMessageDelete` with `Maybe GroupMember` diff --git a/plans/delivery-context-fix.md b/plans/delivery-context-fix.md new file mode 100644 index 0000000000..4b1b13c30a --- /dev/null +++ b/plans/delivery-context-fix.md @@ -0,0 +1,354 @@ +# Plan: Fix Channel Message Delivery Architecture + +## Table of Contents +1. [Context](#context) +2. [Executive Summary](#executive-summary) +3. [Issue 1: Eliminate memberForChannel/memberIdForChannel](#issue-1) +4. [Issue 2: groupMsgReaction required GroupMember](#issue-2) +5. [Issue 3: Fix groupMessageUpdate lookup](#issue-3) +6. [Issue 4: DeliveryTaskContext type](#issue-4) +7. [Issue 5: Fix testChannelReactionAttribution](#issue-5) +8. [Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment](#issue-6) +9. [Other: sendAsGroup parameter ordering](#other-issue) +10. [Verification](#verification) + +## Context + +The current implementation on `ep/channel-messages-2` determines delivery context (whether to forward messages as channel or as member) using `isChannelOwner` — inferring from the sender's role whether they're the channel owner. This is architecturally wrong: the delivery context should be determined **from the item's direction** (`CIChannelRcv` vs `CIGroupRcv`), not from who sent it. The `f/msg-from-channel` branch has the correct approach. + +## Executive Summary + +7 changes across 7 files: +1. **Delivery.hs** — Add `DeliveryTaskContext` type, update `NewMessageDeliveryTask` only (`MessageDeliveryTask` unchanged) +2. **Subscriber.hs** — Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; all processing functions return `Maybe DeliveryTaskContext`; determine `sentAsGroup` from item direction; `groupMsgReaction` takes required `GroupMember`; add `withAuthor` in forwarded handler +3. **Store/Delivery.hs** — Update SQL row mapping for `taskContext` +4. **Commands.hs** — Reorder `sendAsGroup` param in `APIForwardChatItems` +5. **Store/Messages.hs** — Reorder `showGroupAsSender` param in `createNewSndChatItem` +6. **Internal.hs** — Reorder `showGroupAsSender` param in `saveSndChatItems`, `prepareGroupMsg` +7. **Tests** — Fix reaction test comment/expectations, fix update fallback test comment + +--- + +## Issue 1: Eliminate memberForChannel/memberIdForChannel {#issue-1} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 935-937, 939-991 + +**Problem:** `isChannelOwner`, `memberForChannel`, `memberIdForChannel` computed at lines 935-937 and passed to processing functions. This pre-infers delivery context from member role. + +**Fix:** Remove these three bindings entirely. Always pass `(Just m'')` to functions that take `Maybe GroupMember`. Functions determine `sentAsGroup` from item direction internally. + +**Direct handler changes (lines 939-991):** +``` +-- BEFORE: +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + memberForChannel = if isChannelOwner then Nothing else Just m'' + memberIdForChannel = memberId' <$> memberForChannel +(deliveryJobScope_, showGroupAsSender') <- case event of + ... +forM deliveryJobScope_ $ \jobScope -> + pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, showGroupAsSender = showGroupAsSender'} + +-- AFTER: +deliveryTaskContext_ <- case event of + XMsgNew mc -> ... -- returns Maybe DeliveryTaskContext + XMsgFileDescr ... -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr + XMsgUpdate ... -> memberCanSend m'' msgScope Nothing $ groupMessageUpdate gInfo' (Just m'') sharedMsgId ... + XMsgDel ... -> groupMessageDelete gInfo' (Just m'') sharedMsgId ... + XMsgReact ... -> groupMsgReaction gInfo' m'' sharedMsgId ... -- required member + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId + ...other events -> Just <$> memberEventDeliveryContext m'' / Nothing +forM deliveryTaskContext_ $ \taskContext -> + pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} +``` + +**Processing function signature changes:** +- `groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params, pass `Maybe GroupMember`, determine `sentAsGroup` from `chatDir` of found item +- `groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> Maybe Bool -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param +- `groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param; fix `findOwnerCI` dual-lookup (lines 2028-2035) same as Issue 3: when `m_ = Nothing` search with `Nothing`, when `m_ = Just m` use member lookup directly +- `xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params + +**`validSender` simplification:** Remove second `Maybe MemberId` parameter. With `(Just m'')` always passed, validation is just: +```haskell +validSender :: Maybe MemberId -> CIDirection 'CTGroup 'MDRcv -> Bool +validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m +validSender Nothing CIChannelRcv = True +validSender _ _ = False +``` + +**`isChannelDir` helper** remains as-is (line 1870-1872) — used to derive `sentAsGroup` from item's `chatDir`. + +**`memberCanSend`** (line 1436): Generic signature `a -> CM a -> CM a` — no change needed. Default values at call sites change from `(Nothing, False)` to `Nothing`. + +**`memberCanSend'`** (line 1448): Return type changes from `CM (Maybe DeliveryJobScope)` to `CM (Maybe DeliveryTaskContext)`. Used in forwarded handler (lines 3153, 3159). + +--- + +## Issue 2: groupMsgReaction required GroupMember {#issue-2} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` line 1814 + +**Problem:** `groupMsgReaction :: GroupInfo -> Maybe GroupMember -> ...` allows `Nothing`, uses `fromMaybe membership m_` fallback. + +**Fix:** Change to required `GroupMember`: +```haskell +groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) +``` + +- No `reactor` binding needed — use `m` directly (eliminates `fromMaybe membership m_` fallback) +- `ciDir = CIGroupRcv (Just m)` (reactions always attributed to member) +- Always return `sentAsGroup = False` — reactions are never from channel +- Return type: `Maybe DeliveryTaskContext` (not tuple) + +**Direct handler call site (line 958-960):** +```haskell +XMsgReact sharedMsgId memberId scope_ reaction add -> + groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs +``` + +**Forwarded handler call site (line 3162-3163):** +```haskell +XMsgReact sharedMsgId memId_ scope_ reaction add -> + withAuthor XMsgReact_ $ \author -> groupMsgReaction gInfo author sharedMsgId memId_ scope_ reaction add rcvMsg msgTs +``` + +--- + +## Issue 3: Fix groupMessageUpdate lookup {#issue-3} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 1973-1994 + +**Problem:** Dual-lookup with `catchError` tries `Nothing` first, then falls back to `senderGMId_`. This is wrong — the `asGroup_` flag from XMsgUpdate should drive the search. + +**Fix:** Use `asGroup_` (the wire flag) to determine search strategy. No `senderGMId_` parameter needed: +```haskell +updateRcvChatItem = do + (cci, scopeInfo) <- withStore $ \db -> do + cci <- case m_ of + Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId + (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) +``` + +When `m_ = Nothing` (channel owner as channel), search with `Nothing` group_member_id → finds channel items. +When `m_ = Just m` (attributed member message), search with member's `memberId` → finds member items. + +The `isSender` check also simplifies — just check `m_` matches the found item's member. + +**Fallback path** (lines 1948-1968, `catchCINotFound`): When item not found, `showGroupAsSender` is derived from `asGroup_` flag (or defaults based on `m_`), which maps to `sentAsGroup` in the `DeliveryTaskContext`. + +--- + +## Issue 4: DeliveryTaskContext type {#issue-4} + +**File:** `src/Simplex/Chat/Delivery.hs` + +### 4a. Add DeliveryTaskContext type +```haskell +data DeliveryTaskContext = DeliveryTaskContext + { jobScope :: DeliveryJobScope, + sentAsGroup :: ShowGroupAsSender + } + deriving (Show) +``` + +Uses existing `type ShowGroupAsSender = Bool` from Messages.hs. + +### 4b. Modify existing helpers +Rename `infoToDeliveryScope` → `infoToDeliveryContext`, inline the scope logic, add `ShowGroupAsSender` parameter: +```haskell +infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext +infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup} + where + jobScope = case scopeInfo of + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} +``` +Remove `infoToDeliveryScope` entirely. + +Rename `memberEventDeliveryScope` → `memberEventDeliveryContext`, change return type: +```haskell +memberEventDeliveryContext :: GroupMember -> Maybe DeliveryTaskContext +memberEventDeliveryContext m@GroupMember {memberRole, memberStatus} + | memberStatus == GSMemPendingApproval = Nothing + | memberStatus == GSMemPendingReview = Just $ DeliveryTaskContext {jobScope = DJSMemberSupport {supportGMId = groupMemberId' m}, sentAsGroup = False} + | memberRole >= GRModerator = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}, sentAsGroup = False} + | otherwise = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False} +``` + +### 4c. Update NewMessageDeliveryTask +```haskell +data NewMessageDeliveryTask = NewMessageDeliveryTask + { messageId :: MessageId, + taskContext :: DeliveryTaskContext + } + deriving (Show) +``` + +### 4d. MessageDeliveryTask — no change + +`MessageDeliveryTask` stays as-is. It's constructed from DB rows in `getMsgDeliveryTask_` and consumed by relay forwarding code — those consumers need `jobScope` and `fwdSender` directly, not `DeliveryTaskContext`. `DeliveryTaskContext` is only for the path from processing functions → `NewMessageDeliveryTask` creation. + +### 4e. Update Store/Delivery.hs + +**`createMsgDeliveryTask`** (line 71-87): Extract `jobScope` and `sentAsGroup` from `taskContext` instead of separate `jobScope`/`showGroupAsSender` fields. + +**`getMsgDeliveryTask_`** — no change needed (`MessageDeliveryTask` unchanged). + +### 4f. Consumers of MessageDeliveryTask — no change needed + +**Subscriber.hs** lines ~3325-3333 and **Messages/Batch.hs** lines ~77-80 already pattern match on `FwdSender` and use `jobScope` from `MessageDeliveryTask`. Since `MessageDeliveryTask` is unchanged, no updates needed. + +### 4g. Return type changes in processing functions + +All functions currently returning `(Maybe DeliveryJobScope, ShowGroupAsSender)` change to `Maybe DeliveryTaskContext`: +- `groupMessageFileDescription` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageUpdate` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageDelete` → `CM (Maybe DeliveryTaskContext)` +- `xFileCancelGroup` → `CM (Maybe DeliveryTaskContext)` +- `groupMsgReaction` → `CM (Maybe DeliveryTaskContext)` + +Events that return `(Nothing, False)` or `(Just scope, False)` are updated: +- `(Nothing, False)` → `Nothing` +- `(Just scope, False)` → `Just $ DeliveryTaskContext scope False` (or use `memberEventDeliveryContext`) +- `(Just scope, showGroupAsSender)` → `Just $ DeliveryTaskContext scope showGroupAsSender` (or use `infoToDeliveryContext`) + +--- + +## Issue 5: Fix testChannelReactionAttribution {#issue-5} + +**File:** `tests/ChatTests/Groups.hs` lines 9057-9084 + +**Problem:** Comment says "reaction is forwarded as channel (owner is anonymous)" and expects `#team>`. Owner should react **as member** — reactions are always `sentAsGroup = False`. + +**Fix:** Change comment and expectations: +```haskell +-- owner reacts to own member message - reaction is forwarded as member +alice ##> "+1 #team hello" +alice <## "added 👍" +bob <# "#team alice> > alice hello" +bob <## " + 👍" +concurrentlyN_ + [ do cath <# "#team alice> > alice hello" + cath <## " + 👍", + do dan <# "#team alice> > alice hello" + dan <## " + 👍", + do eve <# "#team alice> > alice hello" + eve <## " + 👍" + ] +``` + +--- + +## Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment {#issue-6} + +**File:** `tests/ChatTests/Groups.hs` line 9127 + +**Problem:** Comment says "bob's internally deleted item is still in DB, update finds it with correct member direction". This is wrong — the item was internally deleted, then XMsgUpdate re-creates it via the `catchCINotFound` fallback. + +**Fix:** Change comment to: +```haskell +-- bob's internally deleted item is re-created as from member (sendAsGroup=False) +``` + +--- + +## Other: sendAsGroup parameter ordering {#other-issue} + +**Problem:** `sendAsGroup`/`ShowGroupAsSender` should come right after direction/scope, not at the end. + +### 7a. `APIForwardChatItems` constructor + +**File:** `src/Simplex/Chat/Library/Commands.hs` (ChatCommand type definition + parser) + +Current: `APIForwardChatItems toChat fromChat itemIds itemTTL sendAsGroup` +New: `APIForwardChatItems toChat sendAsGroup fromChat itemIds itemTTL` + +Affects: +- Constructor definition in `src/Simplex/Chat/Controller.hs` line 341 +- Parser at line 4639 +- Call sites at lines 930, 2192, 2198, 2204 + +### 7b. `createNewSndChatItem` + +**File:** `src/Simplex/Chat/Store/Messages.hs` line 528 + +Current: `createNewSndChatItem db user chatDirection msg ciContent quotedItem itemForwarded timed live hasLink showGroupAsSender createdAt` +New: `createNewSndChatItem db user chatDirection showGroupAsSender msg ciContent quotedItem itemForwarded timed live hasLink createdAt` + +Move `showGroupAsSender` right after `chatDirection` (direction context). + +Affects call site in `Internal.hs` line 2276. + +### 7c. `saveSndChatItems` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 2256-2265 + +Current param order: `user -> cd -> itemsData -> itemTimed -> live -> showGroupAsSender` +New: `user -> cd -> showGroupAsSender -> itemsData -> itemTimed -> live` + +Move `showGroupAsSender` right after `cd` (direction context). + +Affects call sites: Internal.hs line 2242, Commands.hs lines 2561, 2608 (and the `saveSndChatItem'` wrapper at line 2240). + +### 7d. `prepareGroupMsg` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 203 + +Current: `prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live showGroupAsSender` +New: `prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live` + +Move `showGroupAsSender` right after `msgScope` (scope context). + +Affects call sites: Internal.hs line 1249, Commands.hs line 4094. + +--- + +## Forwarded handler (xGrpMsgForward) changes + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 3136-3173 + +Add `withAuthor` helper to replace ad-hoc `| Just author <- author_` guards: +```haskell +where + withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () + withAuthor tag action = case author_ of + Just author -> action author + Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" +``` + +Update forwarded event handling: +- `XMsgFileDescr` → pass `author_` (Maybe GroupMember) directly +- `XMsgUpdate` → pass `author_` directly, void result +- `XMsgDel` → pass `author_` directly, void result +- `XMsgReact` → use `withAuthor` (required member) +- `XFileCancel` → pass `author_` directly +- Other events with `| Just author <- author_` → use `withAuthor` + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Delivery.hs` | Add `DeliveryTaskContext`, update `NewMessageDeliveryTask` only | +| `src/Simplex/Chat/Store/Delivery.hs` | Update `createMsgDeliveryTask` to extract from `taskContext` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; change function signatures to return `Maybe DeliveryTaskContext`; add `withAuthor`; simplify `validSender`; `groupMsgReaction` required member; fix lookup | +| `src/Simplex/Chat/Controller.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` constructor | +| `src/Simplex/Chat/Library/Commands.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` parser + call sites | +| `src/Simplex/Chat/Store/Messages.hs` | Reorder `showGroupAsSender` in `createNewSndChatItem` | +| `src/Simplex/Chat/Library/Internal.hs` | Reorder `showGroupAsSender` in `saveSndChatItems`, `prepareGroupMsg` | +| `src/Simplex/Chat/Messages/Batch.hs` | No change needed (`MessageDeliveryTask` unchanged) | +| `tests/ChatTests/Groups.hs` | Fix reaction test expectations + update fallback comment | + +--- + +## Verification + +1. `cabal build --ghc-options=-O0` — must compile clean +2. Run channel test suite: `cabal test simplex-chat-test --test-option='-m "channels"' --ghc-options=-O0` +3. Adversarial self-review loop until 2 consecutive clean passes +4. Verify no `isChannelOwner` references remain in Subscriber.hs direct handler +5. Verify `groupMsgReaction` signature has required `GroupMember` (no Maybe) +6. Verify no dual-lookup with `catchError` in `groupMessageUpdate` diff --git a/plans/group_channel_feature_coverage.md b/plans/group_channel_feature_coverage.md new file mode 100644 index 0000000000..f4b6e49353 --- /dev/null +++ b/plans/group_channel_feature_coverage.md @@ -0,0 +1,377 @@ +# Group & Channel Feature Test Coverage Plan + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Feature Coverage Matrix](#feature-coverage-matrix) +3. [Gap Analysis by Category](#gap-analysis-by-category) +4. [Recommended New Tests](#recommended-new-tests) +5. [Implementation Roadmap](#implementation-roadmap) + +--- + +## Executive Summary + +**Current State:** The test suite in `Groups.hs` provides comprehensive coverage across 120+ scenarios in 14 categories. Core functionality (group CRUD, messaging, member management) is well-tested. + +**Key Gaps Identified:** +- Business/contact card group links (untested invitation flow) +- Legacy group link auto-accept path +- Permission enforcement for `SGFFullDelete` +- Error recovery paths (file transfers, database busy, duplicate forwarding) +- Moderator-only scoped message delivery (`DJSMemberSupport`) +- Edge cases in channel message deletion + +**Risk Assessment:** +| Priority | Gap Count | Impact | +|----------|-----------|--------| +| Critical | 3 | Production failures in business flows | +| High | 5 | Feature regressions possible | +| Medium | 4 | Edge case handling incomplete | + +**Recommendation:** Add 12 new test scenarios in 3 phases over 2 sprints. + +--- + +## Feature Coverage Matrix + +### Legend +- ✅ Tested (comprehensive) +- ⚠️ Partial (some paths covered) +- ❌ Untested + +### Core Group Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Group creation | ✅ | `testGroup` | Basic + edge cases | +| Group deletion | ✅ | `testGroupDelete*` | Multiple scenarios | +| Group naming/description | ✅ | `testUpdateGroupProfile` | | +| Group preferences | ✅ | `testGroupPreferences` | Voice, files, etc. | +| Group link creation | ✅ | `testGroupLink*` | | +| Group link via contact card | ❌ | - | Business links untested | +| Legacy auto-accept | ❌ | - | Deprecated path | + +### Message Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| XMsgNew (send) | ✅ | Multiple | Core flow | +| XMsgUpdate (edit) | ✅ | `testGroupMessageUpdate` | | +| XMsgDel (delete) | ✅ | `testGroupMessageDelete` | | +| XMsgReact | ✅ | `testGroupMsgReaction` | | +| XMsgFileDescr | ✅ | `testGroupFileTransfer` | | +| Batch messages | ✅ | `testBatch*` | | +| Live messages | ✅ | `testGroupLiveMessage` | | +| Quote messages | ✅ | `testGroup*Quote*` | | +| Duplicate forwarding | ❌ | - | De-dup logic untested | + +### Member Management + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Member add | ✅ | `testGroupAddMember*` | | +| Member remove | ✅ | `testGroupRemoveMember*` | | +| Member roles | ✅ | `testGroupMemberRole*` | | +| Member blocking | ✅ | `testGroupBlock*` | | +| Member merging | ✅ | `testMergeMemberContact*` | | +| Member deletion errors | ❌ | - | Error paths missing | +| Contact from member | ✅ | `testCreateMemberContact*` | | + +### Moderation & Full Delete + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Moderate message | ✅ | `testGroupModerate*` | | +| Block for all | ✅ | `testGroupBlockForAll*` | | +| SGFFullDelete enabled | ✅ | `testFullDeleteGroup*` | | +| SGFFullDelete restricted | ❌ | - | Permission checks | + +### Channels & Relays + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| 1-relay delivery | ✅ | `testChannel1Relay*` | | +| 2-relay delivery | ✅ | `testChannel2Relay*` | | +| Owner-only sending | ✅ | `testChannel*Message*` | | +| Identity protection | ✅ | `testChannel*Incognito*` | | +| Channel msg delete errors | ❌ | - | Invalid state handling | + +### Scoped Messages (Support Chats) + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Single moderator | ✅ | `testSupportChat*` | | +| Multi moderator | ✅ | `testSupportChat*Multi*` | | +| Member reports | ✅ | `testReportMessage*` | | +| Forwarding in scope | ✅ | `testSupportChatForward*` | | +| Stats | ✅ | `testSupportChatStats` | | +| DJSMemberSupport delivery | ❌ | - | Moderator-only path | + +### Group Links & Invitations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Create/delete link | ✅ | `testGroupLink*` | | +| Join via link | ✅ | `testGroupLink*` | | +| Link screening | ✅ | `testGroupLink*Screening*` | | +| Connection plans | ✅ | `testPlanGroupLink*` | | +| Short links | ✅ | `testGroupShortLink*` | | +| Business link invitation | ❌ | - | Contact card flow | + +### Error Handling + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| CEGroupNotJoined | ⚠️ | Implicit | Some coverage | +| CEGroupMemberNotFound | ⚠️ | Implicit | Some coverage | +| File transfer errors | ❌ | - | Recovery paths | +| Database busy | ❌ | - | Retry logic | +| Simplex link warnings | ❌ | - | Feature gate | + +### History & Disappearing + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| History on join | ✅ | `testGroupHistory*` | | +| File history | ✅ | `testGroupHistoryFiles` | | +| Disappearing messages | ✅ | `testGroupHistoryDisappear*` | | + +--- + +## Gap Analysis by Category + +### Critical Priority (Production Impact) + +#### 1. Business Group Link via Contact Card +**Location:** `APIAddMember` with `InvitationContact` path +**Risk:** Business users cannot invite via contact cards +**Current State:** Only `InvitationMember` path tested +**Missing Coverage:** +- `processGroupInvitation` with `CTContactRequest` +- Auto-accept flow for business links +- Profile merge on business join + +#### 2. SGFFullDelete Permission Enforcement +**Location:** `canFullDelete`, `checkFullDeleteAllowed` +**Risk:** Non-admins might delete others' messages +**Missing Coverage:** +- `SGFFullDelete` set to `FAAdmins` restriction +- Error `CECommandError` when non-admin attempts full delete +- Role-based permission matrix + +#### 3. DJSMemberSupport Delivery Path +**Location:** `deliverGroupMessages`, `groupMsgDeliveryJobs` +**Risk:** Support messages not reaching moderators correctly +**Missing Coverage:** +- `DJSMemberSupport` job creation +- Moderator-only broadcast logic +- Scope isolation verification + +### High Priority (Feature Regressions) + +#### 4. Channel Message Deletion Errors +**Location:** `apiDeleteMemberChatItem`, `deleteGroupChatItemInternal` +**Missing Coverage:** +- Delete non-existent channel message +- Delete by non-owner in channel +- `CEInvalidChatItemDelete` error path + +#### 5. Member Deletion Error Paths +**Location:** `removeMemberDeleteItem`, `deleteGroupChatItem` +**Missing Coverage:** +- Delete item for already-removed member +- Concurrent deletion race condition +- `CEGroupMemberNotFound` specific handling + +#### 6. File Transfer Error Recovery +**Location:** `rcvFileError`, `sndFileError` +**Missing Coverage:** +- Partial transfer resume +- `CEFileTransferError` handling +- Cleanup on failed transfers + +#### 7. Legacy Group Link Auto-Accept +**Location:** `processGroupInvitation`, `autoAcceptGroupLink` +**Risk:** Breaking change for older clients +**Missing Coverage:** +- V1 protocol compatibility +- Auto-accept timing + +#### 8. Duplicate Message Forwarding +**Location:** `forwardGroupMessage`, `checkDuplicateForward` +**Missing Coverage:** +- Same message forwarded twice +- De-duplication by `sharedMsgId` +- UI state consistency + +### Medium Priority (Edge Cases) + +#### 9. Simplex Links Feature Warnings +**Location:** `simplexLinkWarning`, `SGFSimplexLinks` +**Missing Coverage:** +- Warning when feature disabled +- Link detection in messages +- User preference override + +#### 10. Database Busy Error Handling +**Location:** `withTransaction`, `retryOnBusy` +**Missing Coverage:** +- Concurrent group operations +- Retry exhaustion +- State consistency after retry + +#### 11. Invalid Channel/Member Scope Errors +**Location:** `validateGroupChatScope`, `scopeNotAllowed` +**Missing Coverage:** +- Member sending to wrong scope +- Scope mismatch on receive +- `CECommandError "scope not allowed"` path + +#### 12. Contact Card Profile Merge +**Location:** `mergeMemberContactProfile`, `updateContactProfile` +**Missing Coverage:** +- Profile conflict resolution +- Image merge logic +- Display name precedence + +--- + +## Recommended New Tests + +### Phase 1: Critical (Sprint 1) + +```haskell +-- Test 1: Business Group Link Invitation +testBusinessGroupLinkInvitation :: HasCallStack => TestParams -> IO () +-- Covers: InvitationContact path, CTContactRequest, auto-accept + +-- Test 2: Full Delete Permission Restriction +testFullDeletePermissionRestricted :: HasCallStack => TestParams -> IO () +-- Covers: SGFFullDelete FAAdmins, non-admin rejection, CECommandError + +-- Test 3: Moderator-Only Support Delivery +testSupportChatModeratorOnlyDelivery :: HasCallStack => TestParams -> IO () +-- Covers: DJSMemberSupport, moderator broadcast, scope isolation +``` + +### Phase 2: High (Sprint 1-2) + +```haskell +-- Test 4: Channel Message Delete Errors +testChannelMessageDeleteErrors :: HasCallStack => TestParams -> IO () +-- Covers: non-existent delete, non-owner delete, CEInvalidChatItemDelete + +-- Test 5: Member Deletion Error Paths +testMemberDeletionErrorPaths :: HasCallStack => TestParams -> IO () +-- Covers: removed member delete, concurrent delete, CEGroupMemberNotFound + +-- Test 6: File Transfer Error Recovery +testGroupFileTransferErrorRecovery :: HasCallStack => TestParams -> IO () +-- Covers: partial resume, CEFileTransferError, cleanup + +-- Test 7: Legacy Group Link Compatibility +testLegacyGroupLinkAutoAccept :: HasCallStack => TestParams -> IO () +-- Covers: V1 protocol, auto-accept timing + +-- Test 8: Duplicate Forward Prevention +testDuplicateMessageForwardPrevention :: HasCallStack => TestParams -> IO () +-- Covers: duplicate detection, sharedMsgId, UI consistency +``` + +### Phase 3: Medium (Sprint 2) + +```haskell +-- Test 9: Simplex Links Feature Warning +testSimplexLinksFeatureWarning :: HasCallStack => TestParams -> IO () +-- Covers: disabled feature warning, link detection + +-- Test 10: Database Busy Retry +testGroupOperationsDatabaseBusy :: HasCallStack => TestParams -> IO () +-- Covers: concurrent ops, retry logic, state consistency + +-- Test 11: Scope Validation Errors +testGroupChatScopeValidationErrors :: HasCallStack => TestParams -> IO () +-- Covers: wrong scope send, scope mismatch, CECommandError + +-- Test 12: Contact Card Profile Merge +testMemberContactProfileMerge :: HasCallStack => TestParams -> IO () +-- Covers: conflict resolution, image merge, name precedence +``` + +--- + +## Implementation Roadmap + +### Sprint 1 (Week 1-2) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 1: Business link | - | PR ready | +| 3-4 | Test 2: Full delete perms | - | PR ready | +| 5 | Test 3: Moderator delivery | - | PR ready | +| 6-7 | Test 4: Channel delete errors | - | PR ready | +| 8-9 | Test 5: Member delete errors | - | PR ready | +| 10 | Integration + Review | - | Merged | + +### Sprint 2 (Week 3-4) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 6: File error recovery | - | PR ready | +| 3-4 | Test 7: Legacy link compat | - | PR ready | +| 5-6 | Test 8: Duplicate forward | - | PR ready | +| 7-8 | Tests 9-12: Medium priority | - | PR ready | +| 9-10 | Final integration + CI | - | Release | + +### Dependencies + +``` +Test 1 (Business Link) ─┬─> Test 12 (Profile Merge) + │ +Test 3 (Moderator) ─────┴─> Test 11 (Scope Validation) + +Test 4 (Channel Delete) ──> Test 5 (Member Delete) + +Test 6 (File Error) ──────> (standalone) + +Test 7 (Legacy Link) ─────> Test 1 (Business Link) + +Test 8 (Duplicate) ───────> (standalone) + +Tests 9, 10 ──────────────> (standalone) +``` + +### Success Criteria + +1. **Coverage Target:** 95%+ of identified gaps covered +2. **CI Integration:** All tests in nightly suite +3. **Documentation:** Test rationale in docstrings +4. **No Regressions:** Existing 120+ tests still pass + +### Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Test flakiness | Use explicit waits, avoid timing assumptions | +| Database state leaks | Ensure proper cleanup in each test | +| Protocol version issues | Test both V1 and V2 where applicable | +| CI timeout | Parallelize independent tests | + +--- + +## Appendix: Test File Locations + +| Test Category | Primary File | Secondary | +|---------------|--------------|-----------| +| Group Core | `tests/ChatTests/Groups.hs` | - | +| Channels | `tests/ChatTests/Groups.hs` | `Channels/` if split | +| Support Chats | `tests/ChatTests/Groups.hs` | `ScopedMessages/` if split | +| File Transfers | `tests/ChatTests/Files.hs` | `Groups.hs` | +| Error Handling | Inline with feature tests | - | + +--- + +*Generated: 2026-02-06* +*Branch: ep/channel-messages-2* +*Coverage baseline: 120+ scenarios, 14 categories* diff --git a/plans/groups_coverage_fill_plan.md b/plans/groups_coverage_fill_plan.md new file mode 100644 index 0000000000..ffe0b7a52c --- /dev/null +++ b/plans/groups_coverage_fill_plan.md @@ -0,0 +1,368 @@ +# Plan: Filling Group/Channel Test Coverage Gaps + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Test File Organization](#test-file-organization) +3. [Priority 0: Critical Channel Paths](#priority-0-critical-channel-paths) +4. [Priority 1: Error and Fallback Paths](#priority-1-error-and-fallback-paths) +5. [Priority 2: Scope-Related Features](#priority-2-scope-related-features) +6. [Priority 3: Feature Restrictions](#priority-3-feature-restrictions) + +--- + +## Executive Summary + +This plan addresses the coverage gaps identified in `groups_test_coverage.md`, focusing exclusively on DSL-based scenario tests using the existing test infrastructure. All tests follow patterns established in `tests/ChatTests/Groups.hs`. + +**Excluded from scope:** JSON serialization tests (per user request). + +**Key gap categories:** +- Non-channel-owner members sending in channel groups +- Moderation/delete paths in channels (`memberDelete`) +- Error fallback paths (`catchCINotFound`) +- Member support scope (`GCSIMemberSupport`) +- Full-delete feature, live updates, mentions + +--- + +## Test File Organization + +All new tests go in `tests/ChatTests/Groups.hs` under existing or new `describe` blocks. + +### New `describe` blocks to add: + +```haskell +describe "channel moderation" $ do + -- Tests for memberDelete path, channel moderation errors + +describe "channel error paths" $ do + -- Tests for catchCINotFound, invalid sender, etc. + +describe "channel mentions" $ do + -- Tests for mentions in channel messages + +describe "group full delete feature" $ do + -- Tests for SGFFullDelete enabled +``` + +--- + +## Priority 0: Critical Channel Paths + +### Test 1: `testChannelMemberModerate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel moderation"` + +**Objective:** Cover `memberDelete` path in `groupMessageDelete` (lines 2016-2076) - moderation of channel messages by admin/owner. + +**Scenario:** +1. Create channel with owner (alice) + relay (bob) + members (cath, dan) +2. Owner sends channel message +3. Admin/owner moderates (deletes) the channel message +4. Verify message marked deleted for all members +5. Verify moderation event is forwarded + +**Coverage targets:** +- `memberDelete` function execution +- `moderate` helper with role checks +- `delete` with `delMember_` populated + +--- + +### Test 2: `testChannelMemberDeleteError` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover error path `CIChannelRcv -> messageError "x.msg.del: unexpected channel message in member delete"` (line 2036). + +**Scenario:** +1. Create channel with owner + relay + member +2. Attempt to trigger memberDelete on CIChannelRcv item (malformed delete request) +3. Verify error is logged/handled correctly + +**Coverage targets:** +- Line 2036: `CIChannelRcv` error case in `memberDelete` + +--- + +### Test 3: `testChannelUpdateNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMessageUpdate` (lines 1950-1969) - update arrives for locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner updates the message +5. Verify member creates new item from update (fallback path) + +**Coverage targets:** +- Line 1960: `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` +- Lines 1951-1969: create-from-update fallback + +--- + +### Test 4: `testChannelReactionNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMsgReaction` (lines 1823-1837) - reaction on locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner adds reaction +5. Verify reaction is handled without crash + +**Coverage targets:** +- Lines 1835-1837: channel reaction fallback + +--- + +### Test 5: `testChannelForwardedMessages` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "relay delivery"` (existing) + +**Objective:** Cover `FwdChannel` branch in delivery task (line 3311) and forwarded message parameters. + +**Scenario:** +1. Create channel with owner + 2 relays + members +2. Send various message types (new, update, delete, reaction) +3. Verify all are forwarded through relay chain +4. Check forwarded parameters are correctly passed + +**Coverage targets:** +- Line 3311: `FwdChannel -> (Nothing, Nothing)` +- Lines 3139-3145: forwarded message handlers + +--- + +## Priority 1: Error and Fallback Paths + +### Test 6: `testGroupDeleteNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` or existing moderation tests + +**Objective:** Cover delete error when message not found (line 2039). + +**Scenario:** +1. Create group with alice, bob +2. Bob sends message +3. Alice locally deletes it +4. Bob broadcasts delete for the same message +5. Verify error path is handled + +**Coverage targets:** +- Line 2039: `messageError ("x.msg.del: message not found, " <> tshow e)` + +--- + +### Test 7: `testGroupInvalidSenderUpdate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `validSender _ _ = False` (line 1874) and update from wrong member error (line 1980). + +**Scenario:** +1. Create group with alice, bob, cath +2. Bob sends message +3. Cath (with spoofed member ID) attempts to update bob's message +4. Verify error is thrown + +**Coverage targets:** +- Line 1874: `validSender _ _ = False` +- Line 1980: `messageError "x.msg.update: group member attempted to update..."` + +--- + +### Test 8: `testGroupReactionDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing `describe "group message reactions"` + +**Objective:** Cover reaction disabled path (line 1839). + +**Scenario:** +1. Create group with reactions feature disabled +2. Member attempts to add reaction +3. Verify reaction is rejected + +**Coverage targets:** +- Line 1839: `otherwise = pure Nothing` when reactions not allowed + +--- + +### Test 9: `testChannelItemNotChanged` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel message operations"` (existing) + +**Objective:** Cover `CEvtChatItemNotChanged` path (lines 2001-2002) - update with same content. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message +3. Owner "updates" message with identical content +4. Verify no change event is emitted + +**Coverage targets:** +- Lines 2001-2002: `CEvtChatItemNotChanged` path + +--- + +## Priority 2: Scope-Related Features + +### Test 10: `testScopedSupportMentions` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover mentions in scoped support messages (`getRcvCIMentions` with non-empty mentions). + +**Scenario:** +1. Create group with alice (owner), bob (member), dan (moderator) +2. Bob sends support message mentioning @alice +3. Alice receives with mention highlighted +4. Verify `userMention` flag is set correctly + +**Coverage targets:** +- Line 2316: `getRcvCIMentions` with actual mentions +- Line 2319: `sameMemberId mId membership` in userReply check +- Lines 279-281: `uniqueMsgMentions` path + +--- + +### Test 11: `testMemberChatStats` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `memberChatStats` function (lines 2323-2330) for both `CDGroupRcv` and `CDChannelRcv` with scope. + +**Scenario:** +1. Create group with support enabled +2. Member sends support message +3. Verify unread stats are updated +4. Verify `memberAttentionChange` is computed + +**Coverage targets:** +- Lines 2325-2329: `memberChatStats` branches +- Line 2621: `memberAttentionChange` + +**Note:** Tests `testScopedSupportUnreadStatsOnRead` and `testScopedSupportUnreadStatsOnDelete` exist but may not cover all branches. + +--- + +### Test 12: `testMkGetMessageChatScope` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `mkGetMessageChatScope` branches (lines 1599-1617). + +**Scenario:** +1. Create group with pending member (knocking) +2. Pending member sends message with scope +3. Verify correct scope resolution +4. Test with `isReport mc` content type + +**Coverage targets:** +- Line 1601: `Just _scopeInfo` return +- Line 1604: `isReport mc` branch +- Lines 1610-1617: `sameMemberId` and `otherwise` branches + +--- + +## Priority 3: Feature Restrictions + +### Test 13: `testGroupFullDelete` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** new `describe "group full delete feature"` + +**Objective:** Cover `groupFeatureAllowed SGFFullDelete` = True path (line 2067) - `deleteGroupCIs` instead of `markGroupCIsDeleted`. + +**Scenario:** +1. Create group with full delete enabled: `/set delete #team on` +2. Bob sends message +3. Alice (or bob) deletes message +4. Verify message is fully deleted (not just marked) + +**Coverage targets:** +- Line 2067: `deleteGroupCIs` path +- `groupFeatureAllowed SGFFullDelete` returns True + +--- + +### Test 14: `testGroupLiveMessage` +**File:** `tests/ChatTests/Groups.hs` +**Note:** `testGroupLiveMessage` exists but may not cover update path. + +**Objective:** Cover live message update path (line 830 in View.hs, `itemLive == Just True`). + +**Scenario:** +1. Create group +2. Send live message +3. Update live message content +4. Verify live update is processed + +**Coverage targets:** +- Line 830: `itemLive == Just True && not liveItems -> []` +- Live update in `groupMessageUpdate` + +--- + +### Test 15: `testGroupVoiceDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing tests or new `describe "group feature restrictions"` + +**Objective:** Cover voice message rejection (line 342 in Internal.hs). + +**Scenario:** +1. Create group with voice disabled: `/set voice #team off` +2. Member attempts to send voice message +3. Verify rejection + +**Coverage targets:** +- Line 342: `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` + +--- + +### Test 16: `testGroupReportsDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group member reports"` (existing) + +**Objective:** Cover reports disabled path (line 344 in Internal.hs). + +**Scenario:** +1. Create group with reports disabled +2. Member attempts to send report +3. Verify rejection + +**Coverage targets:** +- Line 344: `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` + +--- + +## Implementation Order + +1. **Phase 1 (P0):** Tests 1-5 - Critical channel paths +2. **Phase 2 (P1):** Tests 6-9 - Error and fallback paths +3. **Phase 3 (P2):** Tests 10-12 - Scope-related features +4. **Phase 4 (P3):** Tests 13-16 - Feature restrictions + +Each test should: +- Use existing DSL operators (`##>`, `<#`, `#$>`, etc.) +- Follow naming convention `test` +- Include `HasCallStack` constraint +- Use appropriate test helpers (`createGroup2`, `createChannel1Relay`, etc.) + +--- + +## Dependencies + +- Existing test infrastructure in `ChatTests.Utils` +- Helper functions: `createChannel1Relay`, `createGroup2`, `createGroup3`, etc. +- DSL operators for assertions + +## Estimated New Tests: 16 + +## Files Modified: 1 +- `tests/ChatTests/Groups.hs` diff --git a/plans/groups_test_coverage.md b/plans/groups_test_coverage.md new file mode 100644 index 0000000000..7ee01f1d6f --- /dev/null +++ b/plans/groups_test_coverage.md @@ -0,0 +1,441 @@ +# Group/Channel Test Coverage Analysis + +Coverage run: `cabal test simplex-chat-test --enable-coverage --ghc-options=-O0 --test-options="-m group"` + +Full 164 group tests executed (151 passed, 13 failed due to unrelated issues). + +## Coverage Summary + +After running all group tests: +- Expressions: 48% +- Alternatives: 33% +- Local declarations: 64% +- Top-level: 34% + +--- + +## What IS Covered (Channel-Specific Paths) + +- `createNewRcvChatItem` with `CDChannelRcv` - channel message creation +- `toGroupChatItem` with `showGroupAsSender = True` - channel message reading +- `validSender Nothing CIChannelRcv = True` - channel sender validation +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId (`IS NOT DISTINCT FROM`) +- `toCIDirection CDChannelRcv -> CIChannelRcv` +- `toChatInfo CDChannelRcv g s -> GroupChat g s` +- `chatItemMember CIChannelRcv -> Nothing` +- `viewChatItem` for both `CIGroupRcv` and `CIChannelRcv` +- `viewItemReaction` dispatch to `groupReaction` for both constructors +- Channel delete happy path (`channelDelete` -> `delete Nothing`) + +--- + +## Uncovered Code Paths + +### 1. Subscriber.hs + +#### `processGroupMessage` dispatch (lines 935-972) + +| Line | Code | Status | +|------|------|--------| +| 956 | `asGroup == Just True && memberRole' m'' < GROwner` | tickonlyfalse - rejecting non-owner sending as group never tested | +| 963 | `ttl` parameter in `groupMessageUpdate` | nottickedoff | +| 965 | `scope_` parameter in `groupMsgReaction` | nottickedoff | +| 967 | `XFile` handler | nottickedoff | +| 970 | `XFileAcptInv` handler | nottickedoff | +| 987 | `XGrpPrefs` handler | nottickedoff | +| 993 | `BFileChunk` handler | nottickedoff | +| 994 | Catch-all `_` for unsupported messages | nottickedoff | + +#### `memberCanSend` / `memberCanSend'` (lines 1446-1454) + +| Line | Code | Status | +|------|------|--------| +| 1449 | `memberPending m` part of condition | tickonlytrue - never false | +| 1450 | `otherwise` branch (error "member is not allowed to send messages") | nottickedoff | + +#### `newGroupContentMessage` (lines 1876-1940) + +| Line | Code | Status | +|------|------|--------| +| 1879 | `vr` parameter in `mkGetMessageChatScope` | nottickedoff | +| 1882 | `ft_` and `False` parameters to `prohibitedGroupContent` | nottickedoff | +| 1883 | `rejected` helper invocation | nottickedoff | +| 1895 | `mentions` parameter for channel messages | nottickedoff | +| 1896 | `pure []` for reactions when `sharedMsgId_` is Nothing | nottickedoff | +| 1901 | `rejected` function body | nottickedoff | +| 1902 | `Just Nothing` for timed_ when forwarded | nottickedoff | +| 1910 | `M.empty` for mentions when blocked | tickonlyfalse | +| 1914 | `gInfo'` and `m'` params to `processFileInv` | nottickedoff | + +#### `groupMessageUpdate` (lines 1943-2002) + +| Line | Code | Status | +|------|------|--------| +| 1960 | `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` | nottickedoff - channel catchCINotFound | +| 1967 | `CDChannelRcv {} -> pure ci'` | nottickedoff | +| 1977 | `mentions' = if memberBlocked m then []` | tickonlyfalse | +| 1980 | `otherwise -> messageError "x.msg.update: group member attempted to update..."` | nottickedoff | +| 1984 | `messageError "x.msg.update: invalid message update"` | nottickedoff | +| 2001-2002 | `CEvtChatItemNotChanged` path | nottickedoff | + +#### `groupMessageDelete` (lines 2004-2076) + +**channelDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2013 | `messageError "x.msg.del: invalid channel message delete"` | nottickedoff | +| 2015 | `messageError ("x.msg.del: channel message not found, " <> tshow e)` | nottickedoff | + +**memberDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2028 | `messageError "x.msg.del: member attempted invalid message delete"` | tickonlyfalse | +| 2036 | `CIChannelRcv -> messageError "x.msg.del: unexpected channel message..."` | nottickedoff | +| 2039 | `messageError ("x.msg.del: message not found, " <> tshow e)` | tickonlyfalse | +| 2041-2042 | `messageError "...message of another member with insufficient..."` | tickonlyfalse | +| 2044-2047 | `createCIModeration` scoped moderation path | nottickedoff | + +**moderate helper:** +| Line | Code | Status | +|------|------|--------| +| 2058 | `messageError "x.msg.del: message of another member with incorrect memberId"` | nottickedoff | +| 2059 | `messageError "x.msg.del: message of another member without memberId"` | nottickedoff | +| 2062 | `messageError "...insufficient member permissions"` | tickonlyfalse | + +#### `groupMsgReaction` (lines 1818-1860) + +| Line | Code | Status | +|------|------|--------| +| 1823-1837 | Entire `catchCINotFound` fallback | nottickedoff | +| 1825-1831 | Scoped reaction path for member with scope | nottickedoff | +| 1832-1834 | Regular group reaction when item not found | nottickedoff | +| 1835-1837 | Channel reaction when item not found | nottickedoff | +| 1839 | `otherwise = pure Nothing` when reactions not allowed | tickonlyfalse | +| 1859 | `Nothing` return for channel (`isJust m_` is False) | nottickedoff | +| 1860 | `pure Nothing` when `ciReactionAllowed` is False | nottickedoff | + +#### `validSender` (lines 1871-1874) + +| Line | Code | Status | +|------|------|--------| +| 1872 | `validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m` | nottickedoff | +| 1873 | `validSender Nothing CIChannelRcv = True` | **covered** | +| 1874 | `validSender _ _ = False` | nottickedoff | + +#### `processForwardedMsg` / `xGrpMsgForward` (lines 3127-3153) + +| Line | Code | Status | +|------|------|--------| +| 3133 | `(const Nothing)` wrapper | nottickedoff | +| 3139 | `mentions`, `msgScope`, `ttl`, `live`, `True` to `groupMessageUpdate` | nottickedoff | +| 3141 | `scope_` and `rcvMsg` to `groupMessageDelete` | nottickedoff | +| 3143 | `scope_` to `groupMsgReaction` | nottickedoff | +| 3145 | `XInfo` handler when `author_` is Just | nottickedoff | +| 3152 | `XGrpPrefs` forwarding | nottickedoff | +| 3153 | Catch-all error for unsupported forwarded event | nottickedoff | +| 3311 | `FwdChannel -> (Nothing, Nothing)` | nottickedoff | + +--- + +### 2. View.hs + +#### `viewChatItem` (line 646) + +| Line | Code | Status | +|------|------|--------| +| 555 | `groupNtf user g mention` - `mention` parameter for channel | nottickedoff | +| 673 | `showSndItemProhibited to` for `CISndGroupInvitation` | nottickedoff | +| 674 | `showSndItem to` fallback for GroupChat | nottickedoff | +| 682 | `CIRcvIntegrityError` in group context | nottickedoff | +| 683 | `CIRcvGroupInvitation` with `isJust m_` guard | nottickedoff | +| 684 | `CIRcvModerated` in group context | nottickedoff | +| 685 | `CIRcvBlocked` in group context | nottickedoff | +| 686 | `showRcvItem from` fallback | nottickedoff | +| 691 | `forwardedFrom` in context computation | nottickedoff | + +#### `viewItemUpdate` (line 798) + +| Line | Code | Status | +|------|------|--------| +| 819 | `CIGroupRcv m -> updGroupItem (Just m)` | nottickedoff | +| 822 | `CIGroupSnd _ -> []` fallback | nottickedoff | +| 825 | `ttyToGroup g scopeInfo` (non-edited send path) | nottickedoff | +| 830 | `itemLive == Just True && not liveItems -> []` | tickonlyfalse | +| 832 | `_ -> []` fallback for non-message content | nottickedoff | +| 834 | `ttyFromGroup g scopeInfo m_` (non-edited receive path) | nottickedoff | +| 837 | `forwardedFrom` in context | nottickedoff | +| 838 | `groupQuote g` in context | nottickedoff | + +#### `viewItemReaction` (line 890) + +| Line | Code | Status | +|------|------|--------| +| 898-899 | `sentByMember' g itemDir` in both CIGroupRcv and CIChannelRcv | nottickedoff | +| 913 | `groupReaction _ -> []` (non-message-content fallback) | nottickedoff | +| 917 | `else sentBy` branch when `showGroupAsSender` is False | nottickedoff | +| 958 | `sentByMember'` function | **entirely nottickedoff** | +| 962 | `CIChannelRcv -> Nothing` in sentByMember' | nottickedoff | + +#### `viewItemDelete` (line 869) + +| Line | Code | Status | +|------|------|--------| +| 880 | `_ -> prohibited` in GroupChat branch | nottickedoff | + +#### `viewGroupChatItemsDeleted` (line 866) + +| Line | Code | Status | +|------|------|--------| +| 158 | `member_` parameter | nottickedoff | +| 866 | `maybe "" (\m -> " " <> ttyMember m) member_` - empty string fallback | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `groupScopeInfoStr` (line 2785) + +| Line | Code | Status | +|------|------|--------| +| - | `Just (GCSIMemberSupport {groupMember_}) -> ...` | nottickedoff | +| - | `Nothing -> "(support)"` sub-branch | nottickedoff | +| - | `Just m -> "(support: " <> viewMemberName m <> ")"` sub-branch | nottickedoff | + +#### Scope info display + +| Line | Code | Status | +|------|------|--------| +| 2768 | `groupScopeInfoStr scopeInfo` in `ttyToGroup` | nottickedoff | +| 2779 | `groupScopeInfoStr scopeInfo` in `ttyToGroupEdited` | nottickedoff | +| 2782 | `groupScopeInfoStr scopeInfo` in `fromGroupAttention_` | nottickedoff | + +#### Other display functions + +| Line | Code | Status | +|------|------|--------| +| 625 | `GroupChat g scopeInfo -> [" " <> ttyToGroup g scopeInfo]` | nottickedoff | +| 766 | `(SMDSnd, GroupChat gInfo _scopeInfo) -> Just $ "you #" <> ...` | nottickedoff | +| 767 | `(SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> ...` | nottickedoff | +| 936 | `viewReactionMembers` | **entirely nottickedoff** | +| 1020 | `viewChatCleared` GroupChat branch | nottickedoff | + +--- + +### 3. Internal.hs + +#### `saveRcvChatItem'` (lines 2294-2340) + +| Line | Code | Status | +|------|------|--------| +| 2288 | `M.empty` for non-group mentions | nottickedoff | +| 2299 | `groupMentions` parameters `db` and `membership` | nottickedoff | +| 2300 | `_ -> pure (M.empty, False)` for non-group | nottickedoff | +| 2303 | `contactChatDeleted cd` | tickonlyfalse | +| 2303 | `vr` parameter in `updateChatTsStats` | nottickedoff | +| 2304 | `else pure $ toChatInfo cd` | nottickedoff | +| 2316 | `getRcvCIMentions` - `db`, `user`, `mentions` parameters | nottickedoff | +| 2319 | `sameMemberId mId membership` in userReply check | nottickedoff | +| 2320 | `\CIMention {memberId} -> sameMemberId memberId membership` | nottickedoff | +| 2311 | `createGroupCIMentions db g ci mentions'` | nottickedoff (mentions always empty) | + +#### `memberChatStats` (line 2323) + +| Line | Code | Status | +|------|------|--------| +| 2325-2327 | `CDGroupRcv _g (Just scope) m -> ...` | nottickedoff | +| 2328-2329 | `CDChannelRcv _g (Just scope) -> ...` | nottickedoff | +| 2330 | `_ -> Nothing` | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `memberAttentionChange` (line 2621) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `getRcvCIMentions` (line 277) + +| Line | Code | Status | +|------|------|--------| +| 279 | `not (null ft) && not (null mentions) -> ...` | nottickedoff | +| 280 | `uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft` | nottickedoff | +| 281 | `mapM (getMentionedMemberByMemberId db user groupId) mentions'` | nottickedoff | + +#### `uniqueMsgMentions` (line 286) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `prepareGroupMsg` / `quoteData` (line 204) + +| Line | Code | Status | +|------|------|--------| +| 209 | `MCForward $ ExtMsgContent ...` forward branch | nottickedoff | +| 227 | `CIGroupSnd` with `showGroupAsSender` False | nottickedoff | +| 228 | `CIGroupRcv m -> pure (qmc, CIQGroupRcv $ Just m, False, Just m)` | nottickedoff | + +#### `mkGetMessageChatScope` (lines 1599-1617) + +| Line | Code | Status | +|------|------|--------| +| 1601 | `groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope` | nottickedoff | +| 1604 | `isReport mc -> ...` | tickonlyfalse | +| 1610 | `sameMemberId mId membership -> ...` | nottickedoff | +| 1614 | `otherwise -> do referredMember <- ...` | nottickedoff | +| 1614 | `vr` parameter in `getGroupMemberByMemberId` | nottickedoff | + +#### `mkGroupSupportChatInfo` (line 1620) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### Feature checks (tickonlyfalse - never true) + +| Line | Code | Status | +|------|------|--------| +| 342 | `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` | tickonlyfalse | +| 344 | `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` | tickonlyfalse | +| 485 | `isACIUserMention deletedChatItem` | tickonlyfalse | +| 1593 | `memberPending m` | tickonlyfalse | + +#### `sendGroupMessages` (line 1986) + +| Line | Code | Status | +|------|------|--------| +| 1989 | `sendProfileUpdate catchAllErrors eToView` | nottickedoff | +| 1995 | `isJust scope = False` branch | nottickedoff | + +--- + +### 4. Messages.hs + +#### JSON direction functions - ALL ENTIRELY UNTESTED + +**`jsonCIDirection` (lines 314-321):** +| Line | Code | Status | +|------|------|--------| +| 315 | `CIDirectSnd -> JCIDirectSnd` | nottickedoff | +| 316 | `CIDirectRcv -> JCIDirectRcv` | nottickedoff | +| 317 | `CIGroupSnd -> JCIGroupSnd` | nottickedoff | +| 318 | `CIGroupRcv m -> JCIGroupRcv m` | nottickedoff | +| 319 | `CIChannelRcv -> JCIChannelRcv` | nottickedoff | +| 320 | `CILocalSnd -> JCILocalSnd` | nottickedoff | +| 321 | `CILocalRcv -> JCILocalRcv` | nottickedoff | + +**`jsonACIDirection` (lines 324-331):** +| Line | Code | Status | +|------|------|--------| +| 325-331 | All branches including `JCIChannelRcv -> ACID SCTGroup SMDRcv CIChannelRcv` | nottickedoff | + +**`jsonCIQDirection` (lines 646-651):** +| Line | Code | Status | +|------|------|--------| +| 647 | `CIQDirectSnd -> JCIDirectSnd` | nottickedoff | +| 648 | `CIQDirectRcv -> JCIDirectRcv` | nottickedoff | +| 649 | `CIQGroupSnd -> JCIGroupSnd` | nottickedoff | +| 650 | `CIQGroupRcv (Just m) -> JCIGroupRcv m` | nottickedoff | +| 651 | `CIQGroupRcv Nothing -> JCIChannelRcv` | nottickedoff | + +**`jsonACIQDirection` (lines 654-661):** +| Line | Code | Status | +|------|------|--------| +| 655-659 | All branches including `JCIChannelRcv -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing` | nottickedoff | +| 660 | `JCILocalSnd -> Left "unquotable"` | nottickedoff | +| 661 | `JCILocalRcv -> Left "unquotable"` | nottickedoff | + +**ToJSON/FromJSON instances:** +| Line | Code | Status | +|------|------|--------| +| 1469-1470 | `CIDirection` ToJSON | nottickedoff | +| 1473 | `CCIDirection` FromJSON | nottickedoff | +| 1476 | `ACIDirection` FromJSON | nottickedoff | +| 1479 | `CIQDirection` FromJSON | nottickedoff | +| 1482-1483 | `CIQDirection` ToJSON | nottickedoff | + +#### Other Messages.hs functions + +| Line | Code | Status | +|------|------|--------| +| 372-375 | `chatItemRcvFromMember` | partially covered - `_ -> Nothing` nottickedoff | +| 403 | `toCIDirection CDLocalRcv _ -> CILocalRcv` | nottickedoff | +| 413 | `toChatInfo CDLocalRcv l -> LocalChat l` | nottickedoff | +| 486 | `aChatItemRcvFromMember` | nottickedoff | +| 665 | `quoteMsgDirection CIQDirectSnd -> MDSnd` | nottickedoff | +| 666 | `quoteMsgDirection CIQDirectRcv -> MDRcv` | nottickedoff | + +--- + +### 5. Store/Messages.hs + +#### Scope-filtered query functions - ALL ENTIRELY UNTESTED + +| Function | Lines | Status | +|----------|-------|--------| +| `findGroupChatPreviews_` | 862-900 | nottickedoff | +| `getChatContentTypes` | 1183-1197 | nottickedoff | +| `getChatItemIDs` | 1476-1505 | nottickedoff | +| `queryUnreadGroupItems` | 1686-1707 | nottickedoff | +| `updateSupportChatItemsRead` | 2038-2077 | nottickedoff | +| `getGroupUnreadTimedItems` | 2080-2102 | nottickedoff | +| `getGroupMemberCIBySharedMsgId` | 2950-2960 | nottickedoff | + +#### `toGroupChatItem` (lines 2327-2337) + +| Line | Code | Status | +|------|------|--------| +| 2329 | `CIChannelRcv` with file | **covered** | +| 2332 | `CIChannelRcv` without file | **covered** | +| 2334 | `CIGroupRcv member` with file | nottickedoff | +| 2336 | `CIGroupRcv member` without file | nottickedoff | +| 2337 | `badItem` fallback | nottickedoff | +| 2321 | `deletedByGroupMember_` parsing | nottickedoff | + +#### `getChatItemQuote_` CDChannelRcv (lines 648-653) + +| Line | Code | Status | +|------|------|--------| +| 651 | `mId == userMemberId` check | nottickedoff | +| 651 | `getUserGroupChatItemId_` call | nottickedoff | +| 652 | `otherwise` fallback | nottickedoff | +| 653 | `_ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing` | **covered** | + +#### Reaction functions + +| Line | Code | Status | +|------|------|--------| +| 3275 | `getGroupCIReactions` | **covered** | +| 3328 | `deleteGroupCIReactions_` | nottickedoff | + +--- + +## Summary + +### Well-tested channel paths: +- Channel message create/read/delete happy paths +- Basic channel reactions +- Channel quote creation (quoting nothing) +- `validSender Nothing CIChannelRcv` +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId + +### Major gaps: + +1. **Non-channel-owner member in channel groups** - `isChannelOwner` always True, `memberForChannel = Just m''` never executed + +2. **All JSON serialization for CI directions** - `jsonCIDirection`, `jsonACIDirection`, `jsonCIQDirection`, `jsonACIQDirection` and all `ToJSON`/`FromJSON` instances entirely untested + +3. **Member support scope (`GCSIMemberSupport`)** - `mkGroupSupportChatInfo`, `groupScopeInfoStr`, `memberChatStats` entirely untested + +4. **Mentions in channel/group messages** - `getRcvCIMentions` with non-empty mentions, `uniqueMsgMentions`, `createGroupCIMentions` never called + +5. **Error/fallback paths** - `catchCINotFound` in update/delete/reaction, invalid sender validation, permission errors + +6. **Full-delete feature** - `groupFeatureAllowed SGFFullDelete` always false, `deleteGroupCIs` never called + +7. **Live message updates** - `itemLive == Just True` always false + +8. **Forwarded message handling** - Most parameters to forwarded handlers untested, `FwdChannel` branch untested + +9. **View functions** - `sentByMember'`, `viewGroupChatItemsDeleted`, `viewReactionMembers` entirely untested + +10. **Scope-filtered store queries** - 7 functions entirely untested + +11. **Feature restriction checks** - Voice messages (`SGFVoice`), reports (`SGFReports`) feature checks never triggered diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 5ebcd08ae9..0d628c1c67 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.11:

+
    +
  • improve image, video and link messages.
  • +
+

New in v6.4-6.4.10:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 305fdb95fe..44af955f40 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5f08457b7e5cd6e42f03a3d5bcabd716afd8b91c" = "14cs4mj59m1f25pvkw0v8x3qvirfz2sf6b2rk3b1ygh10czsad9x"; + "https://github.com/simplex-chat/simplexmq.git"."99f9de71e5df213bb062fa11dd165778fc1d7160" = "1gqza72i1lnllj0h0i6d2mf47zy1k4yinvmm61wypij5n1pf626h"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f72e9fd37e..e6bbbf38d8 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.0.9 +version: 6.5.0.12 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -80,6 +80,7 @@ library Simplex.Chat.Store.Messages Simplex.Chat.Store.NoteFolders Simplex.Chat.Store.Profiles + Simplex.Chat.Store.RelayRequests Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled @@ -127,7 +128,9 @@ library Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link - Simplex.Chat.Store.Postgres.Migrations.M20260201_client_services + Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays + Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed + Simplex.Chat.Store.Postgres.Migrations.M20260407_client_services else exposed-modules: Simplex.Chat.Archive @@ -278,7 +281,9 @@ library Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link - Simplex.Chat.Store.SQLite.Migrations.M20260201_client_services + Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays + Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed + Simplex.Chat.Store.SQLite.Migrations.M20260407_client_services other-modules: Paths_simplex_chat hs-source-dirs: @@ -544,6 +549,7 @@ test-suite simplex-chat-test ChatClient ChatTests ChatTests.ChatList + ChatTests.ChatRelays ChatTests.Direct ChatTests.DBUtils ChatTests.Files diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aeae74c6c1..448a2bbc16 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 @@ -73,14 +74,18 @@ defaultChatConfig = smp = simplexChatSMPServers, useSMP = 4, xftp = map (presetServer True) $ L.toList defaultXFTPServers, - useXFTP = 3 + useXFTP = 3, + chatRelays = simplexChatRelays, + useChatRelays = 2 }, PresetOperator { operator = Just operatorFlux, smp = fluxSMPServers, useSMP = 3, xftp = fluxXFTPServers, - useXFTP = 3 + useXFTP = 3, + chatRelays = [], + useChatRelays = 0 } ], ntf = _defaultNtfServers, @@ -109,6 +114,7 @@ defaultChatConfig = highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, + channelSubscriberRole = GRObserver, deviceNameForRemote = "", remoteCompression = True, chatHooks = defaultChatHooks @@ -168,6 +174,9 @@ newChatController chatStoreChanged <- newTVarIO False deliveryTaskWorkers <- TM.emptyIO deliveryJobWorkers <- TM.emptyIO + relayRequestWorkers <- TM.emptyIO + relayGroupLinkChecksAsync <- newTVarIO Nothing + chatRelayTests <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO cleanupManagerAsync <- newTVarIO Nothing @@ -209,6 +218,9 @@ newChatController filesFolder, deliveryTaskWorkers, deliveryJobWorkers, + relayRequestWorkers, + relayGroupLinkChecksAsync, + chatRelayTests, expireCIThreads, expireCIFlags, cleanupManagerAsync, @@ -241,7 +253,9 @@ newChatController smp = map newUserServer smpSrvs, useSMP = 0, xftp = map newUserServer xftpSrvs, - useXFTP = 0 + useXFTP = 0, + chatRelays = [], + useChatRelays = 0 } randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p)) randomServerCfgs name p opDomains rndSrvs = @@ -263,7 +277,8 @@ newChatController getServers ops opDomains user' = do smpSrvs <- getProtocolServers db SPSMP user' xftpSrvs <- getProtocolServers db SPXFTP user' - uss <- groupByOperator' (ops, smpSrvs, xftpSrvs) + chatRelays <- getChatRelays db user' + uss <- groupByOperator' (ops, smpSrvs, xftpSrvs, chatRelays) ts <- getCurrentTime uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss let auId = aUserId user' diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 03a2a3fc9f..537c423423 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, @@ -248,6 +249,9 @@ data ChatController = ChatController filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, + relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework + relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), + chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), @@ -337,7 +341,7 @@ data ChatCommand | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIGetReactionMembers {userId :: UserId, groupId :: GroupId, chatItemId :: ChatItemId, reaction :: MsgReaction} | APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId} - | APIForwardChatItems {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int} + | APIForwardChatItems {toChatRef :: ChatRef, sendAsGroup :: ShowGroupAsSender, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int} | APIUserRead UserId | UserRead | APIChatRead {chatRef :: ChatRef} @@ -394,6 +398,10 @@ data ChatCommand | SetUserProtoServers AProtocolType [AProtoServerWithAuth] | APITestProtoServer UserId AProtoServerWithAuth | TestProtoServer AProtoServerWithAuth + | GetUserChatRelays + | SetUserChatRelays [CLINewRelay] + | APITestChatRelay UserId ShortLinkContact + | TestChatRelay ShortLinkContact | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) | SetServerOperators (NonEmpty ServerOperatorRoles) @@ -420,6 +428,7 @@ data ChatCommand | APISetMemberSettings GroupId GroupMemberId GroupMemberSettings | APIContactInfo ContactId | APIGroupInfo GroupId + | APIGetUpdatedGroupLinkData {groupId :: GroupId} | APIGroupMemberInfo GroupId GroupMemberId | APIContactQueueInfo ContactId | APIGroupMemberQueueInfo GroupId GroupMemberId @@ -463,7 +472,7 @@ data ChatCommand | APIChangeConnectionUser Int64 UserId -- new user id to switch connection to | APIConnectPlan {userId :: UserId, connectionLink :: Maybe AConnectionLink} -- Maybe is used to report link parsing failure as special error | APIPrepareContact UserId ACreatedConnLink ContactShortLinkData - | APIPrepareGroup UserId CreatedLinkContact GroupShortLinkData + | APIPrepareGroup UserId CreatedLinkContact DirectLink GroupShortLinkData | APIChangePreparedContactUser ContactId UserId | APIChangePreparedGroupUser GroupId UserId | APIConnectPreparedContact {contactId :: ContactId, incognito :: IncognitoEnabled, msgContent_ :: Maybe MsgContent} @@ -505,6 +514,10 @@ data ChatCommand | ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text} | APINewGroup {userId :: UserId, incognito :: IncognitoEnabled, groupProfile :: GroupProfile} | NewGroup IncognitoEnabled GroupProfile + -- 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} | AcceptMember GroupName ContactName GroupMemberRole @@ -631,6 +644,32 @@ allowRemoteCommand = \case ExecAgentStoreSQL _ -> False _ -> True +data RelayConnectionResult = RelayConnectionResult + { relayMember :: GroupMember, + relayError :: Maybe ChatError + } + deriving (Show) + +data RelayTestStep + = RTSGetLink + | RTSDecodeLink + | RTSConnect + | RTSWaitResponse + | RTSVerify + deriving (Show) + +data RelayTestFailure = RelayTestFailure + { rtfStep :: RelayTestStep, + rtfError :: ChatError + } + deriving (Show) + +data RelayTest = RelayTest + { challenge :: ByteString, + rootKey :: C.PublicKeyEd25519, + result :: TMVar (Maybe RelayTestFailure) + } + data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} @@ -647,9 +686,10 @@ data ChatResponse | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} + | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, relayTestFailure :: Maybe RelayTestFailure} | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} - | CRUserServersValidation {user :: User, serverErrors :: [UserServersError]} + | CRUserServersValidation {user :: User, serverErrors :: [UserServersError], serverWarnings :: [UserServersWarning]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} @@ -679,6 +719,8 @@ data ChatResponse | CRChatHelp {helpSection :: HelpSection} | 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]} @@ -688,7 +730,7 @@ data ChatResponse | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} - | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} + | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus @@ -707,7 +749,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} @@ -717,7 +759,7 @@ data ChatResponse | CRAcceptingContactRequest {user :: User, contact :: Contact} | CRContactAlreadyExists {user :: User, contact :: Contact} | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} - | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo} + | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo, msgSigned :: Bool} | CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation} | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} -- TODO add chatItem :: AChatItem @@ -737,9 +779,9 @@ data ChatResponse | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberSupportChatRead {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} - | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} - | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} + | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole, msgSigned :: Bool} + | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool, msgSigned :: Bool} + | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember, msgSigned :: Bool} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} | CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink} @@ -838,20 +880,22 @@ data ChatEvent | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CEvtGroupLinkDataUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay], relaysChanged :: Bool} + | CEvtGroupRelayUpdated {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRelay :: GroupRelay} | CEvtJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- there is the same command response | CEvtJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} | CEvtMemberAcceptedByOther {user :: User, groupInfo :: GroupInfo, acceptingMember :: GroupMember, member :: GroupMember} - | CEvtMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CEvtMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} + | CEvtMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole, msgSigned :: Maybe MsgSigStatus} + | CEvtMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool, msgSigned :: Maybe MsgSigStatus} | CEvtConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} - | CEvtDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool} - | CEvtDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool} - | CEvtLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CEvtDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool, msgSigned :: Maybe MsgSigStatus} + | CEvtDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool, msgSigned :: Maybe MsgSigStatus} + | CEvtLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Maybe MsgSigStatus} | CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} -- there is the same command response + | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Maybe MsgSigStatus} + | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember, msgSigned :: Maybe MsgSigStatus} -- there is the same command response | CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CEvtNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CEvtNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} @@ -926,14 +970,9 @@ logEventToFile = \case data SendRef = SRDirect ContactId - | SRGroup GroupId (Maybe GroupChatScope) + | SRGroup GroupId (Maybe GroupChatScope) ShowGroupAsSender deriving (Eq, Show) -sendToChatRef :: SendRef -> ChatRef -sendToChatRef = \case - SRDirect cId -> ChatRef CTDirect cId Nothing - SRGroup gId scope -> ChatRef CTGroup gId scope - data ChatPagination = CPLast Int | CPAfter ChatItemId Int @@ -986,13 +1025,22 @@ data ContactAddressPlan deriving (Show) data GroupLinkPlan - = GLPOk {groupSLinkData_ :: Maybe GroupShortLinkData} + = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData} | GLPOwnLink {groupInfo :: GroupInfo} | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} deriving (Show) +type DirectLink = Bool + +data GroupShortLinkInfo = GroupShortLinkInfo + { direct :: Bool, + groupRelays :: [ShortLinkContact], + publicGroupId :: Maybe B64UrlByteString + } + deriving (Show) + connectionPlanProceed :: ConnectionPlan -> Bool connectionPlanProceed = \case CPInvitationLink ilp -> case ilp of @@ -1006,7 +1054,7 @@ connectionPlanProceed = \case CAPContactViaAddress _ -> True _ -> False CPGroupLink glp -> case glp of - GLPOk _ -> True + GLPOk {} -> True GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True _ -> False @@ -1263,6 +1311,7 @@ data ChatErrorType | CENoRcvFileUser {agentRcvFileId :: AgentRcvFileId} | CEUserUnknown | CEUserExists {contactName :: ContactName} + | CEChatRelayExists | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} | CECantDeleteActiveUser {userId :: UserId} | CECantDeleteLastUser {userId :: UserId} @@ -1331,6 +1380,7 @@ data ChatErrorType | CEConnectionIncognitoChangeProhibited | CEConnectionUserChangeProhibited | CEPeerChatVRangeIncompatible + | CERelayTestError {message :: String} | CEInternalError {message :: String} | CEException {message :: String} deriving (Show, Exception) @@ -1596,6 +1646,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) +$(JQ.deriveJSON defaultJSON ''GroupShortLinkInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) @@ -1660,6 +1712,12 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) +$(JQ.deriveJSON defaultJSON ''RelayConnectionResult) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "RTS") ''RelayTestStep) + +$(JQ.deriveJSON defaultJSON ''RelayTestFailure) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 14ced716fc..bd6cac2110 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -15,7 +15,9 @@ where import Control.Logger.Simple import Control.Monad +import Control.Monad.Except import Control.Monad.Reader +import qualified Data.ByteString.Char8 as B import Data.List (find) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -27,18 +29,21 @@ import Simplex.Chat.Library.Commands import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..), CreateBotOpts (..)) import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences (FeatureAllowed (..), FilesPreference (..), Preferences (..), emptyChatPrefs) -import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse, simplexChatContact) +import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Encoding.String import System.Exit (exitFailure) import System.IO (hFlush, stdout) import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath, maintenance}, createBot} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath, maintenance}, createBot} chat = case logAgent of Just level -> do setLogLevel level @@ -51,7 +56,8 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha putStrLn $ "Error opening database: " <> show e exitFailure run db@ChatDatabase {chatStore} = do - u_ <- getSelectActiveUser chatStore + users <- withTransaction chatStore getUsers + u_ <- selectActiveUser coreOptions chatStore users let backgroundMode = maintenance newChatController db u_ cfg opts backgroundMode >>= \case Left e -> do @@ -59,18 +65,19 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha exitFailure Right cc -> do forM_ (preStartHook chatHooks) ($ cc) - u <- maybe (noMaintenance >> createActiveUser cc createBot) pure u_ + u <- maybe (noMaintenance >> createActiveUser cc coreOptions createBot) pure u_ unless testView $ putStrLn $ "Current user: " <> userStr u - runSimplexChat opts u cc chat + runSimplexChat cfg opts u cc chat noMaintenance = when maintenance $ do putStrLn "exiting: no active user in maintenance mode" exitFailure -runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () -runSimplexChat ChatOpts {coreOptions = CoreChatOpts {maintenance}} u cc@ChatController {config = ChatConfig {chatHooks}} chat +runSimplexChat :: ChatConfig -> ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () +runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatRelay, maintenance}} u cc@ChatController {config = ChatConfig {chatHooks}} chat | maintenance = wait =<< async (chat u cc) | otherwise = do a1 <- runReaderT (startChatController True True) cc + when (chatRelay && not testView) $ askCreateRelayAddress cc u forM_ (postStartHook chatHooks) ($ cc) a2 <- async $ chat u cc waitEither_ a1 a2 @@ -81,24 +88,30 @@ sendChatCmdStr cc s = runReaderT (execChatCommand Nothing (encodeUtf8 $ T.pack s sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd 0) cc -getSelectActiveUser :: DBStore -> IO (Maybe User) -getSelectActiveUser st = do - users <- withTransaction st getUsers - case find activeUser users of - Just u -> pure $ Just u - Nothing -> selectUser users +selectActiveUser :: CoreChatOpts -> DBStore -> [User] -> IO (Maybe User) +selectActiveUser CoreChatOpts {chatRelay} st users + | chatRelay = + case find (\User {userChatRelay} -> isTrue userChatRelay) users of + Just u + | activeUser u -> pure $ Just u + | otherwise -> Just <$> withTransaction st (`setActiveUser` u) + Nothing -> pure Nothing + | otherwise = + case find activeUser users of + Just u -> pure $ Just u + Nothing -> selectUser where - selectUser :: [User] -> IO (Maybe User) - selectUser = \case + selectUser :: IO (Maybe User) + selectUser = case users of [] -> pure Nothing [user] -> Just <$> withTransaction st (`setActiveUser` user) - users -> do + _users -> do putStrLn "Select user profile:" forM_ (zip [1 :: Int ..] users) $ \(n, user) -> putStrLn $ show n <> ": " <> userStr user loop where loop = do - nStr <- getWithPrompt $ "user number (1 .. " <> show (length users) <> ")" + nStr <- withPrompt ("user number (1 .. " <> show (length users) <> "): ") getLine case readMaybe nStr :: Maybe Int of Nothing -> putStrLn "not a number" >> loop Just n @@ -107,39 +120,77 @@ getSelectActiveUser st = do let user = users !! (n - 1) in Just <$> withTransaction st (`setActiveUser` user) -createActiveUser :: ChatController -> Maybe CreateBotOpts -> IO User -createActiveUser cc = \case +createActiveUser :: ChatController -> CoreChatOpts -> Maybe CreateBotOpts -> IO User +createActiveUser cc CoreChatOpts {chatRelay} = \case Just CreateBotOpts {botDisplayName, allowFiles, clientService} -> do let preferences = if allowFiles then Nothing else Just emptyChatPrefs {files = Just FilesPreference {allow = FANo}} createUser exitFailure clientService $ (mkProfile botDisplayName) {peerType = Just CPTBot, preferences} - Nothing -> do - putStrLn - "No user profiles found, it will be created now.\n\ - \Please choose your display name.\n\ - \It will be sent to your contacts when you connect.\n\ - \It is only stored on your device and you can change it later." - loop + Nothing -> putStrLn noProfile >> loop + where + noProfile + | chatRelay = + "No chat relay user profile found, it will be created now.\n\ + \Please choose chat relay display name." + | otherwise = + "No user profiles found, it will be created now.\n\ + \Please choose your display name.\n\ + \It will be sent to your contacts when you connect.\n\ + \It is only stored on your device and you can change it later." + loop = do + displayName <- T.pack <$> withPrompt "display name" getLine + createUser loop False $ mkProfile displayName where - loop = do - displayName <- T.pack <$> getWithPrompt "display name" - createUser loop False $ mkProfile displayName mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} createUser onError clientService p = - execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case + execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user r -> printResponseEvent (Nothing, Nothing) (config cc) r >> onError +askCreateRelayAddress :: ChatController -> User -> IO () +askCreateRelayAddress cc@ChatController {chatStore} user = + withTransaction chatStore (\db -> runExceptT $ getUserAddress db user) >>= \case + Right _ -> pure () + Left SEUserContactLinkNotFound -> promptCreate + Left e -> printChatError (config cc) $ ChatErrorStore e + where + promptCreate :: IO () + promptCreate = do + ok <- onOffPrompt "Create relay address" True + when ok $ + execChatCommand' CreateMyAddress 0 `runReaderT` cc >>= \case + Right (CRUserContactLinkCreated _ address) -> do + putStrLn "Chat relay address is created:" + putStrLn $ addressStr address + r -> printResponseEvent (Nothing, Nothing) (config cc) r + addressStr :: CreatedLinkContact -> String + addressStr (CCLink cReq shortLink) = B.unpack $ maybe cReqStr strEncode shortLink + where + cReqStr = strEncode $ simplexChatContact cReq + printResponseEvent :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Either ChatError r -> IO () printResponseEvent hu cfg = \case Right r -> do ts <- getCurrentTime tz <- getCurrentTimeZone putStrLn $ serializeChatResponse hu cfg ts tz (fst hu) r - Left e -> do - putStrLn $ serializeChatError True cfg e + Left e -> printChatError cfg e -getWithPrompt :: String -> IO String -getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine +printChatError :: ChatConfig -> ChatError -> IO () +printChatError cfg e = putStrLn $ serializeChatError True cfg e + +withPrompt :: String -> IO a -> IO a +withPrompt s a = putStr s >> hFlush stdout >> a + +onOffPrompt :: String -> Bool -> IO Bool +onOffPrompt prompt def = + withPrompt (prompt <> if def then " (Yn): " else " (yN): ") $ + getLine >>= \case + "" -> pure def + "y" -> pure True + "Y" -> pure True + "n" -> pure False + "N" -> pure False + _ -> putStrLn "Invalid input, please enter 'y' or 'n'" >> onOffPrompt prompt def userStr :: User -> String userStr User {localDisplayName, profile = LocalProfile {fullName}} = diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index d0a77514eb..822ee5efb9 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -10,7 +10,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Maybe (fromMaybe) import Data.Time.Clock (UTCTime) -import Simplex.Chat.Messages (GroupChatScopeInfo (..), MessageId) +import Simplex.Chat.Messages (GroupChatScopeInfo (..), MessageId, ShowGroupAsSender) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types @@ -41,6 +41,16 @@ instance TextEncoding DeliveryWorkerScope where DWSMemberSupport -> "member_support" -- DWSMemberProfileUpdate -> "member_profile_update" +-- Context for creating a delivery task. Separate from DeliveryJobScope because +-- sentAsGroup is only needed for task persistence and batching into XGrpMsgForward events. +-- Once batched into jobs, sentAsGroup=True and sentAsGroup=False messages can be mixed, +-- so jobs don't need this flag. +data DeliveryTaskContext = DeliveryTaskContext + { jobScope :: DeliveryJobScope, + sentAsGroup :: ShowGroupAsSender + } + deriving (Show) + data DeliveryJobScope = DJSGroup {jobSpec :: DeliveryJobSpec} | DJSMemberSupport {supportGMId :: GroupMemberId} @@ -93,12 +103,14 @@ jobSpecImpliedPending = \case DJDeliveryJob {includePending} -> includePending DJRelayRemoved -> True -infoToDeliveryScope :: GroupInfo -> Maybe GroupChatScopeInfo -> DeliveryJobScope -infoToDeliveryScope GroupInfo {membership} = \case - Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} - Just GCSIMemberSupport {groupMember_} -> - let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ - in DJSMemberSupport {supportGMId} +infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext +infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup} + where + jobScope = case scopeInfo of + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} memberEventDeliveryScope :: GroupMember -> Maybe DeliveryJobScope memberEventDeliveryScope m@GroupMember {memberRole, memberStatus} @@ -109,8 +121,7 @@ memberEventDeliveryScope m@GroupMember {memberRole, memberStatus} data NewMessageDeliveryTask = NewMessageDeliveryTask { messageId :: MessageId, - jobScope :: DeliveryJobScope, - messageFromChannel :: MessageFromChannel + taskContext :: DeliveryTaskContext } deriving (Show) @@ -118,13 +129,10 @@ data MessageDeliveryTask = MessageDeliveryTask { taskId :: Int64, jobScope :: DeliveryJobScope, senderGMId :: GroupMemberId, - senderMemberId :: MemberId, - senderMemberName :: ContactName, + fwdSender :: FwdSender, brokerTs :: UTCTime, - chatMessage :: ChatMessage 'Json, - messageFromChannel :: MessageFromChannel + verifiedMsg :: VerifiedMsg 'Json } - deriving (Show) deliveryTaskId :: MessageDeliveryTask -> Int64 deliveryTaskId = taskId diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 32f8ffd944..cf11787a54 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -97,6 +97,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.ShortLink as SL import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQEncOff, pattern PQSupportOff, pattern PQSupportOn) @@ -114,6 +115,7 @@ import System.Exit (ExitCode, exitSuccess) import System.FilePath (takeExtension, takeFileName, ()) import System.IO (Handle, IOMode (..)) import System.Random (randomRIO) +import System.Timeout (timeout) import UnliftIO.Async import UnliftIO.Concurrent (forkIO, threadDelay) import UnliftIO.Directory @@ -122,13 +124,13 @@ import UnliftIO.IO (hClose) import UnliftIO.STM #if defined(dbPostgres) import Data.Bifunctor (bimap, second) -import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) #else import Data.Bifunctor (bimap, first, second) import qualified Data.ByteArray as BA import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive -import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) import Simplex.Messaging.Agent.Store.Common (withConnection) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) #endif @@ -190,8 +192,10 @@ startChatController mainApp enableSndFiles = do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users startDeliveryWorkers + startRelayRequestWorker_ startCleanupManager void $ forkIO $ mapM_ startExpireCIs users + startRelayChecks users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -203,6 +207,10 @@ startChatController mainApp enableSndFiles = do runExceptT (startDeliveryTaskWorkers >> startDeliveryJobWorkers) >>= \case Left e -> liftIO $ putStrLn $ "Error starting delivery workers: " <> show e Right _ -> pure () + startRelayRequestWorker_ = + runExceptT startRelayRequestWorker >>= \case + Left e -> liftIO $ putStrLn $ "Error starting relay request worker: " <> show e + Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case @@ -210,6 +218,15 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () + startRelayChecks users = do + let relayUser_ = find (\User {userChatRelay} -> isTrue userChatRelay) users + forM_ relayUser_ $ \relayUser -> do + relayAsync <- asks relayGroupLinkChecksAsync + readTVarIO relayAsync >>= \case + Nothing -> do + a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser) + atomically $ writeTVar relayAsync a + _ -> pure () startExpireCIs user = whenM shouldExpireChats $ do startExpireCIThread user setExpireCIFlag user True @@ -327,20 +344,21 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace processChatCommand :: VersionRangeChat -> NetworkRequestMode -> ChatCommand -> CM ChatResponse processChatCommand vr nm = \case ShowActiveUser -> withUser' $ pure . CRActiveUser - CreateActiveUser NewUser {profile, pastTimestamp, clientService} -> do + CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay, clientService} -> do forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser users <- withFastStore' getUsers - forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> + forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} + when (isTrue userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists (uss, (smp', xftp')) <- chooseServers =<< readTVarIO u let service = isTrue clientService auId <- withAgent $ \a -> createUser a service smp' xftp' ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withFastStore $ \db -> do - user <- createUserRecordAt db (AgentUserId auId) service p True ts + user <- createUserRecordAt db (AgentUserId auId) (isTrue userChatRelay) service p True ts mapM_ (setUserServers db user ts) uss createPresetContactCards db user `catchAllErrors` \_ -> pure () createNoteFolder db user @@ -366,9 +384,16 @@ processChatCommand vr nm = \case let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as pure (uss, (smp', xftp')) copyServers :: UserOperatorServers -> UpdatedUserOperatorServers - copyServers UserOperatorServers {operator, smpServers, xftpServers} = - let new srv = AUS SDBNew srv {serverId = DBNewEntity} - in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers} + copyServers UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = + let newSrv srv = AUS SDBNew srv {serverId = DBNewEntity} + newCRelay chatRelay = AUCR SDBNew chatRelay {chatRelayId = DBNewEntity} + in + UpdatedUserOperatorServers { + operator, + smpServers = map newSrv smpServers, + xftpServers = map newSrv xftpServers, + chatRelays = map newCRelay chatRelays + } coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withFastStore' getUsersInfo @@ -610,12 +635,12 @@ processChatCommand vr nm = \case mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) - SRGroup chatId gsScope -> + SRGroup chatId gsScope asGroup -> withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do g <- getGroupInfo db vr user chatId (g,) <$> mapM (composedMessageReqMentions db user g) cms - sendGroupContentMessages user gInfo gsScope live itemTTL cmrs + sendGroupContentMessages user gInfo gsScope asGroup live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user @@ -644,7 +669,7 @@ processChatCommand vr nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} - sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False Nothing [composedMessageReq cm] + sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage @@ -662,7 +687,7 @@ processChatCommand vr nm = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - let event = XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) Nothing + let event = XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) Nothing Nothing (SndMessage {msgId}, _) <- sendDirectContactMessage user ct event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -685,7 +710,7 @@ processChatCommand vr nm = \case -- TODO [knocking] check chat item scope? cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of - CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do + CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do chatScopeInfo <- mapM (getChatScopeInfo vr user) scope @@ -696,7 +721,7 @@ processChatCommand vr nm = \case ciMentions <- withFastStore $ \db -> getCIMentions db user gInfo ft_ mentions let msgScope = toMsgScope gInfo <$> chatScopeInfo mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions - event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope + event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope (Just showGroupAsSender) SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -756,7 +781,7 @@ processChatCommand vr nm = \case assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing $ toMsgScope gInfo <$> chatScopeInfo) msgIds - mapM_ (sendGroupMessages user gInfo Nothing recipients) events + mapM_ (sendGroupMessages user gInfo Nothing False recipients) events -- TODO delGroupChatItems sends deletion events too. Are they needed? delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False @@ -842,10 +867,10 @@ processChatCommand vr nm = \case throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) unless (ciReactionAllowed ci) $ throwCmdError "reaction not allowed - chat item has no content" - let GroupMember {memberId = itemMemberId} = chatItemMember g ci + let itemMemberId = memberId' <$> chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId (Just itemMemberId) (toMsgScope g <$> chatScopeInfo) reaction add) + SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt @@ -917,7 +942,7 @@ processChatCommand vr nm = \case MCChat {} -> True MCUnknown {} -> True -- TODO [knocking] forward from / to scope - APIForwardChatItems toChat@(ChatRef toCType toChatId toScope) fromChat@(ChatRef fromCType fromChatId _fromScope) itemIds itemTTL -> withUser $ \user -> case toCType of + APIForwardChatItems toChat@(ChatRef toCType toChatId toScope) sendAsGroup fromChat@(ChatRef fromCType fromChatId _fromScope) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user case L.nonEmpty cmrs of @@ -931,7 +956,7 @@ processChatCommand vr nm = \case Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId - sendGroupContentMessages user gInfo toScope False itemTTL cmrs' + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -1182,8 +1207,7 @@ processChatCommand vr nm = \case pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId - let GroupMember {memberRole = membershipMemRole} = membership - let isOwner = membershipMemRole == GROwner + let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo @@ -1191,7 +1215,10 @@ processChatCommand vr nm = \case deleteCIFiles user filesInfo (members, recipients) <- getRecipients gInfo let doSendDel = memberActive membership && isOwner - when doSendDel . void $ sendGroupMessage' user gInfo recipients XGrpDel + msgSigned <- + if doSendDel + then isJust . signedMsg_ <$> sendGroupMessage' user gInfo recipients XGrpDel + else pure False deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchAllErrors` \_ -> pure () @@ -1199,11 +1226,11 @@ processChatCommand vr nm = \case withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo withFastStore' $ \db -> deleteGroupMembers db user gInfo withFastStore' $ \db -> deleteGroup db user gInfo - pure $ CRGroupDeletedUser user gInfo + pure $ CRGroupDeletedUser user gInfo msgSigned where - getRecipients gInfo@GroupInfo {useRelays} - | isTrue useRelays = do - relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + getRecipients gInfo + | useRelays' gInfo = do + relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo pure (relays, relays) | otherwise = do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo @@ -1266,7 +1293,7 @@ processChatCommand vr nm = \case sendWelcomeMsg user ct ucl UserContactRequest {welcomeSharedMsgId} = forM_ (autoReply $ addressSettings ucl) $ \mc -> case welcomeSharedMsgId of Just smId -> - void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing + void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing Nothing -> do (msg, _) <- sendDirectContactMessage user ct $ XMsgNew $ MCSimple $ extMsgContent mc Nothing ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) @@ -1458,7 +1485,7 @@ processChatCommand vr nm = \case pure $ CRConnNtfMessages ntfMsgs GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do srvs <- withFastStore (`getUserServers` user) - liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs) + liftIO $ CRUserServers user <$> groupByOperator (onlyProtocolServers p srvs) SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) case L.nonEmpty userServers_ of @@ -1477,6 +1504,61 @@ processChatCommand vr nm = \case lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand vr nm $ APITestProtoServer userId srv + APITestChatRelay userId address -> withUserId userId $ \user -> do + let failAt step e = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step e) + r <- tryAllErrors $ getShortLinkConnReq nm user address + case r of + Left e -> failAt RTSGetLink e + Right (FixedLinkData {rootKey, linkConnReq = cReq}, cData) -> do + relayProfile_ <- liftIO $ decodeLinkUserData cData + case relayProfile_ of + Nothing -> failAt RTSDecodeLink (ChatError $ CERelayTestError "no relay address link data") + Just RelayAddressLinkData {relayProfile} -> do + let failWithProfile step e = + pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step e) + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> failWithProfile RTSConnect (ChatError $ CERelayTestError "invalid connection request") + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn@Connection {connId = testCId} <- withFastStore $ \db -> + createRelayTestConnection db vr user connId ConnPrepared chatV subMode + challenge <- drgRandomBytes 32 + testVar <- newEmptyTMVarIO + let acId = aConnId conn + relayTest = RelayTest {challenge, rootKey, result = testVar} + chatRelayTests_ <- asks chatRelayTests + atomically $ TM.insert acId relayTest chatRelayTests_ + testResult <- tryAllErrors $ do + dm <- encodeConnInfo $ XGrpRelayTest challenge Nothing + void $ withAgent $ \a -> joinConnection a nm (aUserId user) acId True cReq dm PQSupportOff subMode + liftIO $ timeout 40000000 $ atomically $ takeTMVar testVar + atomically $ TM.delete acId chatRelayTests_ + withFastStore' $ \db -> deleteConnectionRecord db user testCId + deleteAgentConnectionAsync acId + case testResult of + Left e -> failWithProfile RTSConnect e + Right Nothing -> failWithProfile RTSWaitResponse (ChatError $ CERelayTestError "timeout") + Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing + Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) + TestChatRelay address -> withUser $ \User {userId} -> + processChatCommand vr nm $ APITestChatRelay userId address + GetUserChatRelays -> withUser $ \user -> do + srvs <- withFastStore (`getUserServers` user) + liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs) + SetUserChatRelays relays -> withUser $ \user@User {userId} -> do + userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) + case L.nonEmpty userServers_ of + Nothing -> throwCmdError "no relays" + Just userServers -> case relays of + [] -> throwCmdError "no relays" + _ -> do + let relays' = map aUserRelay relays + processChatCommand vr nm $ APISetUserServers userId $ L.map (updatedRelays relays') userServers + where + aUserRelay :: CLINewRelay -> AUserChatRelay + aUserRelay CLINewRelay {address, name} = AUCR SDBNew $ newChatRelay (mkRelayProfile name Nothing) [""] address APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators APISetServerOperators operators -> do as <- asks randomAgentServers @@ -1496,7 +1578,8 @@ processChatCommand vr nm = \case getServers db as ops opDomains user = do smpSrvs <- getProtocolServers db SPSMP user xftpSrvs <- getProtocolServers db SPXFTP user - uss <- groupByOperator (ops, smpSrvs, xftpSrvs) + chatRelays <- getChatRelays db user + uss <- groupByOperator (ops, smpSrvs, xftpSrvs, chatRelays) pure $ (aUserId user,) $ useServers as opDomains uss SetServerOperators operatorsRoles -> do ops <- serverOperators <$> withFastStore getServerOperators @@ -1511,8 +1594,9 @@ processChatCommand vr nm = \case APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do - errors <- validateAllUsersServers userId $ L.toList userServers + (errors, warnings) <- validateAllUsersServers userId $ L.toList userServers unless (null errors) $ throwCmdError $ "user servers validation error(s): " <> show errors + unless (null warnings) $ logWarn $ "user servers validation warning(s): " <> tshow warnings uss <- withFastStore $ \db -> do ts <- liftIO getCurrentTime mapM (setUserServers db user ts) userServers @@ -1525,7 +1609,7 @@ processChatCommand vr nm = \case setProtocolServers a auId xftp' ok_ APIValidateServers userId userServers -> withUserId userId $ \user -> - CRUserServersValidation user <$> validateAllUsersServers userId userServers + uncurry (CRUserServersValidation user) <$> validateAllUsersServers userId userServers APIGetUsageConditions -> do (usageConditions, acceptedConditions) <- withFastStore $ \db -> do usageConditions <- getCurrentUsageConditions db @@ -1629,8 +1713,8 @@ processChatCommand vr nm = \case withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchAllErrors` eToView ok user where - getMembers db gInfo@GroupInfo {useRelays} - | isTrue useRelays = getGroupRelays db vr user gInfo + getMembers db gInfo + | useRelays' gInfo = getGroupRelayMembers db vr user gInfo | otherwise = getGroupMembers db vr user gInfo _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do @@ -1656,6 +1740,18 @@ processChatCommand vr nm = \case Nothing -> throwChatError $ CEContactNotActive ct APIGroupInfo gId -> withUser $ \user -> CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db vr user gId) + APIGetUpdatedGroupLinkData groupId -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = GroupProfile {publicGroup}} <- withFastStore $ \db -> getGroupInfo db vr user groupId + case publicGroup of + Just PublicGroupProfile {groupLink = sLnk} | useRelays' gInfo -> do + (_, cData) <- getShortLinkConnReq nm user sLnk + groupSLinkData_ <- liftIO $ decodeLinkUserData cData + let publicGroupData_ = groupSLinkData_ >>= \GroupShortLinkData {publicGroupData} -> publicGroupData + publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ + gInfo' <- fromMaybe gInfo + <$> forM publicMemberCount_ (\count -> withFastStore $ \db -> setPublicMemberCount db vr user gInfo count) + pure $ CRGroupInfo user gInfo' + _ -> throwCmdError "group link data not available" APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) @@ -1861,7 +1957,9 @@ processChatCommand vr nm = \case let Profile {preferences} = profile groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user groupProfile True ccLink welcomeSharedMsgId + gVar <- asks random + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing + 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 createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing @@ -1885,12 +1983,16 @@ processChatCommand vr nm = \case Just (AChatItem SCTDirect dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTDirect chat - APIPrepareGroup userId ccLink groupSLinkData -> withUserId userId $ \user -> do - let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData + APIPrepareGroup userId ccLink direct groupSLinkData -> withUserId userId $ \user -> do + let GroupShortLinkData {groupProfile = gp@GroupProfile {description}, publicGroupData = publicGroupData_} = groupSLinkData + publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user gp False ccLink welcomeSharedMsgId + 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 subRole publicMemberCount_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) - let cd = CDGroupRcv gInfo Nothing hostMember + let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing @@ -1908,11 +2010,17 @@ processChatCommand vr nm = \case lift $ createContactChangedFeatureItems user ct ct' pure $ CRContactUserChanged user ct newUser ct' APIChangePreparedGroupUser groupId newUserId -> withUser $ \user -> do - (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId when (isNothing $ preparedGroup gInfo) $ throwCmdError "group doesn't have link to connect" - when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" + hostMember_ <- + if useRelays' gInfo + then pure Nothing + else do + hostMember <- withFastStore $ \db -> getHostMember db vr user groupId + when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" + pure $ Just hostMember newUser <- privateGetUser newUserId - gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember newUser + gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember_ newUser pure $ CRGroupUserChanged user gInfo newUser gInfo' APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId @@ -1960,10 +2068,87 @@ processChatCommand vr nm = \case pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup groupId incognito msgContent_ -> withUser $ \user -> do - (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId - case preparedGroup gInfo of - Nothing -> throwCmdError "group doesn't have link to connect" - Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId} -> do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + case gInfo of + GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" + GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do + sLnk <- case toShortLinkContact connLinkToConnect of + Just sl -> pure sl + Nothing -> throwChatError $ CEException "failed to retrieve relays: no short link" + (FixedLinkData {linkConnReq = mainCReq@(CRContactUri crData), linkEntityId, rootKey}, cData@(ContactLinkData _ UserContactData {owners, relays})) <- getShortLinkConnReq nm user sLnk + groupSLinkData_ <- liftIO $ decodeLinkUserData cData + -- Validate link entity ID matches group profile's publicGroupId (relay groups must have both) + case groupSLinkData_ of + Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {publicGroupId}}} + | (B64UrlByteString <$> linkEntityId) == Just publicGroupId -> pure () + _ -> throwChatError CEInvalidConnReq + let publicGroupData_ = groupSLinkData_ >>= \GroupShortLinkData {publicGroupData} -> publicGroupData + publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ + -- Prepare group record once before connecting to relays (updatePreparedRelayedGroup): + -- set group link info and incognito profile, generate and store membership keys + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} + gVar <- asks random + (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar + gInfo' <- withFastStore $ \db -> do + gInfo' <- updatePreparedRelayedGroup db vr user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ + -- Pre-emptively create owner members with trusted keys from link data + forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> + void $ createLinkOwnerMember db vr user gInfo' (MemberId ownerId) ownerKey + pure gInfo' + rs <- mapConcurrently (connectToRelay gInfo') relays + let relayFailed = \case (_, _, Left _) -> True; _ -> False + (failed, succeeded) = partition relayFailed rs + if null succeeded + then do + -- Updated group info (connLinkPreparedConnection) - in UI it would lock ability to change + -- user or incognito profile for group, in case server received request while client got network error + toView $ CEvtChatInfoUpdated user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) + -- Prefer throwing temporary network connection error to enable retry + case find isTempErr failed <|> listToMaybe failed of + Just (_, _, Left e) -> throwError e + _ -> throwChatError $ CEException "no relay connection results" -- shouldn't happen + else do + 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 + 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 + connectToRelay gInfo' relayLink = do + gVar <- asks random + -- Save relayLink to re-use relay member record on retry (check by relayLink) + relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo' relayLink + r <- tryAllErrors $ do + (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink + relayLinkData_ <- liftIO $ decodeLinkUserData cData + case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + _ -> throwChatError $ CEException "relay link: no relay link data or entity id" + let cReq = linkConnReq fd + relayLinkToConnect = CCLink cReq (Just relayLink) + void $ connectViaContact user (Just $ PCEGroup gInfo' relayMember) incognito relayLinkToConnect Nothing Nothing + -- Re-read member to get updated activeConn and updated data (from updateRelayMemberData) + relayMember' <- withFastStore $ \db -> getGroupMember db vr user groupId (groupMemberId' relayMember) + pure (relayLink, relayMember', r) + retryRelayConnectionAsync gInfo' relayLink relayMember@GroupMember {activeConn} = do + forM_ activeConn $ \conn -> do + deleteAgentConnectionAsync $ aConnId conn + withStore' $ \db -> deleteConnectionRecord db user (dbConnId conn) + subMode <- chatReadVar subscriptionMode + newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink + withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode + GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do + hostMember <- withFastStore $ \db -> getHostMember db vr user groupId msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) Nothing -> do @@ -1985,19 +2170,24 @@ 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 APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do + -- TODO [relays] member: /c api to support groups with relays + -- TODO - possibly by going through APIPrepareGroup -> APIConnectPreparedGroup (ccLink, plan) <- connectPlan user cLink `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq @@ -2006,7 +2196,7 @@ processChatCommand vr nm = \case ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do - (cReq, _cData) <- getShortLinkConnReq user sLnk + (FixedLinkData {linkConnReq = cReq}, _cData) <- getShortLinkConnReq nm user sLnk pure $ CCLink cReq $ Just sLnk Nothing -> throwCmdError "no address in contact profile" connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do @@ -2024,18 +2214,22 @@ processChatCommand vr nm = \case CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand vr nm $ APIListContacts userId - APICreateMyAddress userId -> withUserId userId $ \user -> do + APICreateMyAddress userId -> withUserId userId $ \user@User {userChatRelay} -> do withFastStore' (\db -> runExceptT $ getUserAddress db user) >>= \case Left SEUserContactLinkNotFound -> pure () Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode - let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing + -- TODO [relays] relay: add identity, key to link data? + let userData + | isTrue userChatRelay = relayShortLinkData (userProfileDirect user Nothing Nothing True) + | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink - withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode - pure $ CRUserContactLinkCreated user ccLink' + let ccLink'' = if isTrue userChatRelay then createdRelayLink ccLink' else ccLink' + withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode + pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress -> withUser $ \User {userId} -> processChatCommand vr nm $ APICreateMyAddress userId APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do @@ -2091,17 +2285,20 @@ processChatCommand vr nm = \case contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing + asGroup <- getSendAsGroup user toChatRef + processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user fromGroupName forwardedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing + asGroup <- getSendAsGroup user toChatRef + processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing + asGroup <- getSendAsGroup user toChatRef + processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg case sendName of @@ -2121,13 +2318,14 @@ processChatCommand vr nm = \case _ -> throwChatError $ CEContactNotFound name Nothing SNGroup name scope_ -> do - (gId, cScope_, mentions) <- withFastStore $ \db -> do - gId <- getGroupIdByName db user name + (gInfo, cScope_, mentions) <- withFastStore $ \db -> do + gInfo <- getGroupInfoByName db vr user name + let gId = groupId' gInfo cScope_ <- forM scope_ $ \(GSNMemberSupport mName_) -> GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ - (gId,cScope_,) <$> liftIO (getMessageMentions db user gId msg) - let sendRef = SRGroup gId cScope_ + (gInfo, cScope_,) <$> liftIO (getMessageMentions db user gId msg) + let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo) processChatCommand vr nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] SNLocal -> do folderId <- withFastStore (`getUserNoteFolderId` user) @@ -2154,7 +2352,7 @@ processChatCommand vr nm = \case processChatCommand vr nm $ APIAcceptMemberContact contactId SendLiveMessage chatName msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg - withSendRef chatRef $ \sendRef -> do + withSendRef user chatRef $ \sendRef -> do let mc = MCText msg processChatCommand vr nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast mc -> withUser $ \user -> do @@ -2186,8 +2384,8 @@ processChatCommand vr nm = \case addContactConn ct ctConns = case contactSendConn_ ct of Right conn | directOrUsed ct -> (ct, conn) : ctConns _ -> ctConns - ctSndEvent :: (Contact, Connection) -> (ConnOrGroupId, ChatMsgEvent 'Json) - ctSndEvent (_, Connection {connId}) = (ConnectionId connId, XMsgNew $ MCSimple (extMsgContent mc Nothing)) + ctSndEvent :: (Contact, Connection) -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) + ctSndEvent (_, Connection {connId}) = (ConnectionId connId, Nothing, XMsgNew $ MCSimple (extMsgContent mc Nothing)) ctMsgReq :: (Contact, Connection) -> SndMessage -> ChatMsgReq ctMsgReq (_, conn) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgNew_}, (vrValue msgBody, [msgId])) combineResults :: (Contact, Connection) -> Either ChatError SndMessage -> Either ChatError ([Int64], PQEncryption) -> Either ChatError (Contact, SndMessage) @@ -2196,7 +2394,7 @@ processChatCommand vr nm = \case combineResults _ _ (Left e) = Left e createCI :: DB.Connection -> User -> Bool -> UTCTime -> (Contact, SndMessage) -> IO () createCI db user hasLink createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) False sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg @@ -2223,25 +2421,70 @@ processChatCommand vr nm = \case chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg processChatCommand vr nm $ APIChatItemReaction chatRef chatItemId add reaction - APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do - checkValidName displayName - gVar <- asks random - -- [incognito] generate incognito profile for group membership - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - gInfo <- withFastStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile - let cd = CDGroupSnd gInfo Nothing - createInternalChatItem user cd CIChatBanner (Just epochStart) - createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing - createGroupFeatureItems user cd CISndGroupFeature gInfo + APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do + g <- asks random + memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12 + gInfo <- newGroup user incognito gProfile False memberId Nothing Nothing + createNewGroupItems user gInfo pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand vr nm $ APINewGroup userId incognito gProfile + APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do + (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user + gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) + (gLink, groupRelays) <- setupLink gInfo `catchAllErrors` \e -> do + deleteInProgressGroup user gInfo + throwError e + createNewGroupItems user gInfo + pure $ CRPublicGroupCreated user gInfo gLink groupRelays + where + prepareGroupLink :: User -> CM (GroupProfile, MemberId, GroupKeys, GroupInfo -> CM (GroupLink, [GroupRelay])) + prepareGroupLink user = do + gVar <- asks random + groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + subMode <- chatReadVar subscriptionMode + -- generate root key pair; entity ID = sha256(rootPubKey) — see docs/rfcs/2026-03-28-group-identity-binding.md + rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar + let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey + crClientData = encodeJSON $ CRDataGroup groupLinkId + -- prepare link with entityId as linkEntityId (no server request) + (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True (Just crClientData) + 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" + -- generate owner key, OwnerAuth signed by root key + memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12) + (memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey + let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId}} + userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)} + userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData} + -- create connection with prepared link (single network call) + connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOff subMode + let groupKeys = GroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} + setupLink gInfo = do + -- 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 - assertDirectAllowed user MDSnd contact XGrpInv_ let Group gInfo members = group Contact {localDisplayName = cName} = contact + when (useRelays' gInfo) $ throwCmdError "can't invite contact to channel" + assertDirectAllowed user MDSnd contact XGrpInv_ assertUserGroupRole gInfo $ max GRAdmin memRole -- [incognito] forbid to invite contact to whom user is connected incognito when (contactConnIncognito contact) $ throwChatError CEContactIncognitoCantInvite @@ -2371,7 +2614,7 @@ processChatCommand vr nm = \case pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do - -- TODO [channels fwd] possible optimization is to read only required members + relays + -- TODO [relays] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -2381,11 +2624,11 @@ processChatCommand vr nm = \case when anyPending $ throwCmdError "can't change role of members pending approval" assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems - (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems + (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g currentMems unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 unless (null errs) $ toView $ CEvtChatErrors errs - pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed + pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole, msgSigned} -- same order is not guaranteed where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) @@ -2416,19 +2659,20 @@ processChatCommand vr nm = \case withFastStore' $ \db -> updateGroupMemberRole db user m newRole pure (m :: GroupMember) {memberRole = newRole} _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of - Nothing -> pure ([], [], []) + Nothing -> pure ([], [], [], False) Just memsToChange' -> do let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' recipients = filter memberCurrent members - (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing recipients events - let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False + (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events + let signed = any (either (const False) (isJust . signedMsg_)) msgs_ + itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ - pure (errs, changed, acis) + pure (errs, changed, acis, signed) where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c sndItemData GroupMember {groupMemberId, memberProfile} msg = @@ -2440,10 +2684,10 @@ processChatCommand vr nm = \case pure (m :: GroupMember) {memberRole = newRole} APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do - -- TODO [channels fwd] possible optimization is to read only required members + relays + -- TODO [relays] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" - -- TODO [channels fwd] consider sending restriction to all members (remove filtering), as we do in delivery jobs + -- TODO [relays] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" @@ -2470,8 +2714,9 @@ processChatCommand vr nm = \case events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' recipients = filter memberCurrent remainingMems (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients events - let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False + let msgSigned = any (either (const False) (isJust . signedMsg_)) msgs_ + itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch" let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ unless (null acis) $ toView $ CEvtNewChatItems user acis @@ -2479,7 +2724,7 @@ processChatCommand vr nm = \case unless (null errs) $ toView $ CEvtChatErrors errs -- TODO not batched - requires agent batch api forM_ blocked $ \m -> toggleNtf m (not blockFlag) - pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag} + pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag, msgSigned} where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c sndItemData GroupMember {groupMemberId, memberProfile} msg = @@ -2488,7 +2733,7 @@ processChatCommand vr nm = \case in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do - -- TODO [channels fwd] possible optimization is to read only required members + relays + -- TODO [relays] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds @@ -2498,22 +2743,26 @@ processChatCommand vr nm = \case assertUserGroupRole gInfo $ max GRAdmin maxRole (errs1, deleted1) <- deleteInvitedMems user invitedMems let recipients = filter memberCurrent members - (errs2, deleted2, acis2) <- deleteMemsSend user gInfo Nothing recipients currentMems - (errs3, deleted3, acis3) <- - foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], []) pendingApprvMems + (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing recipients currentMems + (errs3, deleted3, acis3, signed3) <- + foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members - (errs4, deleted4, acis4) <- - foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], []) pendingRvwMems + (errs4, deleted4, acis4, signed4) <- + foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems let acis = acis2 <> acis3 <> acis4 errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 - -- Read group info with updated membersRequireAttention - gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId + msgSigned = signed2 || signed3 || signed4 + -- Read group info with updated membersRequireAttention and publicMemberCount + gInfo' <- + if useRelays' gInfo + then updatePublicGroupData user gInfo + else withFastStore $ \db -> getGroupInfo db vr user groupId let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' unless (null errs) $ toView $ CEvtChatErrors errs when withMessages $ deleteMessages user gInfo' deleted - pure $ CRUserDeletedMembers user gInfo' deleted withMessages -- same order is not guaranteed + pure $ CRUserDeletedMembers user gInfo' deleted withMessages msgSigned -- same order is not guaranteed where selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False) @@ -2537,29 +2786,30 @@ processChatCommand vr nm = \case delMember db m = do deleteGroupMember db user m pure m {memberStatus = GSMemRemoved} - deletePendingMember :: ([ChatError], [GroupMember], [AChatItem]) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem]) - deletePendingMember (accErrs, accDeleted, accACIs) user gInfo recipients m = do + deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do (m', scopeInfo) <- mkMemberSupportChatInfo m - (errs, deleted, acis) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] - pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs) - deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] + pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed) + deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of - Nothing -> pure ([], [], []) + Nothing -> pure ([], [], [], False) Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' - (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope recipients events - let itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) + (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events + let signed = any (either (const False) (isJust . signedMsg_)) msgs_ + itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) skipUnwantedItem = \case Right Nothing -> Nothing Right (Just a) -> Just $ Right a Left e -> Just $ Left e itemsData = mapMaybe skipUnwantedItem itemsData_ - cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) False itemsData Nothing False deleteMembersConnections' user memsToDelete True (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) $ rights cis_ - pure (errs, deleted, acis) + pure (errs, deleted, acis, signed) where sndItemData :: GroupMember -> SndMessage -> Maybe (NewSndChatItemData c) sndItemData GroupMember {groupMemberId, memberProfile, memberStatus} msg @@ -2595,9 +2845,9 @@ processChatCommand vr nm = \case withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} where - getRecipients user gInfo@GroupInfo {useRelays} - | isTrue useRelays = do - relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + getRecipients user gInfo + | useRelays' gInfo = do + relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo pure (relays, relays) | otherwise = do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo @@ -2665,7 +2915,7 @@ processChatCommand vr nm = \case when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode - let userData = encodeShortLinkData $ GroupShortLinkData groupProfile + let userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = Nothing} userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} crClientData = encodeJSON $ CRDataGroup groupLinkId (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) IKPQOff subMode @@ -2785,13 +3035,14 @@ processChatCommand vr nm = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do - (groupId, quotedItemId, mentions) <- + (gInfo, quotedItemId, mentions) <- withFastStore $ \db -> do - gId <- getGroupIdByName db user gName + gInfo <- getGroupInfoByName db vr user gName + let gId = groupId' gInfo qiId <- getGroupChatItemIdByText db user gId cName quotedMsg - (gId, qiId,) <$> liftIO (getMessageMentions db user gId msg) + (gInfo, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRGroup groupId Nothing) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + processChatCommand vr nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand vr nm $ APIClearChat (ChatRef CTLocal folderId Nothing) @@ -2832,10 +3083,10 @@ processChatCommand vr nm = \case chatRef <- getChatRef user chatName case chatRef of ChatRef CTLocal folderId _ -> processChatCommand vr nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] - _ -> withSendRef chatRef $ \sendRef -> processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] + _ -> withSendRef user chatRef $ \sendRef -> processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName - withSendRef chatRef $ \sendRef -> do + withSendRef user chatRef $ \sendRef -> do filePath <- lift $ toFSFilePath fPath unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath @@ -3066,6 +3317,9 @@ processChatCommand vr nm = \case | otherwise -> throwCmdError "not supported" _ -> throwCmdError "not supported" pure $ ChatRef cType chatId Nothing + getSendAsGroup :: User -> ChatRef -> CM ShowGroupAsSender + getSendAsGroup user' (ChatRef CTGroup chatId _) = sendAsGroup' <$> withFastStore (\db -> getGroupInfo db vr user' chatId) + getSendAsGroup _ _ = pure False getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) getChatRefAndMentions user cName msg = do chatRef@(ChatRef cType chatId _) <- getChatRef user cName @@ -3157,15 +3411,15 @@ processChatCommand vr nm = \case when (isJust msg_ && isJust groupLinkId) $ throwChatError CEConnReqMessageProhibited case preparedEntity_ of Just (PCEContact ct@Contact {activeConn}) -> case activeConn of - Nothing -> connect' Nothing Nothing + Nothing -> connect' Nothing Nothing Nothing Just conn@Connection {connStatus, xContactId} -> case connStatus of ConnPrepared -> joinPreparedConn' xContactId conn Nothing _ -> pure $ CVRConnectedContact ct Just (PCEGroup gInfo GroupMember {activeConn}) -> case activeConn of - Nothing -> connect' groupLinkId Nothing + Nothing -> connect' groupLinkId Nothing (Just $ Just gInfo) Just conn@Connection {connStatus, xContactId} -> case connStatus of - ConnPrepared -> joinPreparedConn' xContactId conn $ Just (Just gInfo) - _ -> connect' groupLinkId xContactId -- why not "already connected" for host member? + ConnPrepared -> joinPreparedConn' xContactId conn (Just $ Just gInfo) + _ -> connect' groupLinkId xContactId (Just $ Just gInfo) -- why not "already connected" for host member? Nothing -> withFastStore' (\db -> getConnReqContactXContactId db vr user cReqHash1 cReqHash2) >>= \case Right ct@Contact {activeConn} -> case groupLinkId of @@ -3175,35 +3429,38 @@ processChatCommand vr nm = \case Just gLinkId -> -- allow repeat contact request -- TODO [short links] is this branch needed? it probably remained from the time we created host contact - connect' (Just gLinkId) Nothing + connect' (Just gLinkId) Nothing (Just Nothing) Left conn_ -> case conn_ of - Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn $ groupLinkId $> Nothing + Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn (groupLinkId $> Nothing) -- TODO [short links] this is executed on repeat request after success -- it probably should send the second message without creating the second connection? - Just Connection {xContactId} -> connect' groupLinkId xContactId - Nothing -> connect' groupLinkId Nothing + Just Connection {xContactId} -> connect' groupLinkId xContactId (groupLinkId $> Nothing) + Nothing -> connect' groupLinkId Nothing (groupLinkId $> Nothing) where - cReqHash = ConnReqUriHash . C.sha256Hash . strEncode - cReqHash1 = cReqHash $ CRContactUri crData {crScheme = SSSimplex} - cReqHash2 = cReqHash $ CRContactUri crData {crScheme = simplexChat} + cReqHash1 = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} + cReqHash2 = contactCReqHash $ CRContactUri crData {crScheme = simplexChat} joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" + -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile - connect' groupLinkId xContactId_ = do + connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId pqSup = if inGroup then PQSupportOff else PQSupportOn (connId, chatV) <- prepareContact user cReq pqSup xContactId <- mkXContactId xContactId_ - -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + -- [incognito] generate profile to send, or use membership profile for relay groups + incognitoProfile_ <- case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo + _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk - conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ (groupLinkId $> Nothing) pqSup + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3217,7 +3474,7 @@ processChatCommand vr nm = \case incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId incognitoProfile Nothing subMode chatV pqSup + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile @@ -3252,7 +3509,15 @@ processChatCommand vr nm = \case let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True - dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_) + chatEvent <- case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> do + let GroupInfo {membership = GroupMember {memberId}} = gInfo + memberPubKey <- case groupKeys gInfo of + Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey + Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" + pure $ XMember profileToSend memberId (MemberKey memberPubKey) + _ -> pure $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ + dm <- encodeConnInfoPQ pqSup chatV chatEvent subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined @@ -3319,18 +3584,20 @@ processChatCommand vr nm = \case mergedProfile = userProfileDirect user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileDirect user' Nothing (Just ct') False - ctSndEvent :: ChangedProfileContact -> (ConnOrGroupId, ChatMsgEvent 'Json) - ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, XInfo mergedProfile') + ctSndEvent :: ChangedProfileContact -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) + ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, Nothing, XInfo mergedProfile') ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) setMyAddressData :: User -> UserContactLink -> CM UserContactLink - setMyAddressData user ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do + setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db vr user let shortLinkProfile = userProfileDirect user Nothing Nothing True -- TODO [short links] do not save address to server if data did not change, spinners, error handling - userData = contactShortLinkData shortLinkProfile $ Just addressSettings + userData + | isTrue userChatRelay = relayShortLinkData shortLinkProfile + | otherwise = contactShortLinkData shortLinkProfile $ Just addressSettings userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} sLnk <- shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData Nothing) withFastStore' $ \db -> setUserContactLinkShortLink db userContactLinkId sLnk @@ -3372,12 +3639,12 @@ processChatCommand vr nm = \case recipients = filter memberCurrentOrPending newMs sendGroupMessage user gInfo' Nothing recipients $ XGrpPrefs ps' Nothing -> do - setGroupLinkData' nm user gInfo' + void $ setGroupLinkData' nm user gInfo' recipients <- getRecipients sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') where getRecipients - | isTrue (useRelays gInfo') = withFastStore' $ \db -> getGroupRelays db vr user gInfo' + | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo' | otherwise = do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo' pure $ filter memberCurrentOrPending ms @@ -3386,7 +3653,7 @@ processChatCommand vr nm = \case ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' Nothing) ci] createGroupFeatureChangedItems user cd CISndGroupFeature gInfo gInfo' - pure $ CRGroupUpdated user gInfo gInfo' Nothing + pure $ CRGroupUpdated user gInfo gInfo' Nothing (isJust $ signedMsg_ msg) checkValidName :: GroupName -> CM () checkValidName displayName = do when (T.null displayName) $ throwChatError CEInvalidDisplayName {displayName, validName = ""} @@ -3394,8 +3661,7 @@ processChatCommand vr nm = \case when (displayName /= validName) $ throwChatError CEInvalidDisplayName {displayName, validName} assertUserGroupRole :: GroupInfo -> GroupMemberRole -> CM () assertUserGroupRole g@GroupInfo {membership} requiredRole = do - let GroupMember {memberRole = membershipMemRole} = membership - when (membershipMemRole < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole + when (memberRole' membership < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive @@ -3404,27 +3670,29 @@ processChatCommand vr nm = \case assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId) $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds + events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds mapM_ (sendGroupMessages_ user gInfo ms) events delGroupChatItems user gInfo chatScopeInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () - assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items' = + assertDeletable GroupInfo {membership} items' = unless (all itemDeletable items') $ throwChatError CEInvalidChatItemDelete where itemDeletable :: CChatItem 'CTGroup -> Bool itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = case chatDir of - CIGroupRcv GroupMember {memberRole} -> membershipMemRole >= memberRole && isJust itemSharedMsgId + CIGroupRcv GroupMember {memberRole} -> memberRole' membership >= memberRole && isJust itemSharedMsgId CIGroupSnd -> isJust itemSharedMsgId - itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] + CIChannelRcv -> memberRole' membership == GROwner && isJust itemSharedMsgId + itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, Maybe MemberId)] itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds where - itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, MemberId) + itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, Maybe MemberId) itemMsgMemIds (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = join <$> forM itemSharedMsgId $ \msgId -> Just $ case chatDir of - CIGroupRcv GroupMember {memberId} -> (msgId, memberId) - CIGroupSnd -> (msgId, membershipMemId) + CIGroupRcv GroupMember {memberId} -> (msgId, Just memberId) + CIGroupSnd -> (msgId, Just membershipMemId) + CIChannelRcv -> (msgId, Nothing) delGroupChatItems :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Bool -> CM [ChatItemDeletion] delGroupChatItems user gInfo@GroupInfo {membership} chatScopeInfo items moderation = do @@ -3466,7 +3734,7 @@ processChatCommand vr nm = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p - validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError] + validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM ([UserServersError], [UserServersWarning]) validateAllUsersServers currUserId userServers = withFastStore $ \db -> do users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db) others <- mapM (getUserOperatorServers db) users' @@ -3494,6 +3762,18 @@ processChatCommand vr nm = \case groupId <- getGroupIdByName db user gName groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) + newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> CM GroupInfo + newGroup user incognito gProfile@GroupProfile {displayName} useRelays memberId groupKeys_ publicMemberCount_ = do + checkValidName displayName + -- [incognito] generate incognito profile for group membership + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + withFastStore $ \db -> createNewGroup db vr user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ + createNewGroupItems :: User -> GroupInfo -> CM () + createNewGroupItems user gInfo = do + let cd = CDGroupSnd gInfo Nothing + createInternalChatItem user cd CIChatBanner (Just epochStart) + createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + createGroupFeatureItems user cd CISndGroupFeature gInfo sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo @@ -3515,8 +3795,44 @@ processChatCommand vr nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) - drgRandomBytes :: Int -> CM ByteString - drgRandomBytes n = asks random >>= atomically . C.randomBytes n + addRelays :: User -> GroupInfo -> ShortLinkContact -> [UserChatRelay] -> CM [GroupRelay] + addRelays user gInfo@GroupInfo {membership} groupSLink relays = + mapConcurrently addRelay relays + where + addRelay :: UserChatRelay -> CM GroupRelay + addRelay relay@UserChatRelay {address} = do + -- TODO [relays] owner: track and reuse relay profiles + -- TODO - single profile linked to relay configuration record (chat_relays) + -- TODO - update when fetching link data from relay address + (FixedLinkData {linkConnReq = cReq}, _cData) <- getShortLinkConnReq nm user address + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + gVar <- asks random + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (relayMember, conn, groupRelay) <- withFastStore $ \db -> do + relayMember <- createRelayForOwner db vr gVar user gInfo relay + groupRelay <- createGroupRelayRecord db gInfo relayMember relay + conn <- createRelayConnection db vr user (groupMemberId' relayMember) connId ConnPrepared chatV subMode + pure (relayMember, conn, groupRelay) + let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + GroupMember {memberId = relayMemberId} = relayMember + relayInv = GroupRelayInvitation { + fromMember = MemberIdRole userMemberId userRole, + fromMemberProfile = membershipProfile, + relayMemberId, + groupLink = groupSLink + } + dm <- encodeConnInfo $ XGrpRelayInv relayInv + sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode + let newConnStatus = if sqSecured then ConnSndReady else ConnJoined + withFastStore' $ \db -> do + void $ updateConnectionStatusFromTo db conn ConnPrepared newConnStatus + updateRelayStatusFromTo db groupRelay RSNew RSInvited privateGetUser :: UserId -> CM User privateGetUser userId = tryAllErrors (withStore (`getUser` userId)) >>= \case @@ -3588,8 +3904,8 @@ processChatCommand vr nm = \case knownLinkPlans l' >>= \case Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq user l' - contactSLinkData_ <- liftIO $ decodeShortLinkData cData + (FixedLinkData {linkConnReq = cReq}, cData) <- getShortLinkConnReq nm user l' + contactSLinkData_ <- liftIO $ decodeLinkUserData cData invitationReqAndPlan cReq (Just l') contactSLinkData_ where knownLinkPlans l' = withFastStore $ \db -> do @@ -3605,20 +3921,17 @@ 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 Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq user l' + (FixedLinkData {linkConnReq = cReq}, cData) <- getShortLinkConnReq nm user l' withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do - contactSLinkData_ <- liftIO $ decodeShortLinkData cData + contactSLinkData_ <- liftIO $ decodeLinkUserData cData plan <- contactRequestPlan user cReq contactSLinkData_ pure (con cReq, plan) where @@ -3629,21 +3942,36 @@ 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 - (cReq, cData) <- getShortLinkConnReq user l' - groupSLinkData_ <- liftIO $ decodeShortLinkData cData - plan <- groupJoinRequestPlan user cReq groupSLinkData_ + (fd, cData@(ContactLinkData _ UserContactData {direct, relays})) <- getShortLinkConnReq nm user l' + let FixedLinkData {linkConnReq = cReq, linkEntityId} = fd + linkInfo = GroupShortLinkInfo {direct, groupRelays = relays, publicGroupId = B64UrlByteString <$> linkEntityId} + groupSLinkData_ <- liftIO $ decodeLinkUserData cData + -- Cross-validate linkEntityId and publicGroupId from profile: + -- for channels both must be present and match, for p2p groups both must be absent + let profilePGId = groupSLinkData_ >>= \GroupShortLinkData {groupProfile = GroupProfile {publicGroup}} -> + fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId) publicGroup + case (B64UrlByteString <$> linkEntityId, profilePGId) of + (Just entityId, Just publicGroupId) | entityId == publicGroupId -> pure () + (Nothing, Nothing) -> pure () + _ -> throwChatError CEInvalidConnReq + plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ pure (con cReq, plan) where knownLinkPlans = withFastStore $ \db -> 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 @@ -3680,7 +4008,7 @@ processChatCommand vr nm = \case groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli case groupLinkId of Nothing -> contactRequestPlan user cReq Nothing - Just _ -> groupJoinRequestPlan user cReq Nothing + Just _ -> groupJoinRequestPlan user cReq Nothing Nothing contactRequestPlan :: User -> ConnReqContact -> Maybe ContactShortLinkData -> CM ConnectionPlan contactRequestPlan user (CRContactUri crData) contactSLinkData_ = do let cReqSchemas = contactCReqSchemas crData @@ -3701,10 +4029,10 @@ processChatCommand vr nm = \case | contactDeleted ct -> pure $ CPContactAddress (CAPOk contactSLinkData_) | otherwise -> pure $ CPContactAddress (CAPKnown ct) -- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?) - Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing + Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing Nothing Just _ -> throwCmdError "found connection entity is not RcvDirectMsgConnection or RcvGroupMsgConnection" - groupJoinRequestPlan :: User -> ConnReqContact -> Maybe GroupShortLinkData -> CM ConnectionPlan - groupJoinRequestPlan user (CRContactUri crData) groupSLinkData_ = do + groupJoinRequestPlan :: User -> ConnReqContact -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> CM ConnectionPlan + groupJoinRequestPlan user (CRContactUri crData) groupSLinkInfo_ groupSLinkData_ = do let cReqSchemas = contactCReqSchemas crData cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case @@ -3713,49 +4041,41 @@ processChatCommand vr nm = \case connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db vr user cReqHashes gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db vr user cReqHashes case (gInfo_, connEnt_) of - (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk groupSLinkData_) + (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk groupSLinkInfo_ groupSLinkData_) -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) - | otherwise -> pure $ CPGroupLink (GLPOk groupSLinkData_) + | otherwise -> pure $ CPGroupLink (GLPOk groupSLinkInfo_ groupSLinkData_) (Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection" - (Just gInfo, _) -> groupPlan gInfo groupSLinkData_ - groupPlan :: GroupInfo -> Maybe GroupShortLinkData -> CM ConnectionPlan - groupPlan gInfo@GroupInfo {membership} groupSLinkData_ + (Just gInfo, _) -> groupPlan gInfo groupSLinkInfo_ groupSLinkData_ + groupPlan :: GroupInfo -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> CM ConnectionPlan + groupPlan gInfo@GroupInfo {membership} groupSLinkInfo_ groupSLinkData_ | memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo) | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo) - | otherwise = pure $ CPGroupLink (GLPOk groupSLinkData_) + | otherwise = pure $ CPGroupLink (GLPOk groupSLinkInfo_ groupSLinkData_) contactCReqSchemas :: ConnReqUriData -> (ConnReqContact, ConnReqContact) contactCReqSchemas crData = ( CRContactUri crData {crScheme = SSSimplex}, CRContactUri crData {crScheme = simplexChat} ) - contactCReqHash :: ConnReqContact -> ConnReqUriHash - contactCReqHash = ConnReqUriHash . C.sha256Hash . strEncode - getShortLinkConnReq :: User -> ConnShortLink m -> CM (ConnectionRequestUri m, ConnLinkData m) - getShortLinkConnReq user l = do - l' <- restoreShortLink' l - (FixedLinkData {linkConnReq = cReq}, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l' - case cData of - ContactLinkData _ UserContactData {direct} | not direct -> throwChatError CEUnsupportedConnReq - _ -> pure () - pure (cReq, cData) -- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser, -- and short links are stored with server hostname schema, so they wouldn't match without it. serverShortLink :: ConnShortLink m -> ConnShortLink m serverShortLink = \case CSLInvitation _ srv lnkId linkKey -> CSLInvitation SLSServer srv lnkId linkKey CSLContact _ ct srv linkKey -> CSLContact SLSServer ct srv linkKey - restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) contactShortLinkData :: Profile -> Maybe AddressSettings -> UserLinkData contactShortLinkData p settings = let msg = autoReply =<< settings business = maybe False businessAddress settings contactData = ContactShortLinkData p msg business in encodeShortLinkData contactData + relayShortLinkData :: Profile -> UserLinkData + relayShortLinkData Profile {displayName, fullName, shortDescr, image} = + encodeShortLinkData $ RelayAddressLinkData {relayProfile = RelayProfile {displayName, fullName, shortDescr, image}} updatePCCShortLinkData :: PendingContactConnection -> Profile -> CM (Maybe ShortLinkInvitation) updatePCCShortLinkData conn@PendingContactConnection {connLinkInv} profile = forM (connShortLink =<< connLinkInv) $ \_ -> do @@ -3804,7 +4124,7 @@ processChatCommand vr nm = \case msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" - r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live + r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) False itemsData timed_ live processSendErrs r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> @@ -3823,8 +4143,8 @@ processChatCommand vr nm = \case prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do case (quotedItemId, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing Nothing), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing Nothing), Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId @@ -3832,7 +4152,7 @@ processChatCommand vr nm = \case let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Just quotedItem) + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing Nothing), Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) @@ -3840,17 +4160,17 @@ processChatCommand vr nm = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo scope live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL cmrs = do assertMultiSendable live cmrs chatScopeInfo <- mapM (getChatScopeInfo vr user) scope recipients <- getGroupRecipients vr user gInfo chatScopeInfo modsCompatVersion - sendGroupContentMessages_ user gInfo scope chatScopeInfo recipients live itemTTL cmrs + sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion - sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope chatScopeInfo recipients live itemTTL cmrs = do + sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs = do forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages @@ -3875,13 +4195,13 @@ processChatCommand vr nm = \case Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do - -- TODO [channels fwd] single description for all recipients + -- TODO [relays] single description for all recipients (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo Nothing recipients chatMsgEvents + (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData timed_ live + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ @@ -3904,7 +4224,7 @@ processChatCommand vr nm = \case forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, ciMentions), fInv_) -> let msgScope = toMsgScope gInfo <$> chatScopeInfo mentions = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions - in prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId itemForwarded fInv_ timed_ live + in prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -4053,10 +4373,12 @@ processChatCommand vr nm = \case getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (\a -> getConnectionQueueInfo a nm acId) - withSendRef :: ChatRef -> (SendRef -> CM ChatResponse) -> CM ChatResponse - withSendRef chatRef a = case chatRef of + withSendRef :: User -> ChatRef -> (SendRef -> CM ChatResponse) -> CM ChatResponse + withSendRef user chatRef a = case chatRef of ChatRef CTDirect cId _ -> a $ SRDirect cId - ChatRef CTGroup gId scope -> a $ SRGroup gId scope + ChatRef CTGroup gId scope -> do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + a $ SRGroup gId scope (sendAsGroup' gInfo) _ -> throwCmdError "not supported" getSharedMsgId :: CM SharedMsgId getSharedMsgId = do @@ -4067,23 +4389,38 @@ data ConnectViaContactResult = CVRConnectedContact Contact | CVRSentInvitation Connection (Maybe Profile) -protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -protocolServers p (operators, smpServers, xftpServers) = case p of - SPSMP -> (operators, smpServers, []) - SPXFTP -> (operators, [], xftpServers) +onlyProtocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) +onlyProtocolServers p (operators, smpServers, xftpServers, _chatRelays) = case p of + SPSMP -> (operators, smpServers, [], []) + SPXFTP -> (operators, [], xftpServers, []) -- disable preset and replace custom servers (groupByOperator always adds custom) updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers -updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of - SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) - SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) +updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = case p' of + SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers, map (AUCR SDBStored) chatRelays) + SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers, map (AUCR SDBStored) chatRelays) where - u = uncurry $ UpdatedUserOperatorServers operator + u = uncurry3 $ UpdatedUserOperatorServers operator + uncurry3 :: (a -> b -> c -> d) -> ((a, b, c) -> d) + uncurry3 f ~(a,b,c) = f a b c updateSrvs :: [UserServer p] -> [AUserServer p] updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} +onlyRelays :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) +onlyRelays (operators, _smpServers, _xftpServers, chatRelays) = (operators, [], [], chatRelays) + +-- disable preset and replace custom chat relays (groupByOperator always adds custom) +updatedRelays :: [AUserChatRelay] -> UserOperatorServers -> UpdatedUserOperatorServers +updatedRelays relays UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = + UpdatedUserOperatorServers operator (map (AUS SDBStored) smpServers) (map (AUS SDBStored) xftpServers) (updateRelays chatRelays) + where + updateRelays :: [UserChatRelay] -> [AUserChatRelay] + updateRelays pRelays = map disableRelay pRelays <> maybe relays (const []) operator + disableRelay relay@UserChatRelay {preset} = + AUCR SDBStored $ if preset then relay {enabled = False} else relay {deleted = True} + type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage @@ -4221,6 +4558,10 @@ cleanupManager = do -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted cleanupDeletedContacts user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay + cleanupInProgressGroups user `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay + cleanupStaleRelayTestConns user `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts @@ -4232,6 +4573,21 @@ cleanupManager = do forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) `catchAllErrors` eToView + cleanupInProgressGroups user = do + vr <- chatVersionRange + ts <- liftIO getCurrentTime + -- older than 30 minutes to avoid deleting a newly created group + let cutoffTs = addUTCTime (- 1800) ts + inProgressGroups <- withStore' $ \db -> getInProgressGroups db vr user cutoffTs + forM_ inProgressGroups $ \gInfo -> + deleteInProgressGroup user gInfo `catchAllErrors` eToView + cleanupStaleRelayTestConns user = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-300) ts + staleConns <- withStore' $ \db -> getStaleRelayTestConns db user cutoffTs + forM_ staleConns $ \acId -> do + deleteAgentConnectionAsync acId + withStore' $ \db -> deleteConnectionByAgentConnId db user acId cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -4249,6 +4605,20 @@ cleanupManager = do let cutoffTs = addUTCTime (-(14 * nominalDay)) ts withStore' (`deleteOldProbes` cutoffTs) +deleteInProgressGroup :: User -> GroupInfo -> CM () +deleteInProgressGroup user gInfo = do + deleteGroupLinkIfExists user gInfo + deleteGroupConnections user gInfo False + withFastStore' $ \db -> deleteGroup db user gInfo + +runRelayGroupLinkChecks :: User -> CM () +runRelayGroupLinkChecks _user = do + -- TODO [relays] relay: periodically check presence of relay link in group links of served groups + -- TODO - retrieve group link data + -- TODO - if relay link is present, update relay status to RSActive + -- TODO - if relay link is absent and status was RSActive -> update to new "Removed" status? + pure () + expireChatItems :: User -> Int64 -> Bool -> CM () expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime @@ -4318,7 +4688,8 @@ chatCommandP = "/block #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), "/unblock #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), - "/create user " *> (CreateActiveUser <$> newUserP), + "/create user " *> (CreateActiveUser <$> newUserP False), + "/create chat relay user " *> (CreateActiveUser <$> newUserP True), "/create bot " *> (CreateActiveUser <$> newBotUserP), "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), @@ -4408,7 +4779,7 @@ chatCommandP = "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> (knownReaction <$?> jsonP)), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> (knownReaction <$?> jsonP)), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), - "/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), + "/_forward " *> (APIForwardChatItems <$> chatRefP <*> (" as_group=" *> onOffP <|> pure False) <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), "/_read user " *> (APIUserRead <$> A.decimal), "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP), @@ -4462,6 +4833,10 @@ chatCommandP = "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), + "/_relay test " *> (APITestChatRelay <$> A.decimal <* A.space <*> strP), + "/relay test " *> (TestChatRelay <$> strP), + "/relays " *> (SetUserChatRelays <$> chatRelaysP), + "/relays" $> GetUserChatRelays, "/_operators" $> APIGetServerOperators, "/_operators " *> (APISetServerOperators <$> jsonP), "/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','), @@ -4488,6 +4863,7 @@ chatCommandP = "/_member settings #" *> (APISetMemberSettings <$> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), + "/_get group link data #" *> (APIGetUpdatedGroupLinkData <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayNameP), @@ -4532,6 +4908,9 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_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)), @@ -4574,7 +4953,7 @@ chatCommandP = "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing)), "/_prepare contact " *> (APIPrepareContact <$> A.decimal <* A.space <*> connLinkP <* A.space <*> jsonP), - "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <* A.space <*> jsonP), + "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <*> (" direct=" *> onOffP <|> pure True) <* A.space <*> jsonP), "/_set contact user @" *> (APIChangePreparedContactUser <$> A.decimal <* A.space <*> A.decimal), "/_set group user #" *> (APIChangePreparedGroupUser <$> A.decimal <* A.space <*> A.decimal), "/_connect contact @" *> (APIConnectPreparedContact <$> A.decimal <*> incognitoOnOffP <*> optional (A.space *> msgContentP)), @@ -4771,11 +5150,11 @@ chatCommandP = k : ws -> pure (k, if null ws then Nothing else Just $ T.unwords ws) pure CBCCommand {label, keyword, params} quoted = A.char '\'' *> A.takeTill (== '\'') <* A.char '\'' - newUserP = do + newUserP relay = do (cName, shortDescr) <- profileNameDescr service <- (" service=" *> onOffP) <|> pure False let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} - pure NewUser {profile, pastTimestamp = False, clientService = BoolDef service} + pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space service <- ("service=" *> onOffP <* A.space) <|> pure False @@ -4784,7 +5163,7 @@ chatCommandP = Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences} - pure NewUser {profile, pastTimestamp = False, clientService = BoolDef service} + pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString groupProfile = do @@ -4795,7 +5174,7 @@ chatCommandP = { directMessages = Just DirectMessagesGroupPreference {enable = FEOn, role = Nothing}, history = Just HistoryGroupPreference {enable = FEOn} } - pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, groupPreferences, memberAdmission = Nothing} + pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences, memberAdmission = Nothing} memberCriteriaP = ("all" $> Just MCAll) <|> ("off" $> Nothing) shortDescrP = do descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure "" @@ -4835,7 +5214,8 @@ chatCommandP = cType -> (\chatId -> ChatRef cType chatId Nothing) <$> A.decimal sendRefP = (A.char '@' $> SRDirect <*> A.decimal) - <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) + asGroupP = ("(as_group=" *> onOffP <* A.char ')') <|> pure False gcScopeP = "(_support" *> (GCSMemberSupport <$> optional (A.char ':' *> A.decimal)) <* A.char ')' sendNameP = (A.char '@' $> SNDirect <*> displayNameP) @@ -4876,6 +5256,11 @@ chatCommandP = optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP) <|> ("yes" $> TMEEnableKeepTTL) <|> ("no" $> TMEDisableKeepTTL) + chatRelaysP = chatRelayP `A.sepBy1` A.char ' ' + chatRelayP = do + name <- "name=" *> text1P + address <- _strP + pure CLINewRelay {name, address} operatorRolesP = do operatorId' <- A.decimal enabled' <- A.char ':' *> onOffP diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f7b8cd3b6e..83abdcf871 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -58,7 +58,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages) +import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators @@ -90,10 +90,12 @@ import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..)) import Simplex.Messaging.Compression (compressionLevel) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (MsgBody, MsgFlags (..), ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer) import qualified Simplex.Messaging.Protocol as SMP @@ -199,30 +201,33 @@ toggleNtf m ntfOn = forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchAllErrors` eToView -prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> ShowGroupAsSender -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of (Nothing, Nothing) -> - let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope + let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope (justTrue showGroupAsSender) in pure (XMsgNew mc', Nothing) (Nothing, Just _) -> - let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope + let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope (justTrue showGroupAsSender) in pure (XMsgNew mc', Nothing) (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId - (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership - let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + (origQmc, qd, sent, member_) <- quoteData qci membership + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = memberId' <$> member_} qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} - mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope) + mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope (justTrue showGroupAsSender)) pure (XMsgNew mc', Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, Maybe GroupMember) quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwError SEInvalidQuote - quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') - quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) + quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc, meta = CIMeta {showGroupAsSender = sentAsGroup}} membership' + | sentAsGroup = pure (qmc, CIQGroupSnd, True, Nothing) + | otherwise = pure (qmc, CIQGroupSnd, True, Just membership') + quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, Just m) + quoteData ChatItem {chatDir = CIChannelRcv, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv Nothing, False, Nothing) quoteData _ _ = throwError SEInvalidQuote updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) @@ -917,7 +922,7 @@ acceptContactRequestAsync liftIO $ setCommandConnId db user cmdId connId getContact db vr user contactId -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember acceptGroupJoinRequestAsync user uclId @@ -926,14 +931,16 @@ acceptGroupJoinRequestAsync cReqChatVRange cReqProfile cReqXContactId_ + cReqMemberId_ welcomeMsgId_ gAccepted gLinkMemRole - incognitoProfile = do + incognitoProfile + memberKey_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus + createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -968,7 +975,7 @@ acceptGroupJoinSendRejectAsync rejectionReason = do gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing GRObserver GSMemRejected + createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -1024,9 +1031,29 @@ acceptBusinessJoinRequestAsync -- TODO [short links] get updated business chat group and member? (currently not used) pure (gInfo, clientMember) +acceptRelayJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> InvitationId -> VersionRangeChat -> ShortLinkContact -> CM (GroupInfo, GroupMember) +acceptRelayJoinRequestAsync + user + uclId + gInfo + _ownerMember@GroupMember {groupMemberId} + cReqInvId + cReqChatVRange + relayLink = do + let msg = XGrpRelayAcpt relayLink + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` cReqChatVRange + connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV + withStore $ \db -> do + liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode + gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted + ownerMember' <- getGroupMemberById db vr user groupMemberId + pure (gInfo', ownerMember') + businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = - GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, groupPreferences = Just groupPreferences, memberAdmission = Nothing} + GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do @@ -1076,11 +1103,11 @@ introduceMember user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn shuffledReMembers <- liftIO $ shuffleMembers reMembers if toMember `supportsVersion` batchSendVersion then do - let events = map memberIntro shuffledReMembers + let events = map (memberIntroEvt gInfo) shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> - sendGroupMemberMessages user conn events' groupId + sendGroupMemberMessages user gInfo conn events' else forM_ shuffledReMembers $ \reMember -> - void $ sendDirectMemberMessage conn (memberIntro reMember) groupId + void $ sendDirectMemberMessage conn (memberIntroEvt gInfo reMember) groupId updateToMemberVector :: [GroupMember] -> CM () updateToMemberVector reMembers = do let relations = map (\GroupMember {indexInGroup} -> (indexInGroup, (IDReferencedIntroduced, MRIntroduced))) reMembers @@ -1089,11 +1116,6 @@ introduceMember user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn updateReMembersVectors reMembers = do let GroupMember {indexInGroup} = toMember withStore' $ \db -> setMembersVectorsNewRelation db reMembers indexInGroup IDSubjectIntroduced MRIntroduced - memberIntro :: GroupMember -> ChatMsgEvent 'Json - memberIntro reMember = - let mInfo = memberInfo gInfo reMember - mRestrictions = memberRestrictions reMember - in XGrpMemIntro mInfo mRestrictions shuffleMembers :: [GroupMember] -> IO [GroupMember] shuffleMembers reMembers = do let (admins, others) = partition isAdmin reMembers @@ -1104,6 +1126,24 @@ introduceMember user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn isAdmin GroupMember {memberRole} = memberRole >= GRAdmin hasPicture GroupMember {memberProfile = LocalProfile {image}} = isJust image +memberIntroEvt :: GroupInfo -> GroupMember -> ChatMsgEvent 'Json +memberIntroEvt gInfo reMember = + let mInfo = memberInfo gInfo reMember + mRestrictions = memberRestrictions reMember + in XGrpMemIntro mInfo mRestrictions + +-- Used in groups with relays to introduce moderators and above to a new member, +-- and to announce the new member to moderators and above. +-- This doesn't create introduction records in db, compared to above methods. +introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" +introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing + let introEvts = map (memberIntroEvt gInfo) modMs + forM_ (L.nonEmpty introEvts) $ \introEvts' -> + sendGroupMemberMessages user gInfo conn introEvts' + userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks {-# INLINE userProfileInGroup #-} @@ -1114,12 +1154,13 @@ userProfileInGroup' User {profile = p} allowSimplexLinks incognitoProfile = in redactedMemberProfile allowSimplexLinks p' memberInfo :: GroupInfo -> GroupMember -> MemberInfo -memberInfo g m@GroupMember {memberId, memberRole, memberProfile, activeConn} = +memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, activeConn} = MemberInfo { memberId, memberRole, v = ChatVersionRange . peerChatVRange <$> activeConn, - profile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile memberProfile + profile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile memberProfile, + memberKey = MemberKey <$> memberPubKey } where allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g @@ -1134,7 +1175,7 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes sendHistory :: User -> GroupInfo -> GroupMember -> CM () sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = +sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just conn} = when (m `supportsVersion` batchSendVersion) $ do (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) (errs', events) <- partitionEithers <$> mapM (tryAllErrors . itemForwardEvents) items @@ -1149,7 +1190,7 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn _ -> events' <> [descr] Nothing -> pure events' forM_ (L.nonEmpty events_) $ \events'' -> - sendGroupMemberMessages user conn events'' groupId + sendGroupMemberMessages user gInfo conn events'' where descrEvent_ :: Maybe (ChatMsgEvent 'Json) descrEvent_ @@ -1159,13 +1200,15 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn | otherwise = Nothing itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] itemForwardEvents cci = case cci of - (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) - | not (blockedByAdmin sender) -> do + (CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent mc, file}) + | not (maybe False blockedByAdmin sender_) -> do fInvDescr_ <- join <$> forM file getRcvFileInvDescr - processContentItem sender ci mc fInvDescr_ + processContentItem sender_ ci mc fInvDescr_ + | otherwise -> pure [] + where sender_ = chatItemRcvFromMember ci (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do fInvDescr_ <- join <$> forM file getSndFileInvDescr - processContentItem membership ci mc fInvDescr_ + processContentItem (Just membership) ci mc fInvDescr_ _ -> pure [] where getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) @@ -1198,8 +1241,8 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn fInv = xftpFileInvitation fileName fileSize fInvDescr in Just (fInv, fileDescrText) | otherwise = Nothing - processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] - processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = + processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] + processContentItem sender_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do @@ -1208,9 +1251,11 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn fInv_ = fst <$> fInvDescr_ (mc', _, mentions') = updatedMentionNames mc formattedText mentions mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' + asGroup = isNothing sender_ -- TODO [knocking] send history to other scopes too? - (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False - let senderVRange = memberChatVRange' sender + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing asGroup mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False + -- for channel messages default chat version range to membership range + let senderVRange = maybe (memberChatVRange' membership) memberChatVRange' sender_ xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of (Just fileDescrText, Just msgId) -> do @@ -1219,9 +1264,9 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn pure . L.toList $ L.map (XMsgFileDescr msgId) parts _ -> pure [] let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents - GroupMember {memberId} = sender - memberName = Just $ memberShortenedName sender - msgForwardEvents = map (\cm -> XGrpMsgForward memberId memberName cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) + fwdSender = maybe FwdChannel (\s -> FwdMember (memberId' s) (memberShortenedName s)) sender_ + fwd = GrpMsgForward {fwdSender, fwdBrokerTs = itemTs} + msgForwardEvents = map (XGrpMsgForward fwd) (xMsgNewChatMsg : fileDescrChatMsgs) pure msgForwardEvents memberShortenedName :: GroupMember -> ContactName @@ -1240,23 +1285,68 @@ splitFileDescr partSize rfdText = splitParts 1 rfdText then fileDescr :| [] else fileDescr <| splitParts (partNo + 1) rest -setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM () +setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM (Maybe GroupLink) setGroupLinkData' nm user gInfo = withFastStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case Right gLink@GroupLink {shortLinkDataSet} - | shortLinkDataSet -> void $ setGroupLinkData nm user gInfo gLink - _ -> pure () + | shortLinkDataSet -> Just <$> setGroupLinkData nm user gInfo gLink + _ -> pure Nothing setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink -setGroupLinkData nm user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLinkId} = do +setGroupLinkData nm user gInfo gLink = do vr <- chatVersionRange - conn <- withFastStore $ \db -> getGroupLinkConnection db vr user gInfo - let userData = encodeShortLinkData $ GroupShortLinkData groupProfile - userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} - crClientData = encodeJSON $ CRDataGroup groupLinkId + (conn, groupRelays) <- withFastStore $ \db -> + (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData)) withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk +setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () +setGroupLinkDataAsync user gInfo gLink = do + vr <- chatVersionRange + (conn, groupRelays) <- withStore $ \db -> + (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays + setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) + +updatePublicGroupData :: User -> GroupInfo -> CM GroupInfo +updatePublicGroupData user gInfo + | useRelays' gInfo && memberRole' (membership gInfo) == GROwner = do + vr <- chatVersionRange + (gInfo', gLink) <- withStore $ \db -> do + gInfo' <- updatePublicMemberCount db vr user gInfo + gLink <- getGroupLink db user gInfo' + pure (gInfo', gLink) + setGroupLinkDataAsync user gInfo' gLink + pure gInfo' + | otherwise = pure gInfo + +-- TODO [relays] owner: set owners on updating link data (multi-owner) +groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) +groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}} GroupLink {groupLinkId} groupRelays = + let direct = not $ useRelays' gInfo + relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays + publicGroupData_ = PublicGroupData <$> publicMemberCount + userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_} + userLinkData = UserContactLinkData UserContactData {direct, owners = [], relays, userData} + crClientData = encodeJSON $ CRDataGroup groupLinkId + in (userLinkData, crClientData) + +restoreShortLink' :: ConnShortLink m -> CM (ConnShortLink m) +restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) + +getShortLinkConnReq :: NetworkRequestMode -> User -> ConnShortLink m -> CM (FixedLinkData m, ConnLinkData m) +getShortLinkConnReq nm user@User {userChatRelay} l = do + l' <- restoreShortLink' l + (fd, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l' + case cData of + ContactLinkData _ UserContactData {direct, relays} + | not supported -> throwChatError CEUnsupportedConnReq + where + supported = direct || not (null relays) || isTrue userChatRelay + _ -> pure () + pure (fd, cData) + encodeShortLinkData :: J.ToJSON a => a -> UserLinkData encodeShortLinkData d = let s = LB.toStrict $ J.encode d @@ -1267,8 +1357,8 @@ encodeShortLinkData d = | otherwise = s in UserLinkData s' -decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) -decodeShortLinkData cData +decodeLinkUserData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) +decodeLinkUserData cData | B.null s = pure Nothing | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) @@ -1293,6 +1383,21 @@ 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) + +toShortRelayLink :: ShortLinkContact -> ShortLinkContact +toShortRelayLink (CSLContact sch _ srv k) = CSLContact sch CCTRelay srv k + +toShortLinkContact :: CreatedLinkContact -> Maybe ShortLinkContact +toShortLinkContact (CCLink _cReq sLink) = sLink + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange @@ -1457,7 +1562,7 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg = parseChatMessage :: Connection -> ByteString -> CM (ChatMessage 'Json) parseChatMessage conn s = do case parseChatMessages s of - [msg] -> liftEither . first (ChatError . errType) $ (\(ACMsg _ m) -> checkEncoding m) =<< msg + [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ _ m)) -> checkEncoding m) =<< msg _ -> throwChatError $ CEException "parseChatMessage: single message is expected" where errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) @@ -1471,10 +1576,10 @@ getChatScopeInfo vr user = \case pure $ GCSIMemberSupport (Just supportMem) getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] -getGroupRecipients vr user gInfo@GroupInfo {useRelays, membership} scopeInfo modsCompatVersion - | isTrue useRelays && not (isMemberRelay membership) = do +getGroupRecipients vr user gInfo@GroupInfo {membership} scopeInfo modsCompatVersion + | useRelays' gInfo && not (isRelay membership) = do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - withFastStore' $ \db -> getGroupRelays db vr user gInfo + withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo | otherwise = case scopeInfo of Nothing -> do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" @@ -1718,10 +1823,10 @@ sendDirectContactMessages user ct events = do sendDirectContactMessages' :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages' user ct events = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct - let idsEvts = L.map (ConnectionId connId,) events + let idsEvts = L.map (ConnectionId connId,Nothing,) events msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} sndMsgs_ <- lift $ createSndMessages idsEvts - (sndMsgs', pqEnc_) <- batchSendConnMessagesB user conn msgFlags sndMsgs_ + (sndMsgs', pqEnc_) <- batchSendConnMessagesB BMJson user conn msgFlags sndMsgs_ forM_ pqEnc_ $ \pqEnc' -> void $ createContactPQSndItem user ct conn pqEnc' pure sndMsgs' @@ -1759,37 +1864,44 @@ sendDirectMessage_ conn chatMsgEvent connOrGroupId = do createSndMessage :: MsgEncodingI e => ChatMsgEvent e -> ConnOrGroupId -> CM SndMessage createSndMessage chatMsgEvent connOrGroupId = - liftEither . runIdentity =<< lift (createSndMessages $ Identity (connOrGroupId, chatMsgEvent)) + liftEither . runIdentity =<< lift (createSndMessages $ Identity (connOrGroupId, Nothing, chatMsgEvent)) -createSndMessages :: forall e t. (MsgEncodingI e, Traversable t) => t (ConnOrGroupId, ChatMsgEvent e) -> CM' (t (Either ChatError SndMessage)) +createSndMessages :: forall e t. (MsgEncodingI e, Traversable t) => t (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent e) -> CM' (t (Either ChatError SndMessage)) createSndMessages idsEvents = do g <- asks random vr <- chatVersionRange' withStoreBatch $ \db -> fmap (createMsg db g vr) idsEvents where - createMsg :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> (ConnOrGroupId, ChatMsgEvent e) -> IO (Either ChatError SndMessage) - createMsg db g vr (connOrGroupId, evnt) = runExceptT $ do - withExceptT ChatErrorStore $ createNewSndMessage db g connOrGroupId evnt encodeMessage + createMsg :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent e) -> IO (Either ChatError SndMessage) + createMsg db g vr (connOrGroupId, msgSigning_, evnt) = runExceptT $ do + withExceptT ChatErrorStore $ createNewSndMessage db g connOrGroupId evnt msgSigning_ encodeMessage where encodeMessage sharedMsgId = encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = evnt} -sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> Connection -> NonEmpty (ChatMsgEvent e) -> GroupId -> CM () -sendGroupMemberMessages user conn events groupId = do +groupMsgSigning :: GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt + | useRelays' gInfo && requiresSignature (toCMEventTag evt) = + Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey +groupMsgSigning _ _ = Nothing + +sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfo -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () +sendGroupMemberMessages user gInfo@GroupInfo {groupId} conn events = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (GroupId groupId,) events + let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning gInfo evt, evt)) events + mode = if useRelays' gInfo then BMBinary else BMJson (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CEvtChatErrors errs forM_ (L.nonEmpty msgs) $ \msgs' -> - batchSendConnMessages user conn MsgFlags {notification = True} msgs' + batchSendConnMessages mode user conn MsgFlags {notification = True} msgs' -batchSendConnMessages :: User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption) -batchSendConnMessages user conn msgFlags msgs = - batchSendConnMessagesB user conn msgFlags $ L.map Right msgs +batchSendConnMessages :: BatchMode -> User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption) +batchSendConnMessages mode user conn msgFlags msgs = + batchSendConnMessagesB mode user conn msgFlags $ L.map Right msgs -batchSendConnMessagesB :: User -> Connection -> MsgFlags -> NonEmpty (Either ChatError SndMessage) -> CM ([Either ChatError SndMessage], Maybe PQEncryption) -batchSendConnMessagesB _user conn msgFlags msgs_ = do - let batched_ = batchSndMessagesJSON msgs_ +batchSendConnMessagesB :: BatchMode -> User -> Connection -> MsgFlags -> NonEmpty (Either ChatError SndMessage) -> CM ([Either ChatError SndMessage], Maybe PQEncryption) +batchSendConnMessagesB mode _user conn msgFlags msgs_ = do + let batched_ = batchSndMessagesJSON mode msgs_ case L.nonEmpty batched_ of Just batched' -> do let msgReqs = L.map (fmap msgBatchReq_) batched' @@ -1810,8 +1922,8 @@ batchSendConnMessagesB _user conn msgFlags msgs_ = do findLastPQEnc :: NonEmpty (Either ChatError ([Int64], PQEncryption)) -> Maybe PQEncryption findLastPQEnc = foldr' (\x acc -> case x of Right (_, pqEnc) -> Just pqEnc; Left _ -> acc) Nothing -batchSndMessagesJSON :: NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch] -batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList +batchSndMessagesJSON :: BatchMode -> NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch] +batchSndMessagesJSON mode = batchMessages mode maxEncodedMsgLength . L.toList encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo chatMsgEvent = do @@ -1894,7 +2006,7 @@ deliverMessagesB msgReqs = do sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage sendGroupMessage user gInfo gcScope members chatMsgEvent = do - sendGroupMessages user gInfo gcScope members (chatMsgEvent :| []) >>= \case + sendGroupMessages user gInfo gcScope False members (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message" @@ -1904,8 +2016,8 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" -sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages user gInfo scope members events = do +sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages user gInfo scope asGroup members events = do -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ sendProfileUpdate `catchAllErrors` eToView @@ -1914,6 +2026,7 @@ sendGroupMessages user gInfo scope members events = do User {profile = p, userMemberProfileUpdatedAt} = user GroupInfo {userMemberProfileSentAt} = gInfo shouldSendProfileUpdate + | asGroup = False | isJust scope = False -- why not sending profile updates to scopes? | incognitoMembership gInfo = False | otherwise = @@ -1937,7 +2050,7 @@ data GroupSndResult = GroupSndResult sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do - let idsEvts = L.map (GroupId groupId,) events + let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning gInfo evt, evt)) events sndMsgs_ <- lift $ createSndMessages idsEvts recipientMembers' <- liftIO $ shuffleMembers recipientMembers let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} @@ -1979,7 +2092,8 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do mIds' = S.insert mId mIds prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) prepareMsgReqs msgFlags msgs toSendSeparate toSendBatched = do - let batched_ = batchSndMessagesJSON msgs + let mode = if useRelays' gInfo then BMBinary else BMJson + batched_ = batchSndMessagesJSON mode msgs case L.nonEmpty batched_ of Just batched' -> do let lenMsgs = length msgs @@ -2025,15 +2139,15 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember {memberRole, memberStatus} +memberSendAction gInfo@GroupInfo {membership} events members m@GroupMember {memberRole, memberStatus} -- groups with relays require newer version - we don't need to check member version for batching and forwarding support - | isTrue useRelays = + | useRelays' gInfo = if -- if user is chat relay, send to all non chat relay members - | isMemberRelay membership && not (isMemberRelay m) -> MSASendBatched . snd <$> readyMemberConn m + | isRelay membership && not (isRelay m) -> MSASendBatched . snd <$> readyMemberConn m -- if user is not chat relay, send only to chat relays - | not (isMemberRelay membership) && isMemberRelay m -> MSASendBatched . snd <$> readyMemberConn m - | otherwise -> Nothing -- TODO [channels fwd] MSAForwarded to create GSSForwarded snd statuses? + | not (isRelay membership) && isRelay m -> MSASendBatched . snd <$> readyMemberConn m + | otherwise -> Nothing -- TODO [relays] MSAForwarded to create GSSForwarded snd statuses? | otherwise = case memberConn m of Nothing -> pendingOrForwarded Just conn@Connection {connStatus} @@ -2095,30 +2209,39 @@ sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} c MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId MSAForwarded -> pure () +-- Send pre-encoded forwarded message preserving original signature +sendFwdMemberMessage :: GroupMember -> GrpMsgForward -> VerifiedMsg 'Json -> CM () +sendFwdMemberMessage member fwd verifiedMsg = + forM_ (readyMemberConn member) $ \(_, conn) -> do + let body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] + void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] + -- TODO ensure order - pending messages interleave with user input messages -sendPendingGroupMessages :: User -> GroupMember -> Connection -> CM () -sendPendingGroupMessages user GroupMember {groupMemberId} conn = do +sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM () +sendPendingGroupMessages user gInfo GroupMember {groupMemberId} conn = do + let mode = if useRelays' gInfo then BMBinary else BMJson msgs <- withStore' $ \db -> getPendingGroupMessages db groupMemberId forM_ (L.nonEmpty msgs) $ \msgs' -> do - void $ batchSendConnMessages user conn MsgFlags {notification = True} msgs' + void $ batchSendConnMessages mode user conn MsgFlags {notification = True} msgs' lift . void . withStoreBatch' $ \db -> L.map (\SndMessage {msgId} -> deletePendingGroupMessage db groupMemberId msgId) msgs' -saveDirectRcvMSG :: MsgEncodingI e => Connection -> MsgMeta -> MsgBody -> ChatMessage e -> CM (Connection, RcvMessage) -saveDirectRcvMSG conn@Connection {connId} agentMsgMeta msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do +saveDirectRcvMSG :: forall e. MsgEncodingI e => Connection -> MsgMeta -> ChatMessage e -> CM (Connection, RcvMessage) +saveDirectRcvMSG conn@Connection {connId} agentMsgMeta chatMsg@ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta brokerTs = metaBrokerTs agentMsgMeta - newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} + newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg = VMUnsigned chatMsg, brokerTs} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) -saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> CM (GroupMember, Connection, RcvMessage) -saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do +saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage) +saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta verifiedMsg = do + let ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = verifiedChatMsg verifiedMsg (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta brokerTs = metaBrokerTs agentMsgMeta - newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} + newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) @@ -2132,21 +2255,22 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta _ -> throwError e pure (am', conn', msg) -saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage) -saveGroupFwdRcvMsg user GroupInfo {groupId, useRelays} forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do - let newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} +saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> Maybe GroupMember -> VerifiedMsg e -> UTCTime -> CM (Maybe RcvMessage) +saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMember_ verifiedMsg brokerTs = do + let ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = verifiedChatMsg verifiedMsg + newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs} fwdMemberId = Just $ groupMemberId' forwardingMember - refAuthorId = Just $ groupMemberId' refAuthorMember - -- TODO [channels fwd] TBC highlighting difference between deduplicated messages (useRelays branch) + refAuthorId = groupMemberId' <$> refAuthorMember_ + -- TODO [relays] TBC highlighting difference between deduplicated messages (useRelays branch) withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case Right msg -> pure $ Just msg Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} - | isTrue useRelays -> pure Nothing -- with chat relays, duplicates are expected + | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of (Just authorGMId, Nothing) -> do vr <- chatVersionRange am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGMId - if sameMemberId refMemberId am + if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" @@ -2161,7 +2285,7 @@ saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothi saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd) saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do let itemTexts = ciContentTexts content - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case + saveSndChatItems user cd False [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" @@ -2180,11 +2304,12 @@ saveSndChatItems :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> + ShowGroupAsSender -> [Either ChatError (NewSndChatItemData c)] -> Maybe CITimed -> Bool -> CM [Either ChatError (ChatItem c 'MDSnd)] -saveSndChatItems user cd itemsData itemTimed live = do +saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do createdAt <- liftIO getCurrentTime vr <- chatVersionRange when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ @@ -2192,11 +2317,11 @@ saveSndChatItems user cd itemsData itemTimed live = do lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do let hasLink_ = ciContentHasLink content (snd itemTexts) - ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt + ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd False ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing createdAt + let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (MSSVerified <$ signedMsg_) createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci @@ -2212,37 +2337,42 @@ ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe Markdown ciContentNoParse content = (content, (ciContentToText content, Nothing)) saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) -saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime vr <- chatVersionRange withStore' $ \db -> do - (mentions' :: Map MemberName CIMention, userMention) <- case cd of - CDGroupRcv g@GroupInfo {membership} _scope _m -> do - mentions' <- getRcvCIMentions db user g ft_ mentions - let userReply = case cmToQuotedMsg chatMsgEvent of - Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership - _ -> False - userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' - in pure (mentions', userMention') - CDDirectRcv _ -> pure (M.empty, False) + (mentions' :: Map MemberName CIMention, userMention) <- case toChatInfo cd of + GroupChat g@GroupInfo {membership} _ -> groupMentions db g membership + _ -> pure (M.empty, False) cInfo' <- - if ciRequiresAttention content || contactChatDeleted cd + if (ciRequiresAttention content || contactChatDeleted cd) then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) else pure $ toChatInfo cd - let hasLink_ = ciContentHasLink content ft_ + let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False + hasLink_ = ciContentHasLink content ft_ (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember createdAt - ci' <- case cd of - CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' + let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember msgSigned createdAt + ci' <- case toChatInfo cd of + GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci pure (ci', cInfo') where + groupMentions db g membership = do + mentions' <- getRcvCIMentions db user g ft_ mentions + let userReply = case cmToQuotedMsg chatMsgEvent of + Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership + _ -> False + userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' + in pure (mentions', userMention') memberChatStats :: Bool -> Maybe (Int, MemberAttention, Int) memberChatStats userMention = case cd of - CDGroupRcv _g (Just scope) m -> do + CDGroupRcv _g (Just scope) m -> let unread = fromEnum $ ciCreateStatus content == CISRcvNew - in Just (unread, memberAttentionChange unread (Just brokerTs) m scope, fromEnum userMention) + in Just (unread, memberAttentionChange unread (Just brokerTs) (Just m) scope, fromEnum userMention) + CDChannelRcv _g (Just scope) -> + let unread = fromEnum $ ciCreateStatus content == CISRcvNew + in Just (unread, memberAttentionChange unread (Just brokerTs) Nothing scope, fromEnum userMention) _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing @@ -2250,12 +2380,12 @@ mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAs mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = let ts@(_, ft_) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ - in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember currentTs + in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember Nothing currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs = let itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgSigned currentTs currentTs in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool @@ -2274,10 +2404,10 @@ createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode pure (cmdId, connId) -joinAgentConnectionAsync :: User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId) -joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do - cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) Nothing enableNtfs cReqUri cInfo PQSupportOff subMode +joinAgentConnectionAsync :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId) +joinAgentConnectionAsync user conn_ enableNtfs cReqUri cInfo subMode = do + cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) (aConnId <$> conn_) enableNtfs cReqUri cInfo PQSupportOff subMode pure (cmdId, connId) allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM () @@ -2311,6 +2441,18 @@ deleteAgentConnectionsAsync' [] _ = pure () deleteAgentConnectionsAsync' acIds waitDelivery = do withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchAllErrors` eToView +setAgentConnShortLinkAsync :: User -> Connection -> UserConnLinkData 'CMContact -> Maybe CRClientData -> CM () +setAgentConnShortLinkAsync user conn@Connection {connId} userLinkData crClientData_ = do + cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFSetShortLink + withAgent $ \a -> setConnShortLinkAsync a (aCorrId cmdId) (aConnId conn) userLinkData crClientData_ + +getAgentConnShortLinkAsync :: User -> CommandFunction -> Maybe Connection -> ShortLinkContact -> CM (CommandId, ConnId) +getAgentConnShortLinkAsync user cmdFunc conn_ shortLink = do + shortLink' <- restoreShortLink' shortLink + cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) cmdFunc + connId <- withAgent $ \a -> getConnShortLinkAsync a (aUserId user) (aCorrId cmdId) (aConnId <$> conn_) shortLink' + pure (cmdId, connId) + agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) @@ -2510,7 +2652,7 @@ createChatItems user itemTs_ dirsCIContents = do memberChatStats = case cd of CDGroupRcv _g (Just scope) m -> do let unread = length $ filter (ciRequiresAttention . fst) contents - in Just (unread, memberAttentionChange unread itemTs_ m scope, 0) + in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0) _ -> Nothing createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> [IO AChatItem] createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents @@ -2521,10 +2663,12 @@ createChatItems user itemTs_ dirsCIContents = do let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -memberAttentionChange :: Int -> (Maybe UTCTime) -> GroupMember -> GroupChatScopeInfo -> MemberAttention -memberAttentionChange unread brokerTs_ rcvMem = \case +-- rcvMem_ Nothing means message from channel - treated same as message from moderator, +-- e.g. it can reset unanswered counter if newer than last unanswered message. +memberAttentionChange :: Int -> (Maybe UTCTime) -> Maybe GroupMember -> GroupChatScopeInfo -> MemberAttention +memberAttentionChange unread brokerTs_ rcvMem_ = \case GCSIMemberSupport (Just suppMem) - | groupMemberId' suppMem == groupMemberId' rcvMem -> MAInc unread brokerTs_ + | maybe False ((groupMemberId' suppMem ==) . groupMemberId') rcvMem_ -> MAInc unread brokerTs_ | msgIsNewerThanLastUnanswered -> MAReset | otherwise -> MAInc 0 Nothing where @@ -2549,9 +2693,9 @@ createLocalChatItems user cd itemsData createdAt = do createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do let hasLink_ = ciContentHasLink content ft_ - ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing createdAt + ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing createdAt + pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = @@ -2605,13 +2749,19 @@ adminContactReq :: ConnReqContact adminContactReq = either error id $ strDecode "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" +contactCReqHash :: ConnReqContact -> ConnReqUriHash +contactCReqHash = ConnReqUriHash . C.sha256Hash . strEncode + +simplexChatImage :: ImageData +simplexChatImage = ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8KCwkMEQ8SEhEPERATFhwXExQaFRARGCEYGhwdHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAETARMDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7LooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiivP/iF4yFvv0rSpAZek0yn7v+yPeunC4WpiqihBf8A8rOc5w2UYZ4jEPTourfZDvH3jL7MW03SpR53SWUfw+w96veA/F0erRLY3zKl6owD2k/8Ar15EWLEljknqadDK8MqyxMUdTlWB5Br66WS0Hh/ZLfv1ufiNLj7Mo5m8ZJ3g9OTpy+Xn5/pofRdFcd4B8XR6tEthfMEvVHyk9JB/jXY18fiMPUw9R06i1P3PK80w2aYaOIw8rxf3p9n5hRRRWB6AUUVDe3UFlavc3MixxIMsxppNuyJnOMIuUnZIL26gsrV7m5kWOJBlmNeU+I/Gd9e6sk1hI8FvA2Y1z973NVPGnimfXLoxRFo7JD8if3vc1zefevr8syiNKPtKyvJ9Ox+F8Ycb1cdU+rYCTjTi/iWjk1+nbue3eEPEdtrtoMER3SD95Hn9R7Vu18+6bf3On3kd1aSmOVDkEd/Y17J4P8SW2vWY6R3aD97F/Ue1eVmmVPDP2lP4fyPtODeMoZrBYXFO1Zf+Tf8AB7r5o3qKKK8Q/QgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAqavbTXmmz20Fw1vJIhVZB1FeDa3p15pWoSWl6hWQHr2YeoNfQlY3izw9Z6/YGGZQky8xSgcqf8K9jKcyWEnyzXuv8D4njLhZ51RVSi7VYLRdGu3k+z+88HzRuq1rWmXmkX8lnexFHU8Hsw9RVLNfcxlGcVKLumfgFahUozdOorSWjT6E0M0kMqyxOyOpyrKcEGvXPAPjCPVolsb9wl6owGPAkH+NeO5p8M0kMqyxOyOpyrA4INcWPy+njKfLLfoz2+HuIMTkmI9pT1i/ij0a/wA+zPpGiuM+H/jCPV4lsL91S+QfKTwJR/jXW3t1BZWslzcyLHFGMsxNfB4jC1aFX2U1r+fof0Rl2bYXMMKsVRl7vXy7p9rBfXVvZWr3NzKscSDLMTXjnjbxVPrtyYoiY7JD8if3vc0zxv4ruNeujFEWjsoz8if3vc1zOa+synKFh0qtVe9+X/BPxvjLjKWZSeEwjtSW7/m/4H5kmaM1HmlB54r3bH51YkzXo3wz8MXMc0es3ZeED/VR5wW9z7VB8O/BpnMerarEREDuhhb+L3Pt7V6cAAAAAAOgFfL5xmqs6FH5v9D9a4H4MlzQzHGq1tYR/KT/AEXzCiiivlj9hCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAxfFvh208QWBhmASdRmKUdVP+FeH63pl5pGoSWV5EUdTwezD1HtX0VWL4t8O2fiHTzBONk6g+TKByp/wr28pzZ4WXs6msH+B8NxdwhTzeDxGHVqy/8m8n59n954FmjNW9b0y80fUHsr2MpIp4PZh6iqWfevuYyjOKlF3TPwetQnRm6dRWktGmSwzSQyrLE7I6nKsDgg1teIPFOqa3a29vdy4jiUAheN7f3jWBmjNROhTnJTkrtbGtLF4ijSnRpzajPddHbuP3e9Lmo80ua0scth+a9E+HXgw3Hl6tqsZEX3oYmH3vc+1J8OPBZnKavq0eIhzDCw+9/tH29q9SAAAAGAOgr5bOM35b0KD16v8ARH6twXwXz8uPx0dN4xfXzf6IFAUAAAAdBRRRXyZ+wBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFB4GTXyj+1p+0ONJjufA3ga6DX7qU1DUY24gB4McZH8Xqe38tqFCdefLETaSufQ3h/4geEde8Uah4a0rWra51Ow/wBfCrD8ceuO+OldRX5I+GfEWseG/ENvr2j30ttqFvJ5iSqxyT3z6g96/RH9nD41aT8U9AWGcx2fiK1QC7tC33/+mieqn07V14zL3QXNHVEQnc9dooorzjQKKKKACiis7xHrel+HdGudY1m8is7K2QvLLI2AAP600m3ZAYfxUg8Pr4VutT1+7isYbSMuLp/4Pb3z6V8++HNd0zxDpq6hpVys8DHGRwVPoR2NeIftJ/G7VPifrbWVk8lp4btZD9mtwcGU/wDPR/c9h2rgfh34z1LwdrAurV2ktZCBcW5PyyD/AB9DX2WTyqYWny1Ho+nY+C4t4Wp5tF16CtVX/k3k/Ps/vPr/ADRmsjwx4g07xFpMWpaZOJInHI/iQ9wR61qbq+mVmro/D6tCdGbp1FZrdEma6/4XafpWoa7jUpV3oA0MLdJD/ntXG5p8E0kMqyxOyOhyrKcEGsMTRlWpShGVm+p1ZbiYYPFQr1IKai72fU+nFAUAKAAOABRXEfDnxpFrMK6fqDhL9BhSeko9frXb1+a4rDVMNUdOotT+k8szLD5lh44jDu8X968n5hRRRXOegFFFFABUGoXlvYWkl1dSrHFGMliaL+7t7C0kuruVYoYxlmNeI+OvFtx4huzHFuisYz+7jz97/aNenluW1MbU00it2fM8S8SUMkoXetR/DH9X5fmeteF/E+m+IFkFoxSWMnMb9cev0rbr5t0vULrTb6K8s5TFNGcgj+R9q9w8E+KbXxDYjlY7xB+9i/qPaurNsneE/eUtYfkeTwlxjHNV9XxVo1V90vTz8vmjoqKKK8I+8CiiigAooooAKKKKACiiigD5V/a8+P0mgvdeAvCUskepFdl9eDjyQR9xPfHeviiR3lkaSR2d2OWZjkk+tfoj+058CtP+Jektq2jxRWnie2T91KMKLlR/yzf+h7V+fOuaVqGiarcaXqtpLaXls5jlikXDKRX0mWSpOlaG/U56l76lKtPwtr+reGNetdb0S8ls761cPHJG2D9D6g9MVmUV6TSasyD9Jf2cfjXpPxR0MW9w0dp4gtkAubYnHmf7aeo/lXr1fkh4W1/V/DGuW2taHey2d9bOHjkjP6H1HtX6Jfs5fGvR/inoQgmeOz8RWqD7XaE439vMT1U+navnMfgHRfPD4fyN4Tvoz12iis7xJremeHdEutZ1i7jtLK1jLyyucAAf1rzUm3ZGgeJNb0vw7otzrOs3kVpZWyF5ZZDgAD+Z9q/PL9pP436r8UNZaxs2ks/Dlq5+z24ODMf77+p9B2o/aU+N2p/FDXDZ2LS2fhy1ci3t84Mx/wCej+/oO1eNV9DgMAqS55/F+RhOd9EFFFABJwBkmvUMzqPh34y1Lwjq63FszSWshAntyeHHt719Z2EstzpVlqD2txbR3kCzxLPGUbawyODXK/slfs8nUpbXx144tGFkhElhp8q4849pHB/h9B3r608X+GLDxBpX2WRFiljX9xIowUPYfT2rGnnkMPWVJ6x6vt/XU+P4o4SjmtN4igrVV/5N5Pz7P7z56zRmrmvaVe6LqMljexMkiHg9mHqKoZr6uEozipRd0z8Rq0J0ZunUVmtGmTwTSQTJNC7JIhyrKcEGvZvhz41j1mJdP1GRUv0GFY8CX/69eJZqSCaWCVZYXZHU5VlOCDXDmGXU8bT5ZaPo+x7WQZ9iMlxHtKesX8UejX+fZn1FRXDfDbxtHrUKadqDqmoIuAx4EoHf613NfnWKwtTC1HTqKzR/QGW5lh8yw8cRh3eL+9Ps/MKr6heW1hZyXd3KsUUYyzGjUby20+zku7yZYoY13MzGvDPHvi+48RXpjiZorCM/u4/73+0feuvLMsqY6pZaRW7/AK6nlcScR0MloXetR/DH9X5D/Hni648Q3nlxlo7GM/u48/e9zXL7qZmjNfodDDwoU1TpqyR+AY7G18dXlXryvJ/19w/dVvSdRutMvo7yzlaOVDkY7+xqkDmvTPhn4HMxj1jV4v3Y+aCFh97/AGjWGPxNHDUXKrt27+R15JlWLzHFxp4XSS1v/L53PQ/C+oXGqaJb3t1bNbyyLkoe/v8AQ1p0AAAAAADoBRX5nUkpSbirLsf0lh6c6dKMJy5mkrvv5hRRRUGwUUUUAFFFFABRRRQAV4d+038CdO+JWkyavo8cdp4mtkzHIBhbkD+B/f0Ne40VpSqypSUovUTV9GfkTruk6joer3Ok6taS2d7ayGOaGVdrKRVKv0T/AGnfgXp/xK0h9Y0iOO18TWqZikAwLkD+B/6Gvz51zStQ0TVbjS9UtZbW8tnKSxSLgqRX1GExccRG636o55RcSlWp4V1/VvDGvWut6JeSWl9bOGjkQ4/A+oPpWXRXU0mrMk/RP4LftDeFvF3ge41HxDfW+lappkG+/idsBwP40HfJ7V8o/tJ/G/VPifrbWVk8tn4btn/0e2zgykfxv6n0HavGwSM4JGeuO9JXFRwFKlUc18vIpzbVgoooAJIAGSa7SQr6x/ZM/Z4k1J7Xxz44tClkMSWFhIuDL3Ejg/w+g70fsmfs8NqMtt448c2eLJCJLCwlX/WnqHcH+H0HevtFFVECIoVVGAAMACvFx+PtenTfqzWEOrEjRI41jjUIigBVAwAPSnUUV4ZsYXjLwzZeJNOaCcBLhQfJmA5U/wCFeBa/pV7ompSWF9GUkToccMOxHtX01WF4z8M2XiXTTBOAk6AmGYDlD/hXvZPnEsHL2dTWD/A+K4r4UhmsHXoK1Zf+TeT8+z+8+c80Zq5r2k3ui6jJY30ZSRTwezD1FUM1+gQlGcVKLumfiFWjOjN06is1umTwTSQTJNE7JIh3KynBBr2PwL8QrO701odbnSC5t0yZCcCUD+teK5pd1cWPy2ljoctTdbPqetkme4rJ6rqUHdPdPZ/8Mdb4/wDGFz4ivDFGxisIz+7j/ve5rls1HuozXTQw1PD01TpqyR5+OxlfHV5V68ryf9fcSZozTAa9P+GHgQzmPWdZhIjHzQQMPvf7R9qxxuMpYOk6lR/8E6MpyfEZriFQoL1fRLux/wAMvApmMesazFiP70EDfxf7R9vavWFAUAAAAcACgAAAAAAdBRX5xjsdVxtXnn8l2P3/ACXJcNlGHVGivV9W/wCugUUUVxHrhRRRQAUUUUAFFFFABRRRQAUUUUAFeH/tOfArT/iXpUmsaSsVp4mto/3UuMLcgDhH/oe1e4Vn+I9a0zw7otzrGsXkVpZWyF5ZZGwAB/WtaNSdOalDcTSa1PyZ1zStQ0TVrnStVtZLS8tnMcsUgwVIqlXp/wC0l8S7T4nePn1aw0q3srO3XyYJBGBNOoPDSHv7DtXmFfXU5SlBOSszlYUUUVYAAScDk19Zfsmfs7vqLW3jjx1ZFLMESafYSjmXuJHHZfQd6+VtLvJtO1K2v7cRtLbyrKgkQOpKnIyp4I46Gv0b/Zv+NOjfFDw+lrIIrDX7RAtzZ8AMMffj9V9u1efmVSrCn7m3Vl00m9T16NEjjWONVRFGFUDAA9KWiivmToCiiigAooooAwfGnhiy8S6cYJwEuEH7mYDlT/hXz7r+k32h6lJYahFskQ8Hsw9QfSvpjUr2106ykvLyZYYYxlmY18+/EXxa/ijU1aOMRWkGRCCBuPuT/Svr+GK2KcnTSvT/ACfl/kfmPiBhMvUI1m7Vn0XVefp0fy9Oa3UbqZmjNfa2PynlJM+9AOajzTo5GjkV0YqynIPoaVg5T1P4XeA/P8vWdaiIj+9BAw+9/tH29q9dAAAAAAHQVwPwx8dQ63Ammai6R6hGuFJ4Ew9vf2rvq/Ms5qYmeJaxGjWy6W8j+gOFcPl9LAReBd0931b8+3oFFFFeSfSBRRRQAUUUUAFFFFABRRRQAUUUUAFFFZ3iTW9L8OaJdazrN5HaWNqheWWQ4AH+NNJt2QB4l1vTPDmiXWs6xdx2llaxl5ZHOAAO3ufavzx/aT+N2qfFDWzZWbSWfhy2ci3tg2DKf77+p9B2pf2lfjdqfxQ1trGxeW08N2z/AOj2+cGYj/lo/v6DtXjVfQ4DAKkuefxfkYTnfRBRRQAScAZNeoZhRXv3w2/Zh8V+Lfh7deJprgadcvHv02zlT5rgdcsf4Qe1eHa5pWoaJq1zpWq2ktpeW0hjlikXDKwrOFanUk4xd2htNFKtTwrr+reGNdtta0S8ltL22cPHIhx07H1HtWXRWjSasxH6S/s4/GrSfijoYtp3jtfENqg+1WpON4/vp6j27V69X5IeFfEGr+F9etdc0O9ks7+1cPHKh/QjuD3Ffoj+zl8bNI+KWhLbztFZ+IraMfa7TON+Osieqn07V85j8A6L54fD+RvCd9GevUUUV5hoFVtTvrXTbGW9vJligiXczNRqd9aabYy3t7MsMEQyzMa+ffiN42uvE96YoS0OmxH91F3b/ab3r1spympmFSy0it3+i8z57iDiCjlFG71qPZfq/Id8RPGl14lvTFEzRafGf3cf97/aNclmmZozX6Xh8NTw1NU6askfheNxdbG1pV68ryY/NGTTM16R4J+GVxrGkSX+pSSWfmJ/oq45J7MR6Vni8ZRwkOes7I1y7K8TmNX2WHjd7/0zzvJozV3xDpF7oepyWF/EUkQ8HHDD1FZ+feuiEozipRd0zjq0Z0puE1ZrdE0E8sEyTQu0ciHKspwQa9z+GHjuLXIU0zUpFTUEXCseBKB/WvBs1JBPLBMk0LmORCGVlOCDXn5lllLH0uWWjWz7HsZFnlfJ6/tKesXuu6/z7M+tKK4D4X+PItdhTTNSdY9SQYVicCYDuPf2rv6/M8XhKuEqulVVmj92y7MaGYUFXoO6f4Ps/MKKKK5juCiiigAooooAKKKKACiig9KAM7xLrmleG9EudZ1q8jtLG2QvLK5wAPQep9q/PH9pP43ap8T9beyspJbTw3bSH7NbZx5pH8b+p9u1bH7YPxL8XeJPG114V1G0udH0jT5SIrNuDOR0kbs2e3pXgdfRZfgVTSqT3/IwnO+iCiigAkgAZJr1DMK+s/2TP2d31Brbxz46tNtmMSafp8i8y9/MkB6L0wO9J+yb+zwdSe28b+ObLFmpEljYSr/rT1DuP7voO9faCKqIERQqqMAAYAFeLj8fa9Om/VmsIdWEaJGixooVFGFUDAA9K8Q/ac+BWnfErSZNY0mOO08T2yZilAwtyAPuP/Q9q9worx6VWVKSlF6mrSasfkTrmlahomrXOlaray2l7bSGOaKRcMrCqVfon+098C7D4l6U+s6Skdr4mtY/3UmMC5UdI29/Q1+fOt6XqGi6rcaVqlrJa3ls5SWKQYKkV9RhMXHERut+qOeUeUpVqeFfEGreGNdttb0W7ktb22cNG6HH4H1FZdFdTSasyT9Jf2cPjVpXxR0Fbe4eK18Q2qD7Va7sbx/z0T1H8q9V1O+tdNsZb29mWGCJdzMxr8ovAOoeIdK8W2GoeF5podVhlDQtEefcH2PevsbxP4417xTp1jDq3lQGKFPOigJ2NLj5m59849K4KHD0sTX9x2h18vJHj55xDSyqhd61Hsv1fkaXxG8bXXie9MURaLTo2/dR5+9/tH3rkM1HmjNffYfC08NTVOmrJH4ljMXWxtaVau7yZJmgHmmAmvWfhN8PTceVrmuQkRDDW9uw+9/tN7Vjj8dSwNJ1ar9F3OjK8pr5nXVGivV9Eu7H/Cf4emcx63rkJEfDW9u4+9/tMPT2r2RQFAVQABwAKAAAAAAB0Aor8uzDMKuOq+0qfJdj9zyjKMPlVBUaK9X1bOf8b+FbHxRppt7gCO4UfuZwOUP9R7V86+IdHv8AQtTk0/UIikqHg9mHqD6V9VVz3jnwrY+KNMNvcKEuEBME2OUP+FenkmdywUvZVdab/A8PijheGZw9vQVqq/8AJvJ+fZnzLuo3Ve8Q6Pf6FqclhqERjkQ8Hsw9Qazs1+jwlGpFSi7pn4xVozpTcJqzW6J7eeSCZJoZGjkQhlZTgg17t8LvHsWuQppmpOseooMKxPEw/wAa8DzV3Q7fULvVIIdLWQ3ZcGMx8EH1z2rzs1y2jjaLVTRrZ9v+AezkGcYnK8SpUVzKWjj3/wCD2PrCiqOgx38Oj20eqTJNeLGBK6jAJq9X5VOPLJq9z98pyc4KTVr9H0CiiipLCiiigAooooAKKKKAPK/2hfg3o/xT8PFdsVprlupNnebec/3W9VNfnR4y8Naz4R8RXWg69ZvaXts5V1YcEdmB7g9jX6115V+0P8GtF+Knh05SO0161UmzvQuD/uP6qf0r08DjnRfJP4fyM5wvqj80RycCvrP9kz9ndtRNr458dWTLaAiTT9PlXBl9JJB/d7gd+tXv2bv2Y7yz19vEHxFs1VbKYi1sCQwlZTw7f7PcDvX2CiLGioihVUYAAwAK6cfmGns6T9WTCHVhGiRoqRqFRRgKBgAUtFFeGbBRRRQAV4h+038CtP8AiZpTatpCQ2fia2jPlS4wtyo52P8A0Pavb6K0pVZUpKUXqJq+jPyJ1zStQ0TVrnStVtJbS9tnMcsUgwVIqPS7C61O+isrKFpZ5W2qor9AP2r/AIM6J448OzeJLV7fTtesoyRO3yrcqP4H9/Q14F8OvBlp4XsvMkCTajKP3suM7f8AZX0H86+1yiDzFcy0S3Pms+zqllNLXWb2X6vyH/DnwZaeF7EPIEm1CUDzZcfd/wBke1dfmo80ua+0pUY0oqMVofjWLxNXF1XWrO8mSZozUea9N+B/hTTdau5NUv5opvsrjbak8k9mYelc+OxcMHQlWqbI1y3LqmYYmOHpbvuafwj+HhnMWva5DiMENb27D73ozD09q9oAAAAAAHQCkUBVCqAAOABS1+U5jmNXH1XUqfJdj9yyjKKGV0FRor1fVsKKKK4D1AooooA57xz4UsPFOmG3uFEdwgJgnA5Q/wBR7V84eI9Gv9A1SXT9RhMcqHg/wuOxB7ivrCud8d+E7DxTpZt51CXKDMEwHKn/AAr6LI88lgpeyq603+Hmv1Pj+J+GIZnB16KtVX/k3k/Psz5p0uxu9Tv4rGxheaeVtqIoyTX0T8OPBNp4XsRJKFm1GQfvZf7v+yvtR8OfBFn4UtDIxW41CUfvJsdB/dX0FdfWue568W3RoP3Pz/4BhwvwtHL0sTiVeq9l/L/wQooor5g+3CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKrarf2ml2E19fTpBbwrud2OAKTVdQtNLsJb6+mWGCJcszGvm34nePLzxXfmGEtDpkTfuos/f/wBpvevZyfJ6uZVbLSC3f6LzPBz3PaOVUbvWb2X6vyH/ABM8d3fiq/MULPDpsR/dRdN3+03vXF5pm6jdX6phsLTw1JUqSskfjGLxVbGVnWrO8mSZ96M0wGnSq8UhjkRkdeCrDBFb2OXlFzWn4b1y/wBA1SPUNPmMciHkdmHoR6Vk7hS596ipTjUi4zV0y6c50pqcHZrZn1X4C8W2HizShc27BLmMATwZ5Q/4V0dfIfhvXL/w/qseo6dMY5U6js47gj0r6Y8BeLtP8WaUtzbER3KAefATyh/qPevzPPshlgJe1pa03+Hk/wBGfr/DfEkcygqNbSqv/JvNefdHSUUUV80fWhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFVtVv7TS7CW+vp1ht4l3O7HpSatqNnpWny319OsMES7mZjXzP8UfH154tv8AyYWeDS4WPlQ5xvP95vU/yr2smyarmVWy0gt3+i8zws8zylldK71m9l+r8h/xP8eXfiy/MUJaHTIm/cxZ5b/ab3ris0zNGa/V8NhaWFpKlSVkj8bxeKrYuq61Z3kx+aX2pmTXsnwc+GrXBh8Qa/CViB3W9sw5b0Zh6e1YZhj6OAourVfourfY3y3LK+Y11Ror1fRLux3wc+GxuPK1/X4SIgQ1tbuPvf7TD09BXT/Fv4dQ6/bPqukxpFqca5KgYE4Hb6+9ekKAqhVAAHAApa/L62fYupi1ilKzWy6W7f5n63R4bwVPBPBuN0931v3/AMj4wuIZred4J42jlQlWVhgg0zNfRHxc+HUXiCB9W0mNI9TRcso4EwH9a+eLiKW2neCeNo5UO1kYYIPpX6TlOa0cypc8NJLddv8AgH5XnOS1srrck9YvZ9/+CJmtPw1rl/4f1WLUdPmMcqHkZ4Yeh9qys0Zr0qlONSLhNXTPKpznSmpwdmtmfWHgDxfp/i3SVubZhHcoAJ4CfmQ/1HvXSV8feGdd1Dw9q0WpabMY5UPIz8rr3UjuK+nPAHjDT/FulLcW7CO6QYngJ5Q/1FfmGfZBLAS9rS1pv8PJ/oz9c4c4jjmMFRraVV/5N5rz7o6WiiivmT6wKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAOY+JXhRfFvh5rAXDwTod8LA/KW9GHcV8s65pV/oupzadqNu0FxC2GVu/uPUV9m1x/xM8DWHi/TD8qw6jEP3E4HP+6fUV9Tw7n7wEvY1v4b/AAf+Xc+S4k4eWYR9vR/iL8V29ex8q5o+gq9ruk32i6nLp2oQNFPG2CCOvuPUV6v8Gvhk1w0PiDxDBiH71tbOPvejMPT2r9Cx2Z4fB4f283o9rdfQ/OMBlWIxuI+rwjZre/T1F+DPw0NwYfEPiCDEQ+a2tnH3vRmHp6Cvc1AVQqgADgAUKoVQqgAAYAHalr8lzPMq2Y1nVqv0XRI/YsryuhltBUqS9X1bCiiivOPSCvNfi98OYvEVu+raTEseqRrllHAnHoff3r0qiuvBY2tgqyq0nZr8fJnHjsDRx1F0ayun+Hmj4ruIZbad4J42ilQlWRhgg1Hmvoz4vfDiLxDA+raRGseqRjLIOBOP8a8AsdI1K91hdIgtJDetJ5ZiK4Knvn0xX6zleb0Mwoe1Ts1uu3/A8z8dzbJK+XYj2TV0/hff/g+Q3SbC81XUIbCwgee4mYKiKOpr6a+F3ga28IaaWkYTajOo8+Tsv+yvtTPhd4DtPCWnCWULNqcq/vZcfd/2V9q7avh+IeIHjG6FB/u1u+//AAD73hrhuOBSxGIV6j2X8v8AwQooor5M+xCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAxdd8LaHrd/a32pWKTT2rbo2Pf2PqK2VAVQqgAAYAHalorSVWc4qMm2lt5GcKNOEnKMUm9/MKKKKzNAooooAKKKKACs+HRdLh1iXV4rKFb6VQrzBfmIrQoqozlG/K7XJlCMrOSvYKKKKkoKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//2Q==" + simplexTeamContactProfile :: Profile simplexTeamContactProfile = Profile { displayName = "Ask SimpleX Team", fullName = "", shortDescr = Just "Send questions about SimpleX Chat app and your suggestions", - image = Just (ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8KCwkMEQ8SEhEPERATFhwXExQaFRARGCEYGhwdHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAETARMDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7LooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiivP/iF4yFvv0rSpAZek0yn7v+yPeunC4WpiqihBf8A8rOc5w2UYZ4jEPTourfZDvH3jL7MW03SpR53SWUfw+w96veA/F0erRLY3zKl6owD2k/8Ar15EWLEljknqadDK8MqyxMUdTlWB5Br66WS0Hh/ZLfv1ufiNLj7Mo5m8ZJ3g9OTpy+Xn5/pofRdFcd4B8XR6tEthfMEvVHyk9JB/jXY18fiMPUw9R06i1P3PK80w2aYaOIw8rxf3p9n5hRRRWB6AUUVDe3UFlavc3MixxIMsxppNuyJnOMIuUnZIL26gsrV7m5kWOJBlmNeU+I/Gd9e6sk1hI8FvA2Y1z973NVPGnimfXLoxRFo7JD8if3vc1zefevr8syiNKPtKyvJ9Ox+F8Ycb1cdU+rYCTjTi/iWjk1+nbue3eEPEdtrtoMER3SD95Hn9R7Vu18+6bf3On3kd1aSmOVDkEd/Y17J4P8SW2vWY6R3aD97F/Ue1eVmmVPDP2lP4fyPtODeMoZrBYXFO1Zf+Tf8AB7r5o3qKKK8Q/QgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAqavbTXmmz20Fw1vJIhVZB1FeDa3p15pWoSWl6hWQHr2YeoNfQlY3izw9Z6/YGGZQky8xSgcqf8K9jKcyWEnyzXuv8D4njLhZ51RVSi7VYLRdGu3k+z+88HzRuq1rWmXmkX8lnexFHU8Hsw9RVLNfcxlGcVKLumfgFahUozdOorSWjT6E0M0kMqyxOyOpyrKcEGvXPAPjCPVolsb9wl6owGPAkH+NeO5p8M0kMqyxOyOpyrA4INcWPy+njKfLLfoz2+HuIMTkmI9pT1i/ij0a/wA+zPpGiuM+H/jCPV4lsL91S+QfKTwJR/jXW3t1BZWslzcyLHFGMsxNfB4jC1aFX2U1r+fof0Rl2bYXMMKsVRl7vXy7p9rBfXVvZWr3NzKscSDLMTXjnjbxVPrtyYoiY7JD8if3vc0zxv4ruNeujFEWjsoz8if3vc1zOa+synKFh0qtVe9+X/BPxvjLjKWZSeEwjtSW7/m/4H5kmaM1HmlB54r3bH51YkzXo3wz8MXMc0es3ZeED/VR5wW9z7VB8O/BpnMerarEREDuhhb+L3Pt7V6cAAAAAAOgFfL5xmqs6FH5v9D9a4H4MlzQzHGq1tYR/KT/AEXzCiiivlj9hCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAxfFvh208QWBhmASdRmKUdVP+FeH63pl5pGoSWV5EUdTwezD1HtX0VWL4t8O2fiHTzBONk6g+TKByp/wr28pzZ4WXs6msH+B8NxdwhTzeDxGHVqy/8m8n59n954FmjNW9b0y80fUHsr2MpIp4PZh6iqWfevuYyjOKlF3TPwetQnRm6dRWktGmSwzSQyrLE7I6nKsDgg1teIPFOqa3a29vdy4jiUAheN7f3jWBmjNROhTnJTkrtbGtLF4ijSnRpzajPddHbuP3e9Lmo80ua0scth+a9E+HXgw3Hl6tqsZEX3oYmH3vc+1J8OPBZnKavq0eIhzDCw+9/tH29q9SAAAAGAOgr5bOM35b0KD16v8ARH6twXwXz8uPx0dN4xfXzf6IFAUAAAAdBRRRXyZ+wBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFB4GTXyj+1p+0ONJjufA3ga6DX7qU1DUY24gB4McZH8Xqe38tqFCdefLETaSufQ3h/4geEde8Uah4a0rWra51Ow/wBfCrD8ceuO+OldRX5I+GfEWseG/ENvr2j30ttqFvJ5iSqxyT3z6g96/RH9nD41aT8U9AWGcx2fiK1QC7tC33/+mieqn07V14zL3QXNHVEQnc9dooorzjQKKKKACiis7xHrel+HdGudY1m8is7K2QvLLI2AAP600m3ZAYfxUg8Pr4VutT1+7isYbSMuLp/4Pb3z6V8++HNd0zxDpq6hpVys8DHGRwVPoR2NeIftJ/G7VPifrbWVk8lp4btZD9mtwcGU/wDPR/c9h2rgfh34z1LwdrAurV2ktZCBcW5PyyD/AB9DX2WTyqYWny1Ho+nY+C4t4Wp5tF16CtVX/k3k/Ps/vPr/ADRmsjwx4g07xFpMWpaZOJInHI/iQ9wR61qbq+mVmro/D6tCdGbp1FZrdEma6/4XafpWoa7jUpV3oA0MLdJD/ntXG5p8E0kMqyxOyOhyrKcEGsMTRlWpShGVm+p1ZbiYYPFQr1IKai72fU+nFAUAKAAOABRXEfDnxpFrMK6fqDhL9BhSeko9frXb1+a4rDVMNUdOotT+k8szLD5lh44jDu8X968n5hRRRXOegFFFFABUGoXlvYWkl1dSrHFGMliaL+7t7C0kuruVYoYxlmNeI+OvFtx4huzHFuisYz+7jz97/aNenluW1MbU00it2fM8S8SUMkoXetR/DH9X5fmeteF/E+m+IFkFoxSWMnMb9cev0rbr5t0vULrTb6K8s5TFNGcgj+R9q9w8E+KbXxDYjlY7xB+9i/qPaurNsneE/eUtYfkeTwlxjHNV9XxVo1V90vTz8vmjoqKKK8I+8CiiigAooooAKKKKACiiigD5V/a8+P0mgvdeAvCUskepFdl9eDjyQR9xPfHeviiR3lkaSR2d2OWZjkk+tfoj+058CtP+Jektq2jxRWnie2T91KMKLlR/yzf+h7V+fOuaVqGiarcaXqtpLaXls5jlikXDKRX0mWSpOlaG/U56l76lKtPwtr+reGNetdb0S8ls761cPHJG2D9D6g9MVmUV6TSasyD9Jf2cfjXpPxR0MW9w0dp4gtkAubYnHmf7aeo/lXr1fkh4W1/V/DGuW2taHey2d9bOHjkjP6H1HtX6Jfs5fGvR/inoQgmeOz8RWqD7XaE439vMT1U+navnMfgHRfPD4fyN4Tvoz12iis7xJremeHdEutZ1i7jtLK1jLyyucAAf1rzUm3ZGgeJNb0vw7otzrOs3kVpZWyF5ZZDgAD+Z9q/PL9pP436r8UNZaxs2ks/Dlq5+z24ODMf77+p9B2o/aU+N2p/FDXDZ2LS2fhy1ci3t84Mx/wCej+/oO1eNV9DgMAqS55/F+RhOd9EFFFABJwBkmvUMzqPh34y1Lwjq63FszSWshAntyeHHt719Z2EstzpVlqD2txbR3kCzxLPGUbawyODXK/slfs8nUpbXx144tGFkhElhp8q4849pHB/h9B3r608X+GLDxBpX2WRFiljX9xIowUPYfT2rGnnkMPWVJ6x6vt/XU+P4o4SjmtN4igrVV/5N5Pz7P7z56zRmrmvaVe6LqMljexMkiHg9mHqKoZr6uEozipRd0z8Rq0J0ZunUVmtGmTwTSQTJNC7JIhyrKcEGvZvhz41j1mJdP1GRUv0GFY8CX/69eJZqSCaWCVZYXZHU5VlOCDXDmGXU8bT5ZaPo+x7WQZ9iMlxHtKesX8UejX+fZn1FRXDfDbxtHrUKadqDqmoIuAx4EoHf613NfnWKwtTC1HTqKzR/QGW5lh8yw8cRh3eL+9Ps/MKr6heW1hZyXd3KsUUYyzGjUby20+zku7yZYoY13MzGvDPHvi+48RXpjiZorCM/u4/73+0feuvLMsqY6pZaRW7/AK6nlcScR0MloXetR/DH9X5D/Hni648Q3nlxlo7GM/u48/e9zXL7qZmjNfodDDwoU1TpqyR+AY7G18dXlXryvJ/19w/dVvSdRutMvo7yzlaOVDkY7+xqkDmvTPhn4HMxj1jV4v3Y+aCFh97/AGjWGPxNHDUXKrt27+R15JlWLzHFxp4XSS1v/L53PQ/C+oXGqaJb3t1bNbyyLkoe/v8AQ1p0AAAAAADoBRX5nUkpSbirLsf0lh6c6dKMJy5mkrvv5hRRRUGwUUUUAFFFFABRRRQAV4d+038CdO+JWkyavo8cdp4mtkzHIBhbkD+B/f0Ne40VpSqypSUovUTV9GfkTruk6joer3Ok6taS2d7ayGOaGVdrKRVKv0T/AGnfgXp/xK0h9Y0iOO18TWqZikAwLkD+B/6Gvz51zStQ0TVbjS9UtZbW8tnKSxSLgqRX1GExccRG636o55RcSlWp4V1/VvDGvWut6JeSWl9bOGjkQ4/A+oPpWXRXU0mrMk/RP4LftDeFvF3ge41HxDfW+lappkG+/idsBwP40HfJ7V8o/tJ/G/VPifrbWVk8tn4btn/0e2zgykfxv6n0HavGwSM4JGeuO9JXFRwFKlUc18vIpzbVgoooAJIAGSa7SQr6x/ZM/Z4k1J7Xxz44tClkMSWFhIuDL3Ejg/w+g70fsmfs8NqMtt448c2eLJCJLCwlX/WnqHcH+H0HevtFFVECIoVVGAAMACvFx+PtenTfqzWEOrEjRI41jjUIigBVAwAPSnUUV4ZsYXjLwzZeJNOaCcBLhQfJmA5U/wCFeBa/pV7ompSWF9GUkToccMOxHtX01WF4z8M2XiXTTBOAk6AmGYDlD/hXvZPnEsHL2dTWD/A+K4r4UhmsHXoK1Zf+TeT8+z+8+c80Zq5r2k3ui6jJY30ZSRTwezD1FUM1+gQlGcVKLumfiFWjOjN06is1umTwTSQTJNE7JIh3KynBBr2PwL8QrO701odbnSC5t0yZCcCUD+teK5pd1cWPy2ljoctTdbPqetkme4rJ6rqUHdPdPZ/8Mdb4/wDGFz4ivDFGxisIz+7j/ve5rls1HuozXTQw1PD01TpqyR5+OxlfHV5V68ryf9fcSZozTAa9P+GHgQzmPWdZhIjHzQQMPvf7R9qxxuMpYOk6lR/8E6MpyfEZriFQoL1fRLux/wAMvApmMesazFiP70EDfxf7R9vavWFAUAAAAcACgAAAAAAdBRX5xjsdVxtXnn8l2P3/ACXJcNlGHVGivV9W/wCugUUUVxHrhRRRQAUUUUAFFFFABRRRQAUUUUAFeH/tOfArT/iXpUmsaSsVp4mto/3UuMLcgDhH/oe1e4Vn+I9a0zw7otzrGsXkVpZWyF5ZZGwAB/WtaNSdOalDcTSa1PyZ1zStQ0TVrnStVtZLS8tnMcsUgwVIqlXp/wC0l8S7T4nePn1aw0q3srO3XyYJBGBNOoPDSHv7DtXmFfXU5SlBOSszlYUUUVYAAScDk19Zfsmfs7vqLW3jjx1ZFLMESafYSjmXuJHHZfQd6+VtLvJtO1K2v7cRtLbyrKgkQOpKnIyp4I46Gv0b/Zv+NOjfFDw+lrIIrDX7RAtzZ8AMMffj9V9u1efmVSrCn7m3Vl00m9T16NEjjWONVRFGFUDAA9KWiivmToCiiigAooooAwfGnhiy8S6cYJwEuEH7mYDlT/hXz7r+k32h6lJYahFskQ8Hsw9QfSvpjUr2106ykvLyZYYYxlmY18+/EXxa/ijU1aOMRWkGRCCBuPuT/Svr+GK2KcnTSvT/ACfl/kfmPiBhMvUI1m7Vn0XVefp0fy9Oa3UbqZmjNfa2PynlJM+9AOajzTo5GjkV0YqynIPoaVg5T1P4XeA/P8vWdaiIj+9BAw+9/tH29q9dAAAAAAHQVwPwx8dQ63Ammai6R6hGuFJ4Ew9vf2rvq/Ms5qYmeJaxGjWy6W8j+gOFcPl9LAReBd0931b8+3oFFFFeSfSBRRRQAUUUUAFFFFABRRRQAUUUUAFFFZ3iTW9L8OaJdazrN5HaWNqheWWQ4AH+NNJt2QB4l1vTPDmiXWs6xdx2llaxl5ZHOAAO3ufavzx/aT+N2qfFDWzZWbSWfhy2ci3tg2DKf77+p9B2pf2lfjdqfxQ1trGxeW08N2z/AOj2+cGYj/lo/v6DtXjVfQ4DAKkuefxfkYTnfRBRRQAScAZNeoZhRXv3w2/Zh8V+Lfh7deJprgadcvHv02zlT5rgdcsf4Qe1eHa5pWoaJq1zpWq2ktpeW0hjlikXDKwrOFanUk4xd2htNFKtTwrr+reGNdtta0S8ltL22cPHIhx07H1HtWXRWjSasxH6S/s4/GrSfijoYtp3jtfENqg+1WpON4/vp6j27V69X5IeFfEGr+F9etdc0O9ks7+1cPHKh/QjuD3Ffoj+zl8bNI+KWhLbztFZ+IraMfa7TON+Osieqn07V85j8A6L54fD+RvCd9GevUUUV5hoFVtTvrXTbGW9vJligiXczNRqd9aabYy3t7MsMEQyzMa+ffiN42uvE96YoS0OmxH91F3b/ab3r1spympmFSy0it3+i8z57iDiCjlFG71qPZfq/Id8RPGl14lvTFEzRafGf3cf97/aNclmmZozX6Xh8NTw1NU6askfheNxdbG1pV68ryY/NGTTM16R4J+GVxrGkSX+pSSWfmJ/oq45J7MR6Vni8ZRwkOes7I1y7K8TmNX2WHjd7/0zzvJozV3xDpF7oepyWF/EUkQ8HHDD1FZ+feuiEozipRd0zjq0Z0puE1ZrdE0E8sEyTQu0ciHKspwQa9z+GHjuLXIU0zUpFTUEXCseBKB/WvBs1JBPLBMk0LmORCGVlOCDXn5lllLH0uWWjWz7HsZFnlfJ6/tKesXuu6/z7M+tKK4D4X+PItdhTTNSdY9SQYVicCYDuPf2rv6/M8XhKuEqulVVmj92y7MaGYUFXoO6f4Ps/MKKKK5juCiiigAooooAKKKKACiig9KAM7xLrmleG9EudZ1q8jtLG2QvLK5wAPQep9q/PH9pP43ap8T9beyspJbTw3bSH7NbZx5pH8b+p9u1bH7YPxL8XeJPG114V1G0udH0jT5SIrNuDOR0kbs2e3pXgdfRZfgVTSqT3/IwnO+iCiigAkgAZJr1DMK+s/2TP2d31Brbxz46tNtmMSafp8i8y9/MkB6L0wO9J+yb+zwdSe28b+ObLFmpEljYSr/rT1DuP7voO9faCKqIERQqqMAAYAFeLj8fa9Om/VmsIdWEaJGixooVFGFUDAA9K8Q/ac+BWnfErSZNY0mOO08T2yZilAwtyAPuP/Q9q9worx6VWVKSlF6mrSasfkTrmlahomrXOlaray2l7bSGOaKRcMrCqVfon+098C7D4l6U+s6Skdr4mtY/3UmMC5UdI29/Q1+fOt6XqGi6rcaVqlrJa3ls5SWKQYKkV9RhMXHERut+qOeUeUpVqeFfEGreGNdttb0W7ktb22cNG6HH4H1FZdFdTSasyT9Jf2cPjVpXxR0Fbe4eK18Q2qD7Va7sbx/z0T1H8q9V1O+tdNsZb29mWGCJdzMxr8ovAOoeIdK8W2GoeF5podVhlDQtEefcH2PevsbxP4417xTp1jDq3lQGKFPOigJ2NLj5m59849K4KHD0sTX9x2h18vJHj55xDSyqhd61Hsv1fkaXxG8bXXie9MURaLTo2/dR5+9/tH3rkM1HmjNffYfC08NTVOmrJH4ljMXWxtaVau7yZJmgHmmAmvWfhN8PTceVrmuQkRDDW9uw+9/tN7Vjj8dSwNJ1ar9F3OjK8pr5nXVGivV9Eu7H/Cf4emcx63rkJEfDW9u4+9/tMPT2r2RQFAVQABwAKAAAAAAB0Aor8uzDMKuOq+0qfJdj9zyjKMPlVBUaK9X1bOf8b+FbHxRppt7gCO4UfuZwOUP9R7V86+IdHv8AQtTk0/UIikqHg9mHqD6V9VVz3jnwrY+KNMNvcKEuEBME2OUP+FenkmdywUvZVdab/A8PijheGZw9vQVqq/8AJvJ+fZnzLuo3Ve8Q6Pf6FqclhqERjkQ8Hsw9Qazs1+jwlGpFSi7pn4xVozpTcJqzW6J7eeSCZJoZGjkQhlZTgg17t8LvHsWuQppmpOseooMKxPEw/wAa8DzV3Q7fULvVIIdLWQ3ZcGMx8EH1z2rzs1y2jjaLVTRrZ9v+AezkGcYnK8SpUVzKWjj3/wCD2PrCiqOgx38Oj20eqTJNeLGBK6jAJq9X5VOPLJq9z98pyc4KTVr9H0CiiipLCiiigAooooAKKKKAPK/2hfg3o/xT8PFdsVprlupNnebec/3W9VNfnR4y8Naz4R8RXWg69ZvaXts5V1YcEdmB7g9jX6115V+0P8GtF+Knh05SO0161UmzvQuD/uP6qf0r08DjnRfJP4fyM5wvqj80RycCvrP9kz9ndtRNr458dWTLaAiTT9PlXBl9JJB/d7gd+tXv2bv2Y7yz19vEHxFs1VbKYi1sCQwlZTw7f7PcDvX2CiLGioihVUYAAwAK6cfmGns6T9WTCHVhGiRoqRqFRRgKBgAUtFFeGbBRRRQAV4h+038CtP8AiZpTatpCQ2fia2jPlS4wtyo52P8A0Pavb6K0pVZUpKUXqJq+jPyJ1zStQ0TVrnStVtJbS9tnMcsUgwVIqPS7C61O+isrKFpZ5W2qor9AP2r/AIM6J448OzeJLV7fTtesoyRO3yrcqP4H9/Q14F8OvBlp4XsvMkCTajKP3suM7f8AZX0H86+1yiDzFcy0S3Pms+zqllNLXWb2X6vyH/DnwZaeF7EPIEm1CUDzZcfd/wBke1dfmo80ua+0pUY0oqMVofjWLxNXF1XWrO8mSZozUea9N+B/hTTdau5NUv5opvsrjbak8k9mYelc+OxcMHQlWqbI1y3LqmYYmOHpbvuafwj+HhnMWva5DiMENb27D73ozD09q9oAAAAAAHQCkUBVCqAAOABS1+U5jmNXH1XUqfJdj9yyjKKGV0FRor1fVsKKKK4D1AooooA57xz4UsPFOmG3uFEdwgJgnA5Q/wBR7V84eI9Gv9A1SXT9RhMcqHg/wuOxB7ivrCud8d+E7DxTpZt51CXKDMEwHKn/AAr6LI88lgpeyq603+Hmv1Pj+J+GIZnB16KtVX/k3k/Psz5p0uxu9Tv4rGxheaeVtqIoyTX0T8OPBNp4XsRJKFm1GQfvZf7v+yvtR8OfBFn4UtDIxW41CUfvJsdB/dX0FdfWue568W3RoP3Pz/4BhwvwtHL0sTiVeq9l/L/wQooor5g+3CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKrarf2ml2E19fTpBbwrud2OAKTVdQtNLsJb6+mWGCJcszGvm34nePLzxXfmGEtDpkTfuos/f/wBpvevZyfJ6uZVbLSC3f6LzPBz3PaOVUbvWb2X6vyH/ABM8d3fiq/MULPDpsR/dRdN3+03vXF5pm6jdX6phsLTw1JUqSskfjGLxVbGVnWrO8mSZ96M0wGnSq8UhjkRkdeCrDBFb2OXlFzWn4b1y/wBA1SPUNPmMciHkdmHoR6Vk7hS596ipTjUi4zV0y6c50pqcHZrZn1X4C8W2HizShc27BLmMATwZ5Q/4V0dfIfhvXL/w/qseo6dMY5U6js47gj0r6Y8BeLtP8WaUtzbER3KAefATyh/qPevzPPshlgJe1pa03+Hk/wBGfr/DfEkcygqNbSqv/JvNefdHSUUUV80fWhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFVtVv7TS7CW+vp1ht4l3O7HpSatqNnpWny319OsMES7mZjXzP8UfH154tv8AyYWeDS4WPlQ5xvP95vU/yr2smyarmVWy0gt3+i8zws8zylldK71m9l+r8h/xP8eXfiy/MUJaHTIm/cxZ5b/ab3ris0zNGa/V8NhaWFpKlSVkj8bxeKrYuq61Z3kx+aX2pmTXsnwc+GrXBh8Qa/CViB3W9sw5b0Zh6e1YZhj6OAourVfourfY3y3LK+Y11Ror1fRLux3wc+GxuPK1/X4SIgQ1tbuPvf7TD09BXT/Fv4dQ6/bPqukxpFqca5KgYE4Hb6+9ekKAqhVAAHAApa/L62fYupi1ilKzWy6W7f5n63R4bwVPBPBuN0931v3/AMj4wuIZred4J42jlQlWVhgg0zNfRHxc+HUXiCB9W0mNI9TRcso4EwH9a+eLiKW2neCeNo5UO1kYYIPpX6TlOa0cypc8NJLddv8AgH5XnOS1srrck9YvZ9/+CJmtPw1rl/4f1WLUdPmMcqHkZ4Yeh9qys0Zr0qlONSLhNXTPKpznSmpwdmtmfWHgDxfp/i3SVubZhHcoAJ4CfmQ/1HvXSV8feGdd1Dw9q0WpabMY5UPIz8rr3UjuK+nPAHjDT/FulLcW7CO6QYngJ5Q/1FfmGfZBLAS9rS1pv8PJ/oz9c4c4jjmMFRraVV/5N5rz7o6WiiivmT6wKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAOY+JXhRfFvh5rAXDwTod8LA/KW9GHcV8s65pV/oupzadqNu0FxC2GVu/uPUV9m1x/xM8DWHi/TD8qw6jEP3E4HP+6fUV9Tw7n7wEvY1v4b/AAf+Xc+S4k4eWYR9vR/iL8V29ex8q5o+gq9ruk32i6nLp2oQNFPG2CCOvuPUV6v8Gvhk1w0PiDxDBiH71tbOPvejMPT2r9Cx2Z4fB4f283o9rdfQ/OMBlWIxuI+rwjZre/T1F+DPw0NwYfEPiCDEQ+a2tnH3vRmHp6Cvc1AVQqgADgAUKoVQqgAAYAHalr8lzPMq2Y1nVqv0XRI/YsryuhltBUqS9X1bCiiivOPSCvNfi98OYvEVu+raTEseqRrllHAnHoff3r0qiuvBY2tgqyq0nZr8fJnHjsDRx1F0ayun+Hmj4ruIZbad4J42ilQlWRhgg1Hmvoz4vfDiLxDA+raRGseqRjLIOBOP8a8AsdI1K91hdIgtJDetJ5ZiK4Knvn0xX6zleb0Mwoe1Ts1uu3/A8z8dzbJK+XYj2TV0/hff/g+Q3SbC81XUIbCwgee4mYKiKOpr6a+F3ga28IaaWkYTajOo8+Tsv+yvtTPhd4DtPCWnCWULNqcq/vZcfd/2V9q7avh+IeIHjG6FB/u1u+//AAD73hrhuOBSxGIV6j2X8v8AwQooor5M+xCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAxdd8LaHrd/a32pWKTT2rbo2Pf2PqK2VAVQqgAAYAHalorSVWc4qMm2lt5GcKNOEnKMUm9/MKKKKzNAooooAKKKKACs+HRdLh1iXV4rKFb6VQrzBfmIrQoqozlG/K7XJlCMrOSvYKKKKkoKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//2Q=="), + image = Just simplexChatImage, contactLink = Just $ CLFull adminContactReq, peerType = Nothing, preferences = Nothing @@ -2640,3 +2790,6 @@ timeItToView s action = do epochStart :: UTCTime epochStart = UTCTime (fromGregorian 1970 1 1) (secondsToDiffTime 0) + +drgRandomBytes :: Int -> CM ByteString +drgRandomBytes n = asks random >>= atomically . C.randomBytes n diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index df9825765d..5e6d3dd326 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -25,7 +25,7 @@ import Control.Monad.Reader import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (lefts, partitionEithers, rights) -import Data.Foldable (foldr') +import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find) @@ -46,7 +46,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (batchDeliveryTasks1) +import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) @@ -54,13 +54,15 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.ContactRequest -import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Delivery +import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups 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 @@ -71,19 +73,22 @@ import Simplex.FileTransfer.Protocol (FilePartyI) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForWork, withWork_, withWorkItems) -import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) +import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForUserNetwork, waitForWork, waitWhileSuspended, withWorkItems, withWork_) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) +import Simplex.Messaging.Agent.RetryInterval (withRetryInterval) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (ProxyClientError (..), NetworkRequestMode (..)) +import Simplex.Messaging.Client (NetworkRequestMode (..), ProxyClientError (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..)) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TransportError (..)) import Simplex.Messaging.Util @@ -218,7 +223,7 @@ processAgentMsgSndFile _corrId aFileId msg = do Nothing -> eToView $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId} _scope) -> do - -- TODO [channels fwd] single description for all recipients + -- TODO [relays] single description for all recipients ms <- getRecipients let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms) extraRFDs = drop (length rfdsMemberFTs) rfds @@ -232,7 +237,7 @@ processAgentMsgSndFile _corrId aFileId msg = do toView $ CEvtSndFileCompleteXFTP user ci' ft where getRecipients - | isTrue (useRelays g) = withStore' $ \db -> getGroupRelays db vr user g + | useRelays' g = withStore' $ \db -> getGroupRelayMembers db vr user g | otherwise = withStore' $ \db -> getGroupMembers db vr user g memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') @@ -265,13 +270,13 @@ processAgentMsgSndFile _corrId aFileId msg = do unless (null errs') $ toView $ CEvtChatErrors errs' pure delivered where - connDescrEvents :: Int -> NonEmpty (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) + connDescrEvents :: Int -> NonEmpty (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) connDescrEvents partSize = L.fromList $ concatMap splitText (L.toList connsTransfersDescrs) where - splitText :: (Connection, SndFileTransfer, RcvFileDescrText) -> [(Connection, (ConnOrGroupId, ChatMsgEvent 'Json))] + splitText :: (Connection, SndFileTransfer, RcvFileDescrText) -> [(Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json))] splitText (conn, _, rfdText) = - map (\fileDescr -> (conn, (connOrGroupId, XMsgFileDescr {msgId = sharedMsgId, fileDescr}))) (L.toList $ splitFileDescr partSize rfdText) - toMsgReq :: (Connection, (ConnOrGroupId, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq + map (\fileDescr -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr}))) (L.toList $ splitFileDescr partSize rfdText) + toMsgReq :: (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq toMsgReq (conn, _) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId])) sendFileError :: FileError -> Text -> VersionRangeChat -> FileTransferMeta -> CM () @@ -374,7 +379,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvGroupMsgConnection conn gInfo m -> processGroupMessage agentMessage entity conn gInfo m UserContactConnection conn uc -> - processUserContactRequest agentMessage entity conn uc + processContactConnMessage agentMessage entity conn uc where updateConnStatus :: ConnectionEntity -> CM ConnectionEntity updateConnStatus acEntity = case agentMsgConnStatus (entityConnection acEntity) agentMessage of @@ -412,15 +417,41 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processDirectMessage agentMsg connEntity conn@Connection {connId, connChatVersion, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId pqSupport _ connInfo -> do - conn' <- processCONFpqSupport conn pqSupport - -- [incognito] send saved profile - (conn'', gInfo_) <- saveConnInfo conn' connInfo - incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True - -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend + chatRelayTests_ <- asks chatRelayTests + relayTest_ <- atomically $ TM.lookup agentConnId chatRelayTests_ + case relayTest_ of + Just RelayTest {challenge, rootKey, result = testVar} -> do + r <- tryAllErrors $ do + ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + case chatMsgEvent of + XGrpRelayTest _challenge sigBytes_ -> + case sigBytes_ of + Just sigBytes -> case C.decodeSignature sigBytes of + Right sig + | C.verify' rootKey sig challenge -> + atomically $ putTMVar testVar Nothing + | otherwise -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify (ChatError $ CERelayTestError "invalid signature")) + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify (ChatError $ CERelayTestError $ "signature decoding failed: " <> e)) + Nothing -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify (ChatError $ CERelayTestError "no signature in response")) + _ -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse (ChatError $ CERelayTestError "unexpected message type")) + case r of + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse e) + Right () -> pure () + Nothing -> do + conn' <- processCONFpqSupport conn pqSupport + -- [incognito] send saved profile + (conn'', gInfo_) <- saveConnInfo conn' connInfo + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + let profileToSend = case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + -- [async agent commands] no continuation needed, but command should be asynchronous for stability + allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport void $ saveConnInfo conn connInfo @@ -466,7 +497,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ct', conn') <- updateContactPQRcv user ct conn pqEncryption checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchAllErrors` \_ -> pure () forM_ aChatMsgs $ \case - Right (ACMsg _ chatMsg) -> + Right (APMsg _ (ParsedMsg _ _ chatMsg)) -> processEvent ct' conn' tags eInfo chatMsg `catchAllErrors` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) @@ -481,13 +512,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let tag = toCMEventTag chatMsgEvent atomically $ modifyTVar' tags (tshow tag :) logInfo $ "contact msg=" <> tshow tag <> " " <> eInfo - let body = chatMsgToBody chatMsg - (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta body chatMsg + (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta chatMsg let ct'' = ct' {activeConn = Just conn''} :: Contact case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent _ ttl live _msgScope -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile @@ -507,12 +537,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XCallEnd callId -> xCallEnd ct'' callId msg BFileChunk sharedMsgId chunk -> bFileChunk ct'' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - checkSendRcpt :: Contact -> [AChatMessage] -> CM Bool + checkSendRcpt :: Contact -> [AParsedMsg] -> CM Bool checkSendRcpt ct' aMsgs = do let Contact {chatSettings = ChatSettings {sendRcpts}} = ct' pure $ fromMaybe (sendRcptsContacts user) sendRcpts && any aChatMsgHasReceipt aMsgs where - aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = + aChatMsgHasReceipt (APMsg _ (ParsedMsg _ _ ChatMessage {chatMsgEvent})) = hasDeliveryReceipt (toCMEventTag chatMsgEvent) RCVD msgMeta msgRcpt -> withAckMessage' "contact rcvd" agentConnId msgMeta $ @@ -596,7 +626,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode - -- TODO REMOVE LEGACY ^^^ + -- TODO REMOVE LEGACY ^^^ SENT msgId proxy -> do void $ continueSending connEntity conn sentMsgDeliveryEvent conn msgId @@ -673,7 +703,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendAutoReply ct mc = \case Just UserContactRequest {welcomeSharedMsgId = Just smId} -> - void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing + void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing _ -> do (msg, _) <- sendDirectContactMessage user ct $ XMsgNew $ MCSimple $ extMsgContent mc Nothing ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) @@ -738,17 +768,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" + XGrpRelayAcpt relayLink + | memberRole' membership == GROwner && isRelay m -> do + withStore' $ \db -> setRelayLinkConfId db m confId relayLink + void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink + | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" _ -> messageError "CONF from invited member must have x.grp.acpt" GCHostMember -> case chatMsgEvent of - XGrpLinkInv glInv -> do - -- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records - (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv - -- [incognito] send saved profile - incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - allowAgentConnectionAsync user conn' confId $ XInfo profileToSend - toView $ CEvtGroupLinkConnecting user gInfo' m' + XGrpLinkInv glInv@GroupLinkInvitation {groupProfile = GroupProfile {publicGroup = rcvPG}} + | let GroupInfo {groupProfile = GroupProfile {publicGroup = curPG}} = gInfo + pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), + useRelays' gInfo == isJust rcvPG && pgId rcvPG == pgId curPG -> do + -- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv + -- [incognito] send saved profile + incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) + let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + allowAgentConnectionAsync user conn' confId $ XInfo profileToSend + toView $ CEvtGroupLinkConnecting user gInfo' m' + | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersRejected db vr user gInfo m glRjct toView $ CEvtGroupLinkConnecting user gInfo' m' @@ -787,7 +826,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do -- TODO [knocking] send pending messages after accepting? -- possible improvement: check for each pending message, requires keeping track of connection state - unless (connDisabled conn) $ sendPendingGroupMessages user m conn + unless (connDisabled conn) $ sendPendingGroupMessages user gInfo m conn withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do @@ -802,41 +841,63 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (m {memberStatus = GSMemConnected}, gInfo') toView $ CEvtUserJoinedGroup user gInfo' m' (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' - let cd = CDGroupRcv gInfo'' scopeInfo m'' - createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing - let prepared = preparedGroup gInfo'' - unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' - memberConnectedChatItem gInfo'' scopeInfo m'' - let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> prepared - unless (memberPending membership || isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo'' m'' - GCInviteeMember -> do - (gInfo', mStatus) <- - if not (memberPending m) - then do - mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected - pure (gInfo, mStatus) - else do - gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo - pure (gInfo', memberStatus m) - (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - memberConnectedChatItem gInfo'' scopeInfo m' - case scopeInfo of - Just (GCSIMemberSupport _) -> do - createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing - _ -> pure () - toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} - let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' - when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing) - case mStatus of - GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators vr user gInfo'' m' - _ -> do - introduceToAll vr user gInfo'' m' - let memberIsCustomer = case businessChat gInfo'' of - Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId - _ -> False - when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m' + -- Create e2ee, feature and group description chat items only on first connected relay + ifM + firstConnectedHost + ( do + let cd = CDGroupRcv gInfo'' scopeInfo m'' + createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + let prepared = preparedGroup gInfo'' + unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' + memberConnectedChatItem gInfo'' scopeInfo m'' + let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> prepared + unless (memberPending membership || isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo'' m'' + ) + (memberConnectedChatItem gInfo'' scopeInfo m'') + where + firstConnectedHost + | useRelays' gInfo = do + relayMems <- withStore' $ \db -> getGroupRelayMembers db vr user gInfo + let numConnected = length $ filter (\GroupMember {memberStatus = ms} -> ms == GSMemConnected) relayMems + pure $ numConnected == 1 + | otherwise = pure True + GCInviteeMember + | isRelay m -> do + withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected + gLink <- withStore $ \db -> getGroupLink db user gInfo + setGroupLinkDataAsync user gInfo gLink + | otherwise -> do + (gInfo', mStatus) <- + if not (memberPending m) + then do + mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected + pure (gInfo, mStatus) + else do + gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo + pure (gInfo', memberStatus m) + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + memberConnectedChatItem gInfo'' scopeInfo m' + case scopeInfo of + Just (GCSIMemberSupport _) -> do + createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing + _ -> pure () + toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} + let Connection {viaUserContactLink} = conn + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' + when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing) + if useRelays' gInfo'' + then do + introduceInChannel vr user gInfo'' m' + when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' + else case mStatus of + GSMemPendingApproval -> pure () + GSMemPendingReview -> introduceToModerators vr user gInfo'' m' + _ -> do + introduceToAll vr user gInfo'' m' + let memberIsCustomer = case businessChat gInfo'' of + Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId + _ -> False + when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m' where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' @@ -870,7 +931,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- possible improvement is to choose scope based on event (some events specify scope) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () - newDeliveryTasks <- reverse <$> foldM (processAChatMsg gInfo' m' tags eInfo) [] aChatMsgs + newDeliveryTasks <- reverse <$> foldM (processAChatMsg gInfo' scopeInfo m' tags eInfo) [] aChatMsgs shouldDelConns <- if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m) then createDeliveryTasks gInfo' m' newDeliveryTasks @@ -881,74 +942,91 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = aChatMsgs = parseChatMessages msgBody brokerTs = metaBrokerTs msgMeta processAChatMsg :: - GroupInfo - -> GroupMember - -> TVar [Text] - -> Text - -> [NewMessageDeliveryTask] - -> Either String AChatMessage - -> CM [NewMessageDeliveryTask] - processAChatMsg gInfo' m' tags eInfo newDeliveryTasks = \case - Right (ACMsg SJson chatMsg) -> do - newTask_ <- processEvent gInfo' m' tags eInfo chatMsg `catchAllErrors` \e -> eToView e $> Nothing - pure $ maybe newDeliveryTasks (: newDeliveryTasks) newTask_ - Right (ACMsg SBinary chatMsg) -> do - void (processEvent gInfo' m' tags eInfo chatMsg) `catchAllErrors` \e -> eToView e - pure newDeliveryTasks + GroupInfo -> + Maybe GroupChatScopeInfo -> + GroupMember -> + TVar [Text] -> + Text -> + [NewMessageDeliveryTask] -> + Either String AParsedMsg -> + CM [NewMessageDeliveryTask] + processAChatMsg gInfo' scopeInfo m' tags eInfo newDeliveryTasks = \case + Right (APMsg enc (parsedMsg@(ParsedMsg fwd_ _ ChatMessage {chatMsgEvent}))) -> do + let tag = toCMEventTag chatMsgEvent + atomically $ modifyTVar' tags (tshow tag :) + case fwd_ of + Just fwd | SJson <- enc -> do + logInfo $ "group fwd=" <> tshow tag <> " " <> eInfo + xGrpMsgForward gInfo' scopeInfo m' fwd parsedMsg brokerTs + `catchAllErrors` \e -> eToView e + pure newDeliveryTasks + -- direct JSON and binary messages; binary events don't produce delivery tasks + _ -> do + logInfo $ "group msg=" <> tshow tag <> " " <> eInfo + newTask_ <- join <$> withVerifiedMsg gInfo' scopeInfo m' parsedMsg brokerTs + (\verifiedMsg -> processEvent gInfo' m' verifiedMsg `catchAllErrors` \e -> eToView e $> Nothing) + pure $ maybe id (:) newTask_ newDeliveryTasks Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e eToView (ChatError . CEException $ "error parsing chat message: " <> e) pure newDeliveryTasks - processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> TVar [Text] -> Text -> ChatMessage e -> CM (Maybe NewMessageDeliveryTask) - processEvent gInfo' m' tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do - let tag = toCMEventTag chatMsgEvent - atomically $ modifyTVar' tags (tshow tag :) - logInfo $ "group msg=" <> tshow tag <> " " <> eInfo - let body = chatMsgToBody chatMsg - (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta body chatMsg + processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) + processEvent gInfo' m' verifiedMsg = do + (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg + let ctx js = DeliveryTaskContext js False + checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + checkSendAsGroup asGroup_ a + | asGroup_ == Just True && memberRole' m'' < GROwner = + messageError "member is not allowed to send as group" $> Nothing + | otherwise = a -- ! see isForwardedGroupMsg: processing functions should return DeliveryJobScope for same events - deliveryJobScope_ <- case event of - XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False - where ExtMsgContent {scope} = mcExtMsgContent mc + deliveryTaskContext_ <- case event of + XMsgNew mc -> + checkSendAsGroup asGroup $ + memberCanSend (Just m'') scope $ newGroupContentMessage gInfo' (Just m'') mc msg brokerTs False + where + ExtMsgContent {scope, asGroup} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live - XMsgDel sharedMsgId memberId scope_ -> groupMessageDelete gInfo' m'' sharedMsgId memberId scope_ msg brokerTs - XMsgReact sharedMsgId (Just memberId) scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> + checkSendAsGroup asGroup_ $ + memberCanSend (Just m'') msgScope $ + groupMessageUpdate gInfo' (Just m'') sharedMsgId mContent mentions msgScope msg brokerTs ttl live asGroup_ + XMsgDel sharedMsgId memberId_ scope_ -> groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ msg brokerTs + XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs - XFileCancel sharedMsgId -> xFileCancelGroup gInfo' m'' sharedMsgId + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName - XInfo p -> xInfoMember gInfo' m'' p brokerTs + XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p msg brokerTs XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs - XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo' m'' memId memRole msg brokerTs - XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs + XGrpMemRole memId memRole -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId XGrpMemDel memId withMessages -> case encoding @e of - SJson -> xGrpMemDel gInfo' m'' memId withMessages chatMsg msg brokerTs False - SBinary -> pure Nothing -- impossible - XGrpLeave -> xGrpLeave gInfo' m'' msg brokerTs - XGrpDel -> Just (DJSGroup {jobSpec = DJRelayRemoved}) <$ xGrpDel gInfo' m'' msg brokerTs - XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs - XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' + SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages verifiedMsg msg brokerTs False + SBinary -> pure Nothing + XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs + XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs + XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg -- TODO [knocking] why don't we forward these messages? - XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs - XGrpMsgForward memberId memberName msg' msgTs -> Nothing <$ xGrpMsgForward gInfo' m'' memberId memberName msg' msgTs brokerTs + XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) - forM deliveryJobScope_ $ \jobScope -> - -- TODO [channels fwd] XMsgNew to return messageFromChannel - pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, messageFromChannel = False} - checkSendRcpt :: [AChatMessage] -> CM Bool + forM deliveryTaskContext_ $ \taskContext -> + pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} + checkSendRcpt :: [AParsedMsg] -> CM Bool checkSendRcpt aMsgs = do let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo @@ -957,11 +1035,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = && any aChatMsgHasReceipt aMsgs && currentMemCount <= smallGroupsRcptsMemLimit where - aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = + aChatMsgHasReceipt (APMsg _ (ParsedMsg _ _ ChatMessage {chatMsgEvent})) = hasDeliveryReceipt (toCMEventTag chatMsgEvent) createDeliveryTasks :: GroupInfo -> GroupMember -> [NewMessageDeliveryTask] -> CM ShouldDeleteGroupConns createDeliveryTasks gInfo'@GroupInfo {groupId = gId} m' newDeliveryTasks = do - let relayRemovedTask_ = find (\NewMessageDeliveryTask {jobScope} -> isRelayRemoved jobScope) newDeliveryTasks + let relayRemovedTask_ = find (\NewMessageDeliveryTask {taskContext = DeliveryTaskContext {jobScope}} -> isRelayRemoved jobScope) newDeliveryTasks createdDeliveryTasks <- case relayRemovedTask_ of Nothing -> do withStore' $ \db -> @@ -981,7 +1059,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where uniqueWorkerScopes :: [NewMessageDeliveryTask] -> [DeliveryWorkerScope] uniqueWorkerScopes createdDeliveryTasks = - let workerScopes = map (\NewMessageDeliveryTask {jobScope} -> toWorkerScope jobScope) createdDeliveryTasks + let workerScopes = map (\NewMessageDeliveryTask {taskContext = DeliveryTaskContext {jobScope}} -> toWorkerScope jobScope) createdDeliveryTasks in foldr' addWorkerScope [] workerScopes where addWorkerScope workerScope acc @@ -995,7 +1073,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) - when continued $ sendPendingGroupMessages user m conn + when continued $ sendPendingGroupMessages user gInfo m conn SWITCH qd phase cStats -> do toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m @@ -1041,9 +1119,57 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ mc_ $ \mc -> do connReq_ <- withStore' $ \db -> getBusinessContactRequest db user groupId sendGroupAutoReply mc connReq_ + LDATA FixedLinkData {linkConnReq = cReq, rootKey = relayKey, linkEntityId} cData -> + withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> + case cmdFunction of + CFGetRelayDataJoin -> do + -- Update relay member with key, memberId and profile from link + relayLinkData_ <- liftIO $ decodeLinkUserData cData + case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + withStore $ \db -> updateRelayMemberData db user m (MemberId entityId) (MemberKey relayKey) p + _ -> throwChatError $ CEException "relay link: no relay link data or entity id" + case cReq of + CRContactUri crData@ConnReqUriData {crClientData} -> do + let pqSup = PQSupportOff + lift (withAgent' $ \a -> connRequestPQSupport a pqSup cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} + -- Update connection with data derived from cReq, now available after getConnShortLinkAsync + withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup + let GroupMember {memberId = membershipMemId} = membership + incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo + profileToSend = userProfileInGroup user gInfo incognitoProfile + memberPubKey <- case groupKeys gInfo of + Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey + Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" + dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey) + subMode <- chatReadVar subscriptionMode + void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode + CFGetRelayDataAccept -> do + let GroupMember {memberId = MemberId expectedMemberId} = m + if linkEntityId == Just expectedMemberId + then do + relayProfile <- liftIO (decodeLinkUserData cData) >>= \case + Just RelayShortLinkData {relayProfile = p} -> pure p + Nothing -> throwChatError $ CEException "relay link: no relay link data" + (confId, m', relay) <- withStore $ \db -> do + confId <- getRelayConfId db m + liftIO $ updateGroupMemberStatus db userId m GSMemAccepted + (m', relay) <- setRelayLinkAccepted db vr user m (MemberKey relayKey) relayProfile + pure (confId, m', relay) + allowAgentConnectionAsync user conn confId XOk + toView $ CEvtGroupRelayUpdated user gInfo m' relay + else + -- TODO [relays] owner: TBC failed RelayStatus? + messageError "relay link: relay member ID mismatch" + _ -> throwChatError $ CECommandError "unexpected cmdFunction" QCONT -> do continued <- continueSending connEntity conn - when continued $ sendPendingGroupMessages user m conn + when continued $ sendPendingGroupMessages user gInfo m conn MWARN msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err) processConnMWARN connEntity conn err @@ -1081,7 +1207,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing sendGroupAutoReply mc = \case Just UserContactRequest {welcomeSharedMsgId = Just smId} -> - void $ sendGroupMessage' user gInfo [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing + void $ sendGroupMessage' user gInfo [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing _ -> do msg <- sendGroupMessage' user gInfo [m] $ XMsgNew $ MCSimple $ extMsgContent mc Nothing ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndMsgContent mc) @@ -1144,15 +1270,45 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo - processUserContactRequest :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () - processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId = uclId} = case agentMsg of + processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () + processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of REQ invId pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport + XMember p joiningMemberId joiningMemberKey -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport + XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv + XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge -- TODO show/log error, other events in contact request _ -> pure () + LINK _link auData -> + withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> + case cmdFunction of + CFSetShortLink -> + case (ucGroupId_, auData) of + (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do + (gInfo, gLink, relays, relaysChanged) <- withStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + gLink <- getGroupLink db user gInfo + relays <- liftIO $ getGroupRelays db gInfo + (relays', changed) <- liftIO $ foldrM (updateRelay db) ([], False) relays + liftIO $ setGroupInProgressDone db gInfo + pure (gInfo, gLink, relays', changed) + toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged + where + -- TODO [relays] owner: on relay deletion (link absent from relayLinks) + -- TODO move status RSActive to new "Removed" status / remove relay record + updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool) -> IO ([GroupRelay], Bool) + updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) = + case relayLink of + Just rLink + | rLink `elem` relayLinks && relayStatus == RSAccepted -> do + relay' <- updateRelayStatus db relay RSActive + pure (relay' : acc, True) + _ -> pure (relay : acc, changed) + _ -> throwChatError $ CECommandError "LINK event expected for a group link only" + _ -> throwChatError $ CECommandError "unexpected cmdFunction" MERR _ err -> do eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err @@ -1252,16 +1408,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getDirectChatItemBySharedMsgId db user contactId sharedMsgId) >>= \case Right (cci@(CChatItem SMDRcv _)) -> do currentTs <- liftIO getCurrentTime - deletions <- if featureAllowed SCFFullDelete forContact ct - then deleteDirectCIs user ct [cci] - else markDirectCIsDeleted user ct [cci] currentTs + deletions <- + if featureAllowed SCFFullDelete forContact ct + then deleteDirectCIs user ct [cci] + else markDirectCIsDeleted user ct [cci] currentTs toView $ CEvtChatItemsDeleted user deletions False False _ -> pure () upsertBusinessRequestItem :: ChatDirection 'CTGroup 'MDRcv -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem) upsertBusinessRequestItem cd@(CDGroupRcv gInfo@GroupInfo {groupId} _ clientMember) = upsertRequestItem cd updateRequestItem markRequestItemDeleted where updateRequestItem (sharedMsgId, mc) = - withStore (\db -> getGroupChatItemBySharedMsgId db user gInfo (groupMemberId' clientMember) sharedMsgId) >>= \case + withStore (\db -> getGroupChatItemBySharedMsgId db user gInfo (Just $ groupMemberId' clientMember) sharedMsgId) >>= \case CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', content = CIRcvMsgContent oldMC} | sameMemberId (memberId' clientMember) m' -> if mc /= oldMC @@ -1281,11 +1438,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right cci@(CChatItem SMDRcv ChatItem {chatDir = CIGroupRcv m'}) | sameMemberId (memberId' clientMember) m' -> do currentTs <- liftIO getCurrentTime - deletions <- if groupFeatureMemberAllowed SGFFullDelete clientMember gInfo - then deleteGroupCIs user gInfo Nothing [cci] Nothing currentTs - else markGroupCIsDeleted user gInfo Nothing [cci] Nothing currentTs + deletions <- + if groupFeatureMemberAllowed SGFFullDelete clientMember gInfo + then deleteGroupCIs user gInfo Nothing [cci] Nothing currentTs + else markGroupCIsDeleted user gInfo Nothing [cci] Nothing currentTs toView $ CEvtChatItemsDeleted user deletions False False _ -> pure () + upsertBusinessRequestItem (CDChannelRcv _ _) = const $ pure Nothing createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem createRequestItem cd (sharedMsgId, mc) = do aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing @@ -1294,38 +1453,76 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = upsertRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> ((SharedMsgId, MsgContent) -> CM (Maybe AChatItem)) -> (SharedMsgId -> CM ()) -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem) upsertRequestItem cd update delete = \case (Just msg, Nothing) -> Just <$> createRequestItem cd msg - (Just msg@(sharedMsgId, _), Just prevSharedMsgId) | sharedMsgId == prevSharedMsgId -> - update msg `catchCINotFound` \_ -> Just <$> createRequestItem cd msg + (Just msg@(sharedMsgId, _), Just prevSharedMsgId) + | sharedMsgId == prevSharedMsgId -> + update msg `catchCINotFound` \_ -> Just <$> createRequestItem cd msg (Nothing, Just prevSharedMsgId) -> Nothing <$ delete prevSharedMsgId _ -> pure Nothing -- ##### Group link join requests (don't create contact requests) ##### 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 "processUserContactRequest: 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_ 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 $ "processUserContactRequest (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 Nothing + (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 + lift $ void $ getRelayRequestWorker True + xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () + xGrpRelayTest invId chatVRange challenge = do + privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) + case privKey_ of + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.signatureBytes $ C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` chatVRange + (cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV + withStore $ \db -> do + Connection {connId = testCId} <- createRelayTestConnection db vr user acId ConnAccepted chatV subMode + liftIO $ setCommandConnId db user cmdId testCId + -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays + -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) + memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM () + memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey = do + (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId + case gLinkInfo_ of + Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey) + (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' + Nothing -> + messageError "memberJoinRequestViaRelay: no group link info for relay link" - memberCanSend :: - GroupMember -> - Maybe MsgScope -> - CM (Maybe DeliveryJobScope) -> - CM (Maybe DeliveryJobScope) - memberCanSend m@GroupMember {memberRole} msgScope a = case msgScope of + muteEventInChannel :: GroupInfo -> GroupMember -> Bool + muteEventInChannel gInfo@GroupInfo {membership} m = + useRelays' gInfo && memberRole' membership < GRModerator && not (isRelay membership) && memberRole' m < GRModerator + + memberCanSend :: Maybe GroupMember -> Maybe MsgScope -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + memberCanSend Nothing _ a = a -- channel message - was previously checked and allowed by relay + memberCanSend (Just m@GroupMember {memberRole}) msgScope a = case msgScope of Just MSMember {} -> a Nothing | memberRole > GRObserver || memberPending m -> a @@ -1353,7 +1550,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (connInactive conn) $ do quotaErrCounter' <- withStore' $ \db -> incQuotaErrCounter db user conn when (quotaErrCounter' >= quotaErrInactiveCount) $ - toView $ CEvtConnectionInactive connEntity True + toView (CEvtConnectionInactive connEntity True) _ -> pure () continueSending :: ConnectionEntity -> Connection -> CM Bool @@ -1521,7 +1718,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do - let ExtMsgContent content _ fInv_ _ _ _ = mcExtMsgContent mc + let ExtMsgContent content _ fInv_ _ _ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of -- MCText "hello 111" -> @@ -1532,7 +1729,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do void $ newChatItem (ciContentNoParse $ CIRcvChatFeatureRejected CFVoice) Nothing Nothing False else do - let ExtMsgContent _ _ _ itemTTL live_ _ = mcExtMsgContent mc + let ExtMsgContent _ _ _ itemTTL live_ _ _ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct @@ -1559,23 +1756,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (fileId, aci) processFDMessage fileId aci fileDescr - groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryJobScope) - groupMessageFileDescription g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId fileDescr = do + groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext) + groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId aci <- getChatItemByFileId db vr user fileId pure (fileId, aci) case aci of - AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir = CIGroupRcv m} -> - if sameMemberId memberId m - then do + AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} + | validSender m_ chatDir -> do -- in processFDMessage some paths are programmed as errors, -- for example failure on not approved relays (CEFileNotApproved). -- we catch error, so that even if processFDMessage fails, message can still be forwarded. processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure () - pure $ Just $ infoToDeliveryScope g scopeInfo - else - messageError "x.msg.file.descr: file of another member" $> Nothing + pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir) + | otherwise -> messageError "x.msg.file.descr: file/sender mismatch" $> Nothing _ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> CM () @@ -1661,9 +1856,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case msgDir of SMDRcv | rcvItemDeletable ci brokerTs -> do - deletions <- if featureAllowed SCFFullDelete forContact ct - then deleteDirectCIs user ct [cci] - else markDirectCIsDeleted user ct [cci] brokerTs + deletions <- + if featureAllowed SCFFullDelete forContact ct + then deleteDirectCIs user ct [cci] + else markDirectCIsDeleted user ct [cci] brokerTs toView $ CEvtChatItemsDeleted user deletions False False | otherwise -> messageError "x.msg.del: contact attempted invalid message delete" SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" @@ -1694,28 +1890,31 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing mapM_ toView cEvt_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - groupMsgReaction g m@GroupMember {memberRole} sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMsgReaction g m sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs | groupFeatureAllowed SGFReactions g = do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False if reactionAllowed add reaction rs then updateChatItemReaction `catchCINotFound` \_ -> case scope_ of Just (MSMember scopeMemberId) - | memberRole >= GRModerator || scopeMemberId == memberId' m -> - withStore $ \db -> do + | memberRole' m >= GRModerator || scopeMemberId == memberId' m -> do + djScope <- withStore $ \db -> do liftIO $ setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId + pure $ fmap (\js -> DeliveryTaskContext js False) djScope | otherwise -> pure Nothing Nothing -> do withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + pure $ Just $ DeliveryTaskContext (DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}) False else pure Nothing | otherwise = pure Nothing where updateChatItemReaction = do (CChatItem md ci, scopeInfo) <- withStore $ \db -> do - cci <- getGroupMemberCIBySharedMsgId db user g itemMemberId sharedMsgId + cci <- case itemMemberId of + Just itemMemberId' -> getGroupMemberCIBySharedMsgId db user g itemMemberId' sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user g Nothing sharedMsgId scopeInfo <- getGroupChatScopeInfoForItem db vr user g (cChatItemId cci) pure (cci, scopeInfo) if ciReactionAllowed ci @@ -1726,7 +1925,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let ci' = CChatItem md ci {reactions} r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction toView $ CEvtChatItemReaction user add r - pure $ Just $ infoToDeliveryScope g scopeInfo + pure $ Just $ infoToDeliveryContext g scopeInfo False else pure Nothing reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool @@ -1738,14 +1937,27 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ - if blockedByAdmin m' - then createBlockedByAdmin gInfo' m' scopeInfo $> Nothing - else - case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of - Just f -> rejected gInfo' m' scopeInfo f $> Nothing + validSender :: Maybe GroupMember -> CIDirection 'CTGroup 'MDRcv -> Bool + validSender (Just m) (CIGroupRcv mem) = sameMemberId (memberId' m) mem + validSender m_ CIChannelRcv = maybe True (\m -> memberRole' m == GROwner) m_ + validSender _ _ = False + + isChannelDir :: CIDirection 'CTGroup 'MDRcv -> ShowGroupAsSender + isChannelDir CIChannelRcv = True + isChannelDir _ = False + + newGroupContentMessage :: GroupInfo -> Maybe GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryTaskContext) + newGroupContentMessage gInfo m_ mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = case m_ of + Nothing -> do + createContentItem gInfo Nothing Nothing + -- no delivery task - message already forwarded by relay + pure Nothing + Just m@GroupMember {memberId} -> do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ + if blockedByAdmin m' + then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing + else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing Nothing -> withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case Just ciModeration -> do @@ -1753,56 +1965,64 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ pure Nothing Nothing -> do - createContentItem gInfo' m' scopeInfo - pure $ Just $ infoToDeliveryScope gInfo scopeInfo + createContentItem gInfo' (Just m') scopeInfo + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo sentAsGroup where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False - timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL + timed_ gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL live' = fromMaybe False live_ - ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ = mcExtMsgContent mc + ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ asGroup_ = mcExtMsgContent mc + sentAsGroup = asGroup_ == Just True ts@(_, ft_) = msgContentTexts content - saveRcvCI gInfo' m' scopeInfo = saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg sharedMsgId_ brokerTs + -- m' is Maybe GroupMember + saveRcvCI gInfo' m' scopeInfo = + let itemMember_ = if sentAsGroup then Nothing else m' + chatDir = maybe (CDChannelRcv gInfo' scopeInfo) (CDGroupRcv gInfo' scopeInfo) itemMember_ + in saveRcvChatItem' user chatDir msg sharedMsgId_ brokerTs createBlockedByAdmin gInfo' m' scopeInfo | groupFeatureAllowed SGFFullDelete gInfo' = do -- ignores member role when blocked by admin - (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvBlocked) Nothing (timed' gInfo') False M.empty + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvBlocked) Nothing (timed_ gInfo') False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv m' + file_ <- processFileInv gInfo' m' (ci, cInfo) <- createNonLive gInfo' m' scopeInfo file_ ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo' ci groupMsgToView cInfo ci' - applyModeration gInfo' m' scopeInfo CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} + applyModeration gInfo' m'@GroupMember {memberRole} scopeInfo CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = - createContentItem gInfo' m' scopeInfo + createContentItem gInfo' (Just m') scopeInfo | groupFeatureMemberAllowed SGFFullDelete moderator gInfo' = do - (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvModerated) Nothing (timed' gInfo') False M.empty + (ci, cInfo) <- saveRcvCI gInfo' (Just m') scopeInfo (ciContentNoParse CIRcvModerated) Nothing (timed_ gInfo') False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv m' - (ci, _cInfo) <- createNonLive gInfo' m' scopeInfo file_ + file_ <- processFileInv gInfo' (Just m') + (ci, _cInfo) <- createNonLive gInfo' (Just m') scopeInfo file_ deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt toView $ CEvtChatItemsDeleted user deletions False False + -- m' is Maybe GroupMember createNonLive gInfo' m' scopeInfo file_ = do - saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') False mentions + saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') False mentions createContentItem gInfo' m' scopeInfo = do - file_ <- processFileInv m' - newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') live' - unless (memberBlocked m') $ autoAcceptFile file_ - processFileInv m' = - processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m' - newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed_ live = do - let mentions' = if memberBlocked m' then [] else mentions - (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed_ live mentions' - ci' <- blockedMemberCI gInfo' m' ci - reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ + file_ <- processFileInv gInfo' m' + newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') live' + unless (maybe False memberBlocked m') $ autoAcceptFile file_ + processFileInv gInfo' m' = + let fileMember_ = if sentAsGroup then Nothing else m' + in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ + newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do + let mentions' = if maybe False memberBlocked m' then [] else mentions + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions' + ci' <- maybe (pure ci) (\m -> blockedMemberCI gInfo' m ci) m' + let memberId_ = memberId' <$> m' + reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId_ sharedMsgId) sharedMsgId_ groupMsgToView cInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM (Maybe DeliveryJobScope) - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ - | prohibitedSimplexLinks gInfo m ft_ = + groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> Maybe Bool -> CM (Maybe DeliveryTaskContext) + groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ asGroup_ + | Just m <- m_, prohibitedSimplexLinks gInfo m ft_ = messageWarning ("x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks) $> Nothing | otherwise = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -1810,102 +2030,158 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - mentions' = if memberBlocked m then [] else mentions - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ - (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' - ci' <- withStore' $ \db -> do - createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content True live Nothing - ci'' <- blockedMemberCI gInfo' m' ci' - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') - pure $ Just $ infoToDeliveryScope gInfo scopeInfo + showGroupAsSender = fromMaybe (isNothing m_) asGroup_ + if showGroupAsSender && maybe False (\m -> memberRole' m < GROwner) m_ + then messageError "x.msg.update: member attempted to update as group" $> Nothing + else do + (gInfo', chatDir, mentions', scopeInfo) <- + if showGroupAsSender + then pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) + else case m_ of + Just m -> do + let mentions' = if memberBlocked m then [] else mentions + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ + pure (gInfo', CDGroupRcv gInfo' scopeInfo m', mentions', scopeInfo) + Nothing -> pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) + (ci, cInfo) <- saveRcvChatItem' user chatDir msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + ci' <- withStore' $ \db -> do + createChatItemVersion db (chatItemId' ci) brokerTs mc + updateGroupChatItem db user groupId ci content True live Nothing + ci'' <- case chatDir of + CDGroupRcv gi' _ m' -> blockedMemberCI gi' m' ci' + CDChannelRcv {} -> pure ci' + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do (cci, scopeInfo) <- withStore $ \db -> do - cci <- getGroupChatItemBySharedMsgId db user gInfo groupMemberId sharedMsgId + cci <- + if asGroup_ == Just True + then getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId + else case m_ of + Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) case cci of - CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> - if sameMemberId memberId m' - then do - let changed = mc /= oldMC - if changed || fromMaybe False itemLive - then do - ci' <- withStore' $ \db -> do - when changed $ - addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - reactions <- getGroupCIReactions db gInfo memberId sharedMsgId - let edited = itemLive /= Just True - ciMentions <- getRcvCIMentions db user gInfo ft_ mentions - ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId - updateGroupCIMentions db gInfo ci' ciMentions - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') - startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' - pure $ Just $ infoToDeliveryScope gInfo scopeInfo - else do - toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) - pure Nothing - else messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing - _ -> messageError "x.msg.update: group member attempted invalid message update" $> Nothing - - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ scope_ RcvMessage {msgId} brokerTs = do - let msgMemberId = fromMaybe memberId sndMemberId_ - withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo msgMemberId sharedMsgId) >>= \case - Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of - CIGroupRcv mem -> case sndMemberId_ of - -- regular deletion - Nothing - | sameMemberId memberId mem && msgMemberId == memberId && rcvItemDeletable ci brokerTs -> - Just <$> delete cci Nothing - | otherwise -> - messageError "x.msg.del: member attempted invalid message delete" $> Nothing - -- moderation (not limited by time) - Just _ - | sameMemberId memberId mem && msgMemberId == memberId -> - Just <$> delete cci (Just m) - | otherwise -> - moderate mem cci - CIGroupSnd -> moderate membership cci - Left e - | msgMemberId == memberId -> - messageError ("x.msg.del: message not found, " <> tshow e) $> Nothing - | senderRole < GRModerator -> do - messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} + | isSender m' -> updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') + | otherwise -> messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing + CChatItem SMDRcv ci@ChatItem {chatDir = CIChannelRcv, meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} + | maybe True (\m -> memberRole' m == GROwner) m_ -> updateCI True ci scopeInfo oldMC itemLive Nothing + | otherwise -> messageError "x.msg.update: member attempted to update channel message" $> Nothing + _ -> messageError "x.msg.update: invalid message update" $> Nothing + where + isSender m' = maybe False (\m -> sameMemberId (memberId' m) m') m_ + updateCI :: ShowGroupAsSender -> ChatItem 'CTGroup 'MDRcv -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe Bool -> Maybe MemberId -> CM (Maybe DeliveryTaskContext) + updateCI showGroupAsSender ci scopeInfo oldMC itemLive memberId = do + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + ci' <- withStore' $ \db -> do + when changed $ + addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) + reactions <- getGroupCIReactions db gInfo memberId sharedMsgId + let edited = itemLive /= Just True + ciMentions <- getRcvCIMentions db user gInfo ft_ mentions + ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + updateGroupCIMentions db gInfo ci' ciMentions + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') + startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' + pure $ Just $ infoToDeliveryContext gInfo scopeInfo showGroupAsSender + else do + toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) pure Nothing - | otherwise -> case scope_ of - Just (MSMember scopeMemberId) -> - withStore $ \db -> do - liftIO $ createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user gInfo m scopeMemberId - Nothing -> do - withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + + groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ rcvMsg brokerTs = + findItem >>= \case + Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case (chatDir, m_) of + (CIGroupRcv mem, Just m@GroupMember {memberId}) -> + let msgMemberId = fromMaybe memberId sndMemberId_ + in case sndMemberId_ of + -- regular deletion + Nothing + | sameMemberId memberId mem && rcvItemDeletable ci brokerTs -> + delete cci False Nothing + | otherwise -> + messageError "x.msg.del: member attempted invalid message delete" $> Nothing + -- moderation (not limited by time) + Just _ + | sameMemberId memberId mem && msgMemberId == memberId -> + delete cci False (Just m) + | otherwise -> moderate m mem cci + (CIChannelRcv, _) + | isNothing sndMemberId_ && isOwner -> delete cci True Nothing + | otherwise -> messageError "x.msg.del: invalid channel message delete" $> Nothing + (CIGroupSnd, Just m) -> moderate m membership cci + _ -> messageError "x.msg.del: invalid message deletion" $> Nothing + Left e -> case m_ of + Just m@GroupMember {memberId, memberRole = senderRole} -> do + let msgMemberId = fromMaybe memberId sndMemberId_ + if + | msgMemberId == memberId -> + messageError ("x.msg.del: message not found, " <> tshow e) $> Nothing + | senderRole < GRModerator -> do + messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + pure Nothing + | otherwise -> case scope_ of + Just (MSMember scopeMemberId) -> + withStore $ \db -> do + liftIO $ createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs + supportGMId <- getScopeMemberIdViaMemberId db user gInfo m scopeMemberId + pure $ Just $ DeliveryTaskContext {jobScope = DJSMemberSupport supportGMId, sentAsGroup = False} + Nothing -> do + withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs + pure $ Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False} + Nothing -> + messageError ("x.msg.del: channel message not found, " <> tshow e) $> Nothing where - moderate :: GroupMember -> CChatItem 'CTGroup -> CM (Maybe DeliveryJobScope) - moderate mem cci = case sndMemberId_ of + isOwner = maybe True (\m -> memberRole' m == GROwner) m_ + RcvMessage {msgId} = rcvMsg + findItem = do + let tryMemberLookup mId = + withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo mId sharedMsgId) + tryChannelLookup = + withStore' (\db -> runExceptT $ getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId) + case sndMemberId_ of + Just sId -> tryMemberLookup sId + Nothing -> case m_ of + Just GroupMember {memberId} -> + tryMemberLookup memberId >>= \case + Right cci -> pure (Right cci) + Left e -> + tryChannelLookup >>= \case + Right cci -> pure (Right cci) + Left _ -> pure (Left e) + Nothing -> tryChannelLookup + moderate :: GroupMember -> GroupMember -> CChatItem 'CTGroup -> CM (Maybe DeliveryTaskContext) + moderate sender mem cci = case sndMemberId_ of Just sndMemberId - | sameMemberId sndMemberId mem -> checkRole mem $ do - jobScope <- delete cci (Just m) - archiveMessageReports cci m - pure $ Just jobScope + | sameMemberId sndMemberId mem -> checkRole (memberRole' sender) mem $ do + ctx_ <- delete cci False (Just sender) + archiveMessageReports cci sender + pure ctx_ | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" $> Nothing _ -> messageError "x.msg.del: message of another member without memberId" $> Nothing - checkRole GroupMember {memberRole} a + checkRole senderRole GroupMember {memberRole} a | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" $> Nothing | otherwise = a - delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM DeliveryJobScope - delete cci byGroupMember = do + delete :: CChatItem 'CTGroup -> Bool -> Maybe GroupMember -> CM (Maybe DeliveryTaskContext) + delete cci asGroup byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) - deletions <- if groupFeatureMemberAllowed SGFFullDelete m gInfo - then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs - else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs + let fullDelete + | asGroup = groupFeatureAllowed SGFFullDelete gInfo + | otherwise = maybe False (\m -> groupFeatureMemberAllowed SGFFullDelete m gInfo) m_ + deletions <- + if fullDelete + then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs + else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs toView $ CEvtChatItemsDeleted user deletions False False - pure $ infoToDeliveryScope gInfo scopeInfo + pure $ if isNothing m_ then Nothing else Just $ infoToDeliveryContext gInfo scopeInfo asGroup archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs @@ -1931,7 +2207,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -2042,23 +2318,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () receiveFileChunk ft Nothing meta chunk - xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM (Maybe DeliveryJobScope) - xFileCancelGroup g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId = do + xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext) + xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId (fileId,) <$> getChatItemByFileId db vr user fileId case aci of - AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir = CIGroupRcv m} -> do - if sameMemberId memberId m - then do + AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} + | validSender m_ chatDir -> do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft toView $ CEvtRcvFileSndCancelled user aci ft - pure $ Just $ infoToDeliveryScope g scopeInfo - else - -- shouldn't happen now that query includes group member id - messageError "x.file.cancel: group member attempted to cancel file of another member" $> Nothing + pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir) + | otherwise -> messageError "x.file.cancel: file cancel sender mismatch" $> Nothing _ -> messageError "x.file.cancel: group member attempted invalid file cancel" $> Nothing xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> CM () @@ -2091,34 +2364,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) cInfo ci] processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM () - processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn} = ct - GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv - forM_ activeConn $ \Connection {connId, connChatVersion, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do - when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) - when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId - -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db vr user ct inv customUserProfileId - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) - let GroupMember {groupMemberId, memberId = membershipMemId} = membership - if sameGroupLinkId groupLinkId groupLinkId' - then do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId - connIds <- joinAgentConnectionAsync user True connRequest dm subMode - withStore' $ \db -> do - setViaGroupLinkUri db groupId connId - createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode - updateGroupMemberStatusById db userId hostId GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) - else do - let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content - withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] - toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} + processGroupInvitation ct inv msg msgMeta + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | otherwise = do + let Contact {localDisplayName = c, activeConn} = ct + GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv + forM_ activeConn $ \Connection {connId, connChatVersion, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do + when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) + when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId + -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile + (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db vr user ct inv customUserProfileId + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + let GroupMember {groupMemberId, memberId = membershipMemId} = membership + if sameGroupLinkId groupLinkId groupLinkId' + then do + subMode <- chatReadVar subscriptionMode + dm <- encodeConnInfo $ XGrpAcpt membershipMemId + connIds <- joinAgentConnectionAsync user Nothing True connRequest dm subMode + withStore' $ \db -> do + setViaGroupLinkUri db groupId connId + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) + else do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] + toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where + GroupInvitation {groupProfile = GroupProfile {publicGroup}} = inv brokerTs = metaBrokerTs msgMeta sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' @@ -2194,9 +2470,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Profile {displayName = n, fullName = fn, shortDescr = sd, image = i, contactLink = cl} = p Profile {displayName = n', fullName = fn', shortDescr = sd', image = i', contactLink = cl'} = p' - xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM (Maybe DeliveryJobScope) - xInfoMember gInfo m p' brokerTs = do - void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs) + xInfoMember :: GroupInfo -> GroupMember -> Profile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xInfoMember gInfo m p' msg brokerTs = do + void $ processMemberProfileUpdate gInfo m p' (Just (msg, brokerTs)) pure $ memberEventDeliveryScope m xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () @@ -2204,7 +2480,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do - m' <- processMemberProfileUpdate gInfo m p' False Nothing + m' <- processMemberProfileUpdate gInfo m p' Nothing withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True let connectedIncognito = memberIncognito membership probeMatchingMemberContact m' connectedIncognito @@ -2268,24 +2544,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion - processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember - processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_ + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember + processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_ | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do updateBusinessChatProfile gInfo case memberContactId of Nothing -> do m' <- withStore $ \db -> updateMemberProfile db user m p' - createProfileUpdatedItem m' - toView $ CEvtGroupMemberUpdated user gInfo m m' + unless (muteEventInChannel gInfo m') $ do + forM_ msgTs_ $ createProfileUpdatedItem m' + toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db vr user mContactId if canUpdateProfile mCt then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' - createProfileUpdatedItem m' - toView $ CEvtGroupMemberUpdated user gInfo m m' - toView $ CEvtContactUpdated user mCt ct' + unless (muteEventInChannel gInfo m') $ do + forM_ msgTs_ $ createProfileUpdatedItem m' + toView $ CEvtGroupMemberUpdated user gInfo m m' + toView $ CEvtContactUpdated user mCt ct' pure m' else pure m where @@ -2301,16 +2579,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' - toView $ CEvtGroupUpdated user g g' (Just m) + toView $ CEvtGroupUpdated user g g' (Just m) Nothing _ -> pure () isMainBusinessMember BusinessChatInfo {chatType, businessId, customerId} GroupMember {memberId} = case chatType of BCBusiness -> businessId == memberId BCCustomer -> customerId == memberId - createProfileUpdatedItem m' = - when createItems $ do - (gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m' - let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' - createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') ciContent itemTs_ + createProfileUpdatedItem m' (msg, brokerTs) = do + (gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m' + let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' + cd = CDGroupRcv gInfo' scopeInfo m'' + (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs ciContent + groupMsgToView cInfo ci xInfoProbe :: ContactOrMember -> Probe -> CM () xInfoProbe cgm2 probe = do @@ -2545,8 +2824,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure (conn', Nothing) xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msgScope_ msg brokerTs = do - checkHostRole m memRole + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do + unless (useRelays' gInfo && isRelay m) $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing else do @@ -2554,22 +2833,28 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do (updatedMember, gInfo') <- withStore $ \db -> do updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus - gInfo' <- if memberPending updatedMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo + gInfo' <- + if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo pure (updatedMember, gInfo') - toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember - memberAnnouncedToView updatedMember gInfo' + gInfo'' <- updatePublicGroupData user gInfo' + toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo'' pure $ deliveryJobScope updatedMember - Right _ -> messageError "x.grp.mem.new error: member already exists" $> Nothing + Right _ + | useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing + | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ -> do (newMember, gInfo') <- withStore $ \db -> do newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus - gInfo' <- if memberPending newMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo + gInfo' <- + if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo pure (newMember, gInfo') - memberAnnouncedToView newMember gInfo' + gInfo'' <- updatePublicGroupData user gInfo' + memberAnnouncedToView newMember gInfo'' pure $ deliveryJobScope newMember where initialStatus = case msgScope_ of @@ -2581,9 +2866,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} gInfo' = do (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember - let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent event) - groupMsgToView cInfo ci + unless (useRelays' gInfo') $ do + let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent event) + groupMsgToView cInfo ci case scopeInfo of Just (GCSIMemberSupport _) -> do createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) @@ -2596,23 +2882,32 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (announcedMember', Just scopeInfo) xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> CM () - xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do + xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _ _) memRestrictions = do case memberCategory m of GCHostMember -> withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right _ -> messageError "x.grp.mem.intro ignored: member already exists" - Left _ -> do - when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) - case memChatVRange of - Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" - Just (ChatVersionRange mcvr) - | maxVersion mcvr >= groupDirectInvVersion -> do - subMode <- chatReadVar subscriptionMode - -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second - groupConnIds <- createConn subMode - let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange - void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds subMode - | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" + Right existingMember + | useRelays' gInfo -> + void $ withStore $ \db -> updatePreparedChannelMember db vr user existingMember memInfo + | otherwise -> + messageError "x.grp.mem.intro ignored: member already exists" + Left _ + | useRelays' gInfo -> + void $ withStore $ \db -> createIntroReMember db user gInfo memInfo memRestrictions + | otherwise -> do + when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) + case memChatVRange of + Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" + Just (ChatVersionRange mcvr) + | maxVersion mcvr >= groupDirectInvVersion -> do + subMode <- chatReadVar subscriptionMode + -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second + groupConnIds <- createConn subMode + let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + void $ withStore $ \db -> do + reMember <- createIntroReMember db user gInfo memInfo memRestrictions + createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode + | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode @@ -2634,18 +2929,19 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq, directConnReq} = do let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- withStore $ \db -> do - toMember <- getGroupMemberByMemberId db vr user gInfo memId - -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent - -- the situation when member does not exist is an error - -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. - -- For now, this branch compensates for the lack of delayed message delivery. - `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - e -> throwError e + toMember <- + getGroupMemberByMemberId db vr user gInfo memId + -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent + -- the situation when member does not exist is an error + -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. + -- For now, this branch compensates for the lack of delayed message delivery. + `catchError` \case + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? -- TODO keep as is? (GSMemIntroInvited has no purpose) @@ -2658,15 +2954,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode - directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode + groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange chatV = vr `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs + xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg@RcvMessage {msgSigned} brokerTs | membershipMemId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole @@ -2684,7 +2980,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci - toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} + toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned} pure $ memberEventDeliveryScope member checkHostRole :: GroupMember -> GroupMemberRole -> CM () @@ -2697,30 +2993,29 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = m@GroupMember {memberRole = senderRole} memId MemberRestrictions {restriction} - msg + msg@RcvMessage {msgSigned} brokerTs | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency - | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} - | blockedByAdmin == mrsBlocked restriction -> pure Nothing - | senderRole < GRModerator || senderRole < memberRole -> - messageError "x.grp.mem.restrict with insufficient member permissions" $> Nothing - | otherwise -> do - bm' <- setMemberBlocked bm - toggleNtf bm' (not blocked) - let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent - groupMsgToView cInfo ci - toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked} - pure $ memberEventDeliveryScope bm - Left (SEGroupMemberNotFoundByMemberId _) -> do - bm <- createUnknownMember gInfo memId Nothing - bm' <- setMemberBlocked bm - toView $ CEvtUnknownMemberBlocked user gInfo m bm' - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} - Left e -> throwError $ ChatErrorStore e + | otherwise = do + unknownRole <- unknownMemberRole gInfo + withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memId "" unknownRole True) >>= \case + Nothing -> messageError "x.grp.mem.restrict: no member" $> Nothing -- shouldn't happen + Just (bm, unknown) -> do + let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm + if + | blockedByAdmin == mrsBlocked restriction -> pure Nothing + | senderRole < GRModerator || senderRole < memberRole -> + messageError "x.grp.mem.restrict with insufficient member permissions" $> Nothing + | otherwise -> do + bm' <- setMemberBlocked bm + toggleNtf bm' (not blocked) + let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent + when unknown $ toView $ CEvtUnknownMemberBlocked user gInfo m bm' + groupMsgToView cInfo ci + toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked, msgSigned} + pure $ memberEventDeliveryScope bm where setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm blocked = mrsBlocked restriction @@ -2732,19 +3027,19 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages chatMsg msg brokerTs forwarded = do + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo - -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd deleteMemberItem gInfo RGEUserDeleted - toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages + toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case @@ -2767,11 +3062,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just (DJSMemberSupport _) | shouldForward -> updateMemberRecordDeleted user gInfo deletedMember GSMemRemoved -- Undeleted "member connected" chat item will prevent deletion of member record. _ -> deleteOrUpdateMemberRecord user gInfo deletedMember + gInfo'' <- updatePublicGroupData user gInfo' let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft deletedMember' = deletedMember {memberStatus = GSMemRemoved} - when withMessages $ deleteMessages gInfo' deletedMember' SMDRcv - unless wasDeleted $ deleteMemberItem gInfo' $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CEvtDeletedMember user gInfo' m deletedMember' withMessages + when withMessages $ deleteMessages gInfo'' deletedMember' SMDRcv + unless wasDeleted $ deleteMemberItem gInfo'' $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + toView $ CEvtDeletedMember user gInfo'' m deletedMember' withMessages msgSigned pure deliveryScope where checkRole GroupMember {memberRole} a @@ -2787,68 +3083,81 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | groupFeatureMemberAllowed SGFFullDelete m gInfo' = deleteGroupMemberCIs user gInfo' delMem m msgDir | otherwise = markGroupMemberCIsDeleted user gInfo' delMem m forwardToMember :: GroupMember -> CM () - forwardToMember member = do - let GroupMember {memberId} = m - memberName = Just $ memberShortenedName m - event = XGrpMsgForward memberId memberName chatMsg brokerTs - sendGroupMemberMessage gInfo member event + forwardToMember member = + let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' m) (memberShortenedName m), fwdBrokerTs = brokerTs} + in sendFwdMemberMessage member fwd verifiedMsg - -- TODO [channels fwd] base on differentiation between groups and channels isUserGrpFwdRelay :: GroupInfo -> Bool - isUserGrpFwdRelay GroupInfo {useRelays, membership = membership@GroupMember {memberRole}} - | isTrue useRelays = isMemberRelay membership - | otherwise = memberRole >= GRAdmin + isUserGrpFwdRelay gInfo@GroupInfo {membership} + | useRelays' gInfo = isRelay membership + | otherwise = memberRole' membership >= GRAdmin + + isMemberGrpFwdRelay :: GroupInfo -> GroupMember -> Bool + isMemberGrpFwdRelay gInfo m + | 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 + xGrpLeave gInfo m msg@RcvMessage {msgSigned} brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft - (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView cInfo ci - toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft} + gInfo'' <- updatePublicGroupData user gInfo' + unless (muteEventInChannel gInfo'' m) $ do + (gInfo''', m', scopeInfo) <- mkGroupChatScope gInfo'' m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo''' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView cInfo ci + toView $ CEvtLeftMember user gInfo''' m' {memberStatus = GSMemLeft} msgSigned pure $ memberEventDeliveryScope m xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () - xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do + xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg@RcvMessage {msgSigned} brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner + deleteGroupLinkIfExists user gInfo withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemGroupDeleted - -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView cInfo ci - toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' + toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' msgSigned xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpInfo g@GroupInfo {groupProfile = p, businessChat} m@GroupMember {memberRole} p' msg brokerTs + xGrpInfo g@GroupInfo {groupProfile = p@GroupProfile {publicGroup = pg}, businessChat} m@GroupMember {memberRole} p'@GroupProfile {publicGroup = pg'} msg@RcvMessage {msgSigned} brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" $> Nothing + | let pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), + useRelays' g && (isNothing pg' || pgId pg' /= pgId pg) = messageError "x.grp.info: publicGroupId mismatch for channel" $> Nothing + | not (useRelays' g) && isJust pg' = messageError "x.grp.info: publicGroup not allowed in p2p groups" $> Nothing | otherwise = do case businessChat of Nothing -> unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' (g'', m', scopeInfo) <- mkGroupChatScope g' m - toView $ CEvtGroupUpdated user g g'' (Just m') + toView $ CEvtGroupUpdated user g g'' (Just m') msgSigned let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') groupMsgToView cInfo ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' - void $ forkIO $ setGroupLinkData' NRMBackground user g'' - Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' + void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' + Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} - xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM (Maybe DeliveryJobScope) - xGrpPrefs g m@GroupMember {memberRole} ps' + xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) + xGrpPrefs g m@GroupMember {memberRole} ps' RcvMessage {msgSigned} | memberRole < GROwner = messageError "x.grp.prefs with insufficient member permissions" $> Nothing - | otherwise = updateGroupPrefs_ g m ps' $> Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} + | otherwise = updateGroupPrefs_ msgSigned g m ps' $> Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} - updateGroupPrefs_ :: GroupInfo -> GroupMember -> GroupPreferences -> CM () - updateGroupPrefs_ g@GroupInfo {groupProfile = p} m ps' = + updateGroupPrefs_ :: Maybe MsgSigStatus -> GroupInfo -> GroupMember -> GroupPreferences -> CM () + updateGroupPrefs_ msgSigned g@GroupInfo {groupProfile = p} m ps' = unless (groupPreferences p == Just ps') $ do g' <- withStore' $ \db -> updateGroupPreferences db user g ps' - toView $ CEvtGroupUpdated user g g' (Just m) + toView $ CEvtGroupUpdated user g g' (Just m) msgSigned (g'', m', scopeInfo) <- mkGroupChatScope g' m let cd = CDGroupRcv g'' scopeInfo m' createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' @@ -2877,13 +3186,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else joinExistingContact subMode mCt where groupDirectInv = - GroupDirectInvitation { - groupDirectInvLink = connReq, - fromGroupId_ = Just groupId, - fromGroupMemberId_ = Just (groupMemberId' m), - fromGroupMemberConnId_ = Just mConnId, - groupDirectInvStartedConnection = autoAcceptMemberContacts user - } + GroupDirectInvitation + { groupDirectInvLink = connReq, + fromGroupId_ = Just groupId, + fromGroupMemberId_ = Just (groupMemberId' m), + fromGroupMemberConnId_ = Just mConnId, + groupDirectInvStartedConnection = autoAcceptMemberContacts user + } joinExistingContact subMode mCt@Contact {contactId = mContactId} | autoAcceptMemberContacts user = do (cmdId, acId) <- joinConn subMode @@ -2928,7 +3237,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p - joinAgentConnectionAsync user True connReq dm subMode + joinAgentConnectionAsync user Nothing True connReq dm subMode createItems mCt' m' = do (g', m'', scopeInfo) <- mkGroupChatScope g m' createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing @@ -2942,45 +3251,76 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing - xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> UTCTime -> CM () - xGrpMsgForward gInfo m@GroupMember {memberRole, localDisplayName} memberId memberName chatMsg msgTs brokerTs = do - when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case - Right author -> processForwardedMsg author - Left (SEGroupMemberNotFoundByMemberId _) -> do - unknownAuthor <- createUnknownMember gInfo memberId memberName - toView $ CEvtUnknownMemberCreated user gInfo m unknownAuthor - processForwardedMsg unknownAuthor - Left e -> throwError $ ChatErrorStore e + xGrpMsgForward :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () + xGrpMsgForward gInfo scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do + unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) + case fwdSender of + FwdMember memberId memberName -> do + unknownRole <- unknownMemberRole gInfo + let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_ + withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownRole allowCreate) >>= \case + Just (author, unknown) -> do + when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author + void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $ + (`processForwardedMsg` Just author) + Nothing -> pure () + FwdChannel -> processForwardedMsg (VMUnsigned chatMsg) Nothing where -- ! see isForwardedGroupMsg: forwarded group events should include msgId to be deduplicated - processForwardedMsg :: GroupMember -> CM () - processForwardedMsg author = do - let body = chatMsgToBody chatMsg - rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author body chatMsg brokerTs + processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM () + processForwardedMsg verifiedMsg author_ = do + rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of - XMsgNew mc -> void $ memberCanSend author scope $ (const Nothing) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True - where ExtMsgContent {scope} = mcExtMsgContent mc + XMsgNew mc -> + void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True + where + ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (const Nothing) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live - XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author sharedMsgId memId scope_ rcvMsg msgTs - XMsgReact sharedMsgId (Just memId) scope_ reaction add -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs - XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author sharedMsgId - XInfo p -> void $ xInfoMember gInfo author p msgTs - XGrpMemNew memInfo msgScope -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs - XGrpMemRole memId memRole -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs - XGrpMemDel memId withMessages -> void $ xGrpMemDel gInfo author memId withMessages chatMsg rcvMsg msgTs True - XGrpLeave -> void $ xGrpLeave gInfo author rcvMsg msgTs - XGrpDel -> void $ xGrpDel gInfo author rcvMsg msgTs - XGrpInfo p' -> void $ xGrpInfo gInfo author p' rcvMsg msgTs - XGrpPrefs ps' -> void $ xGrpPrefs gInfo author ps' + XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> + void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ + XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ rcvMsg msgTs + XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs + XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId + XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs + XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs + XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs + XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs + XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages verifiedMsg rcvMsg msgTs True + XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs + XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs + XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs + XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) + where + withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () + withAuthor tag action = case author_ of + Just author -> action author + Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" - createUnknownMember :: GroupInfo -> MemberId -> Maybe ContactName -> CM GroupMember - createUnknownMember gInfo memberId memberName = do - let name = fromMaybe (nameFromMemberId memberId) memberName - withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name + withVerifiedMsg :: MsgEncodingI e => GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a) + withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = + case verified of + Just verifiedMsg -> Just <$> action verifiedMsg + Nothing -> do + createInternalChatItem user (CDGroupRcv gInfo scopeInfo member) (CIRcvGroupEvent RGEMsgBadSignature) (Just ts) + pure Nothing + where + verified = case signedMsg_ of + Just sm@SignedMsg {chatBinding, signatures, signedBody} + | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> + case chatBinding of + CBGroup | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> + let prefix = smpEncode chatBinding <> smpEncode (publicGroupId, memberId) + in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) + _ -> signed MSSSignedNoKey <$ guard signatureOptional + | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) + where + signed status = VMSigned status sm chatMsg + Nothing -> VMUnsigned chatMsg <$ guard signatureOptional + where + tag = toCMEventTag chatMsgEvent + signatureOptional = not (useRelays' gInfo) || not (requiresSignature tag) directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do @@ -3067,14 +3407,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing deleteGroupConnections :: User -> GroupInfo -> Bool -> CM () -deleteGroupConnections user gInfo waitDelivery = do +deleteGroupConnections user gInfo@GroupInfo {membership} waitDelivery = do vr <- chatVersionRange -- member records are not deleted to keep history members <- getMembers vr deleteMembersConnections' user members waitDelivery where getMembers vr - | isTrue (useRelays gInfo) = withStore' $ \db -> getGroupRelays db vr user gInfo + | useRelays' gInfo, not (isRelay membership) = withStore' $ \db -> getGroupRelayMembers db vr user gInfo | otherwise = withStore' $ \db -> getGroupMembers db vr user gInfo startDeliveryTaskWorkers :: CM () @@ -3096,7 +3436,7 @@ runDeliveryTaskWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryTaskWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config vr <- chatVersionRange - -- TODO [channels fwd] in future may be required to read groupInfo and user on each iteration for up to date state + -- TODO [relays] in future may be required to read groupInfo and user on each iteration for up to date state -- TODO - same for delivery jobs (runDeliveryJobWorker) gInfo <- withStore $ \db -> do user <- getUserByGroupId db groupId @@ -3135,10 +3475,9 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do | workerScope /= DWSGroup -> throwChatError $ CEInternalError "delivery task worker: relay removed task in wrong worker scope" | otherwise -> do - let MessageDeliveryTask {senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage} = task - fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs - cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} - body = chatMsgToBody cm + let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task + fwd = GrpMsgForward {fwdSender, fwdBrokerTs} + body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed @@ -3198,65 +3537,65 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job sendBodyToMembers :: CM () sendBodyToMembers - | isTrue (useRelays gInfo) = -- channel - case jobScope of - -- there's no member review in channels, so job spec includePending is ignored - DJSGroup {} -> do - bucketSize <- asks $ deliveryBucketSize . config - sendLoop bucketSize startingCursor - where - sendLoop :: Int -> Maybe GroupMemberId -> CM () - sendLoop bucketSize cursorGMId_ = do - mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize - unless (null mems) $ do - deliver body mems - let cursorGMId' = groupMemberId' $ last mems - withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' - unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') - DJSMemberSupport scopeGMId -> do - -- for member support scope we just load all recipients in one go, without cursor - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let moderatorFilter m = - memberCurrent m - && maxVersion (memberChatVRange m) >= groupKnockingVersion - && Just (groupMemberId' m) /= singleSenderGMId_ - modMs' = filter moderatorFilter modMs - mems <- - if Just scopeGMId == singleSenderGMId_ - then pure modMs' - else do - scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId - pure $ scopeMem : modMs' - unless (null mems) $ deliver body mems - | otherwise = -- fully connected group - case singleSenderGMId_ of - Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" - Just singleSenderGMId -> do - sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId - ms <- buildMemberList sender - unless (null ms) $ deliver body ms - where - buildMemberList sender = do - vec <- withStore (`getMemberRelationsVector` sender) - -- this excludes the sender - let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec - case jobScope of - DJSGroup {jobSpec} -> do - ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs - pure $ filter shouldForwardTo ms - where - shouldForwardTo m - | jobSpecImpliedPending jobSpec = memberCurrentOrPending m - | otherwise = memberCurrent m - DJSMemberSupport scopeGMId -> do - ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs - pure $ filter shouldForwardTo ms - where - shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m - currentModerator m@GroupMember {memberRole} = - memberRole >= GRModerator - && memberCurrent m - && maxVersion (memberChatVRange m) >= groupKnockingVersion + -- channel + | useRelays' gInfo = case jobScope of + -- there's no member review in channels, so job spec includePending is ignored + DJSGroup {} -> do + bucketSize <- asks $ deliveryBucketSize . config + sendLoop bucketSize startingCursor + where + sendLoop :: Int -> Maybe GroupMemberId -> CM () + sendLoop bucketSize cursorGMId_ = do + mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + unless (null mems) $ do + deliver body mems + let cursorGMId' = groupMemberId' $ last mems + withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' + unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + DJSMemberSupport scopeGMId -> do + -- for member support scope we just load all recipients in one go, without cursor + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + let moderatorFilter m = + memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion + && Just (groupMemberId' m) /= singleSenderGMId_ + modMs' = filter moderatorFilter modMs + mems <- + if Just scopeGMId == singleSenderGMId_ + then pure modMs' + else do + scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId + pure $ scopeMem : modMs' + unless (null mems) $ deliver body mems + -- fully connected group + | otherwise = case singleSenderGMId_ of + Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" + Just singleSenderGMId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + ms <- buildMemberList sender + unless (null ms) $ deliver body ms + where + buildMemberList sender = do + vec <- withStore (`getMemberRelationsVector` sender) + -- this excludes the sender + let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec + case jobScope of + DJSGroup {jobSpec} -> do + ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m + | jobSpecImpliedPending jobSpec = memberCurrentOrPending m + | otherwise = memberCurrent m + DJSMemberSupport scopeGMId -> do + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m + currentModerator m@GroupMember {memberRole} = + memberRole >= GRModerator + && memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion where deliver :: ByteString -> [GroupMember] -> CM () deliver msgBody mems = @@ -3277,3 +3616,116 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body Just 1 -> VRValue (Just 1) msgBody Just _ -> VRRef 1 + +-- Single worker processes all relay requests (XGrpRelayInv). +-- We use map with a single key 1 to fit into existing worker management framework. +relayRequestWorkerKey :: Int +relayRequestWorkerKey = 1 + +startRelayRequestWorker :: CM () +startRelayRequestWorker = do + hasPending <- withStore' hasPendingRelayRequests + when hasPending $ lift resumeRelayRequestWork + +resumeRelayRequestWork :: CM' () +resumeRelayRequestWork = void $ getRelayRequestWorker False + +getRelayRequestWorker :: Bool -> CM' Worker +getRelayRequestWorker hasWork = do + ws <- asks relayRequestWorkers + a <- asks smpAgent + getAgentWorker "relay_request" hasWork a relayRequestWorkerKey ws $ + runRelayRequestWorker a + +runRelayRequestWorker :: AgentClient -> Worker -> CM () +runRelayRequestWorker a Worker {doWork} = do + vr <- chatVersionRange + (user, uclId) <- withStore $ \db -> do + user <- getRelayUser db + UserContactLink {userContactLinkId} <- getUserAddress db user + pure (user, userContactLinkId) + forever $ do + lift $ waitForWork doWork + runRelayRequestOperation vr user uclId + where + runRelayRequestOperation :: VersionRangeChat -> User -> Int64 -> CM () + runRelayRequestOperation vr user uclId = + withWork_ a doWork (withStore' getNextPendingRelayRequest) $ + \(groupId, rrd) -> do + ri <- asks $ reconnectInterval . agentConfig . config + withRetryInterval ri $ \_ loop -> do + liftIO $ waitWhileSuspended a + liftIO $ waitForUserNetwork a + processRelayRequest groupId rrd `catchAllErrors` retryTmpError loop groupId + where + retryTmpError :: CM () -> GroupId -> ChatError -> CM () + retryTmpError loop groupId = \case + ChatErrorAgent {agentError} | temporaryOrHostError agentError -> loop + e -> do + withStore' $ \db -> setRelayRequestErr db groupId (tshow e) + eToView e + processRelayRequest :: GroupId -> RelayRequestData -> CM () + processRelayRequest groupId rrd = do + (gInfo, groupLink_) <- withStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo + pure (gInfo, groupLink_) + -- Check if relay link already exists (recovery case) + case groupLink_ of + Right GroupLink {connLinkContact = CCLink _ sLnk_} -> + case sLnk_ of + Just sLnk -> acceptOwnerConnection rrd gInfo sLnk + Nothing -> throwChatError $ CEException "processRelayRequest: relay link doesn't have short link" + Left _ -> do + (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd gInfo + acceptOwnerConnection rrd gInfo' sLnk + where + getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfo -> CM (GroupInfo, ShortLinkContact) + getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} gInfo = do + (FixedLinkData {linkEntityId, rootKey}, cData@(ContactLinkData _ UserContactData {owners})) <- getShortLinkConnReq NRMBackground user reqGroupLink + liftIO (decodeLinkUserData cData) >>= \case + Nothing -> throwChatError $ CEException "getLinkDataCreateRelayLink: no group link data" + Just GroupShortLinkData {groupProfile = gp@GroupProfile {publicGroup}} -> do + pg <- case (linkEntityId, publicGroup) of + (Just entityId, Just pg@PublicGroupProfile {publicGroupId}) + | B64UrlByteString entityId == publicGroupId -> pure pg + _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: linkEntityId does not match profile publicGroupId" + validateGroupProfile gp + ((_, memberPrivKey), sLnk) <- createRelayLink gInfo + gInfo' <- withStore $ \db -> do + void $ updateGroupProfile db user gInfo gp + updateRelayGroupKeys db user gInfo pg rootKey memberPrivKey owners + getGroupInfo db vr user groupId + pure (gInfo', sLnk) + where + validateGroupProfile :: GroupProfile -> CM () + validateGroupProfile _groupProfile = do + -- TODO [relays] relay: validate group profile, verify owner's signature + pure () + createRelayLink :: GroupInfo -> CM (C.KeyPairEd25519, ShortLinkContact) + createRelayLink gi = do + let GroupInfo {membership} = gi + GroupMember {memberId = MemberId relayMemId, memberProfile = p} = membership + gVar <- asks random + groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + subMode <- chatReadVar subscriptionMode + sigKeys <- liftIO $ atomically $ C.generateKeyPair gVar + let crClientData = encodeJSON $ CRDataGroup groupLinkId + -- prepare link with relayMemId as linkEntityId (no server request) + (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) + ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink + sLnk <- case toShortLinkContact ccLink' of + Just sl -> pure sl + Nothing -> throwChatError $ CEException "failed to create relay link: no short link" + let userData = encodeShortLinkData $ RelayShortLinkData {relayProfile = fromLocalProfile p} + userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} + -- create connection with prepared link (single network call) + connId <- withAgent $ \a' -> createConnectionForLink a' NRMBackground (aUserId user) True ccLink preparedParams userLinkData CR.IKPQOff subMode + -- TODO [relays] starting role should be communicated in protocol from owner to relays + subRole <- asks $ channelSubscriberRole . config + void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode + pure (sigKeys, sLnk) + acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () + acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do + ownerMember <- withStore $ \db -> getHostMember db vr user groupId + void $ acceptRelayJoinRequestAsync user uclId gi ownerMember relayInvId reqChatVRange relayLink diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 056b857f80..e404388d8d 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -116,8 +116,7 @@ checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of Just Refl -> Right x Nothing -> Left "bad chat type" -data GroupChatScope - = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support +data GroupChatScope = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support deriving (Eq, Show, Ord) data GroupChatScopeTag @@ -172,8 +171,7 @@ data ChatInfo (c :: ChatType) where deriving instance Show (ChatInfo c) -data GroupChatScopeInfo - = GCSIMemberSupport {groupMember_ :: Maybe GroupMember} +data GroupChatScopeInfo = GCSIMemberSupport {groupMember_ :: Maybe GroupMember} deriving (Show) toChatScope :: GroupChatScopeInfo -> GroupChatScope @@ -292,6 +290,7 @@ data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectRcv :: CIDirection 'CTDirect 'MDRcv CIGroupSnd :: CIDirection 'CTGroup 'MDSnd CIGroupRcv :: GroupMember -> CIDirection 'CTGroup 'MDRcv + CIChannelRcv :: CIDirection 'CTGroup 'MDRcv CILocalSnd :: CIDirection 'CTLocal 'MDSnd CILocalRcv :: CIDirection 'CTLocal 'MDRcv @@ -306,6 +305,7 @@ data JSONCIDirection | JCIDirectRcv | JCIGroupSnd | JCIGroupRcv {groupMember :: GroupMember} + | JCIChannelRcv | JCILocalSnd | JCILocalRcv deriving (Show) @@ -316,6 +316,7 @@ jsonCIDirection = \case CIDirectRcv -> JCIDirectRcv CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m + CIChannelRcv -> JCIChannelRcv CILocalSnd -> JCILocalSnd CILocalRcv -> JCILocalRcv @@ -325,6 +326,7 @@ jsonACIDirection = \case JCIDirectRcv -> ACID SCTDirect SMDRcv CIDirectRcv JCIGroupSnd -> ACID SCTGroup SMDSnd CIGroupSnd JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m + JCIChannelRcv -> ACID SCTGroup SMDRcv CIChannelRcv JCILocalSnd -> ACID SCTLocal SMDSnd CILocalSnd JCILocalRcv -> ACID SCTLocal SMDRcv CILocalRcv @@ -359,10 +361,13 @@ chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed timedDeleteAt' :: CITimed -> Maybe UTCTime timedDeleteAt' CITimed {deleteAt} = deleteAt -chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> GroupMember -chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of - CIGroupSnd -> membership - CIGroupRcv m -> m +chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> Maybe GroupMember +chatItemMember GroupInfo {membership} ChatItem {chatDir, meta = CIMeta {showGroupAsSender}} = case chatDir of + CIGroupSnd + | showGroupAsSender -> Nothing + | otherwise -> Just membership + CIGroupRcv m -> Just m + CIChannelRcv -> Nothing chatItemRcvFromMember :: ChatItem c d -> Maybe GroupMember chatItemRcvFromMember ChatItem {chatDir} = case chatDir of @@ -383,6 +388,7 @@ data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv CDGroupSnd :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDSnd CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + CDChannelRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDRcv CDLocalSnd :: NoteFolder -> ChatDirection 'CTLocal 'MDSnd CDLocalRcv :: NoteFolder -> ChatDirection 'CTLocal 'MDRcv @@ -392,6 +398,7 @@ toCIDirection = \case CDDirectRcv _ -> CIDirectRcv CDGroupSnd _ _ -> CIGroupSnd CDGroupRcv _ _ m -> CIGroupRcv m + CDChannelRcv _ _ -> CIChannelRcv CDLocalSnd _ -> CILocalSnd CDLocalRcv _ -> CILocalRcv @@ -401,6 +408,7 @@ toChatInfo = \case CDDirectRcv c -> DirectChat c CDGroupSnd g s -> GroupChat g s CDGroupRcv g s _ -> GroupChat g s + CDChannelRcv g s -> GroupChat g s CDLocalSnd l -> LocalChat l CDLocalRcv l -> LocalChat l @@ -504,6 +512,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta editable :: Bool, forwardedByMember :: Maybe GroupMemberId, showGroupAsSender :: ShowGroupAsSender, + msgSigned :: Maybe MsgSigStatus, createdAt :: UTCTime, updatedAt :: UTCTime } @@ -511,12 +520,12 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta type ShowGroupAsSender = Bool -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgSigned createdAt updatedAt = let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs editable = deletable && isNothing itemForwarded hasLink = BoolDef hasLink_ - in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, hasLink, deletable, editable, forwardedByMember, showGroupAsSender, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, hasLink, deletable, editable, forwardedByMember, showGroupAsSender, msgSigned, createdAt, updatedAt} deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool deletable' itemContent itemDeleted itemTs allowedInterval currentTs = @@ -547,6 +556,7 @@ dummyMeta itemId ts itemText = editable = False, forwardedByMember = Nothing, showGroupAsSender = False, + msgSigned = Nothing, createdAt = ts, updatedAt = ts } @@ -634,23 +644,23 @@ deriving instance Show (CIQDirection c) data ACIQDirection = forall c. (ChatTypeI c, ChatTypeQuotable c) => ACIQDirection (SChatType c) (CIQDirection c) -jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection +jsonCIQDirection :: CIQDirection c -> JSONCIDirection jsonCIQDirection = \case - CIQDirectSnd -> Just JCIDirectSnd - CIQDirectRcv -> Just JCIDirectRcv - CIQGroupSnd -> Just JCIGroupSnd - CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m - CIQGroupRcv Nothing -> Nothing + CIQDirectSnd -> JCIDirectSnd + CIQDirectRcv -> JCIDirectRcv + CIQGroupSnd -> JCIGroupSnd + CIQGroupRcv (Just m) -> JCIGroupRcv m + CIQGroupRcv Nothing -> JCIChannelRcv -jsonACIQDirection :: Maybe JSONCIDirection -> Either String ACIQDirection +jsonACIQDirection :: JSONCIDirection -> Either String ACIQDirection jsonACIQDirection = \case - Just JCIDirectSnd -> Right $ ACIQDirection SCTDirect CIQDirectSnd - Just JCIDirectRcv -> Right $ ACIQDirection SCTDirect CIQDirectRcv - Just JCIGroupSnd -> Right $ ACIQDirection SCTGroup CIQGroupSnd - Just (JCIGroupRcv m) -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv (Just m) - Nothing -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing - Just JCILocalSnd -> Left "unquotable" - Just JCILocalRcv -> Left "unquotable" + JCIDirectSnd -> Right $ ACIQDirection SCTDirect CIQDirectSnd + JCIDirectRcv -> Right $ ACIQDirection SCTDirect CIQDirectRcv + JCIGroupSnd -> Right $ ACIQDirection SCTGroup CIQGroupSnd + JCIGroupRcv m -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv (Just m) + JCIChannelRcv -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing + JCILocalSnd -> Left "unquotable" + JCILocalRcv -> Left "unquotable" quoteMsgDirection :: CIQDirection c -> MsgDirection quoteMsgDirection = \case @@ -1141,23 +1151,22 @@ type ChatItemTs = UTCTime data SndMessage = SndMessage { msgId :: MessageId, sharedMsgId :: SharedMsgId, - msgBody :: MsgBody + msgBody :: MsgBody, + signedMsg_ :: Maybe SignedMsg } deriving (Show) data NewRcvMessage e = NewRcvMessage { chatMsgEvent :: ChatMsgEvent e, - msgBody :: MsgBody, + verifiedMsg :: VerifiedMsg e, brokerTs :: UTCTime } - deriving (Show) data RcvMessage = RcvMessage { msgId :: MessageId, chatMsgEvent :: AChatMsgEvent, sharedMsgId_ :: Maybe SharedMsgId, - msgBody :: MsgBody, - authorMember :: Maybe GroupMemberId, + msgSigned :: Maybe MsgSigStatus, forwardedByMember :: Maybe GroupMemberId } @@ -1468,7 +1477,7 @@ instance FromJSON ACIDirection where parseJSON v = jsonACIDirection <$> J.parseJSON v instance ChatTypeI c => FromJSON (CIQDirection c) where - parseJSON v = (jsonACIQDirection >=> \(ACIQDirection _ x) -> checkChatType x) <$?> J.parseJSON v + parseJSON v = (jsonACIQDirection . fromMaybe JCIChannelRcv >=> \(ACIQDirection _ x) -> checkChatType x) <$?> J.parseJSON v instance ToJSON (CIQDirection c) where toJSON = J.toJSON . jsonCIQDirection diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 2c3bd2b87d..a9e835a83e 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -6,6 +7,10 @@ module Simplex.Chat.Messages.Batch ( MsgBatch (..), + BatchMode (..), + encodeBatchElement, + encodeFwdElement, + encodeBinaryBatch, batchMessages, batchDeliveryTasks1, ) @@ -22,73 +27,94 @@ import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Types (VersionRangeChat) +import Simplex.Messaging.Encoding (Large (..), smpEncode, smpEncodeList) + +data BatchMode = BMJson | BMBinary + deriving (Eq, Show) + +-- | Encode a batch element with optional signature prefix. +-- Dual of elementP's '/'/'{'cases. +encodeBatchElement :: Maybe SignedMsg -> ByteString -> ByteString +encodeBatchElement Nothing body = body +encodeBatchElement (Just SignedMsg {chatBinding, signatures}) body = + "/" <> smpEncode (chatBinding, signatures) <> body data MsgBatch = MsgBatch ByteString [SndMessage] --- | Batches SndMessages in [Either ChatError SndMessage] into batches of ByteStrings in form of JSON arrays. +-- | Batches SndMessages in [Either ChatError SndMessage] into batches of ByteStrings. +-- BMJson mode: JSON arrays like [msg1,msg2,...] +-- BMBinary mode: Binary format =()* -- Preserves original errors in the list. --- Does not check if the resulting batch is a valid JSON. --- If a single element is passed, it is returned as is (a JSON string). +-- If a single element is passed, it is returned as is. -- If an element exceeds maxLen, it is returned as ChatError. -batchMessages :: Int -> [Either ChatError SndMessage] -> [Either ChatError MsgBatch] -batchMessages maxLen = addBatch . foldr addToBatch ([], [], 0, 0) +-- Elements are encoded with signature prefix via encodeBatchElement. +batchMessages :: BatchMode -> Int -> [Either ChatError SndMessage] -> [Either ChatError MsgBatch] +batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) where - msgBatch batch = Right (MsgBatch (encodeMessages batch) batch) - addToBatch :: Either ChatError SndMessage -> ([Either ChatError MsgBatch], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [SndMessage], Int, Int) - addToBatch (Left err) acc = (Left err : addBatch acc, [], 0, 0) -- step over original error - addToBatch (Right msg@SndMessage {msgBody}) acc@(batches, batch, len, n) - | batchLen <= maxLen = (batches, msg : batch, len', n + 1) - | msgLen <= maxLen = (addBatch acc, [msg], msgLen, 1) - | otherwise = (errLarge msg : addBatch acc, [], 0, 0) + addToBatch :: Either ChatError SndMessage -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) + addToBatch (Left err) acc = (Left err : addBatch acc, [], [], 0, 0) -- step over original error + addToBatch (Right msg@SndMessage {msgBody, signedMsg_}) acc@(batches, bodies, msgs, len, n) + | batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') + | msgLen <= maxLen = (addBatch acc, [body], [msg], msgLen, 1) + | otherwise = (errLarge msg : addBatch acc, [], [], 0, 0) where - msgLen = B.length msgBody - len' - | n == 0 = msgLen - | otherwise = msgLen + len + 1 -- 1 accounts for comma - batchLen - | n == 0 = len' - | otherwise = len' + 2 -- 2 accounts for opening and closing brackets + body = encodeBatchElement signedMsg_ msgBody + msgLen = B.length body + len' = len + msgLen + n' = n + 1 errLarge SndMessage {msgId} = Left $ ChatError $ CEInternalError ("large message " <> show msgId) - addBatch :: ([Either ChatError MsgBatch], [SndMessage], Int, Int) -> [Either ChatError MsgBatch] - addBatch (batches, batch, _, n) = if n == 0 then batches else msgBatch batch : batches - encodeMessages :: [SndMessage] -> ByteString - encodeMessages = \case - [] -> mempty - [msg] -> body msg - msgs -> B.concat ["[", B.intercalate "," (map body msgs), "]"] - body SndMessage {msgBody} = msgBody + addBatch :: ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) -> [Either ChatError MsgBatch] + addBatch (batches, bodies, msgs, _, n) + | n == 0 = batches + | otherwise = + let encoded = encodeBatch mode bodies + in Right (MsgBatch encoded msgs) : batches -- | Batches delivery tasks into (batch, [taskIds], [largeTaskIds]). +-- Always uses binary batch format for relay groups. batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) -batchDeliveryTasks1 vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList +batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where addToBatch :: ([ByteString], [Int64], [Int64], Int, Int) -> MessageDeliveryTask -> ([ByteString], [Int64], [Int64], Int, Int) addToBatch (msgBodies, taskIds, largeTaskIds, len, n) task - -- too large: skip msgBody, record taskId in largeTaskIds + -- too large: skip, record taskId in largeTaskIds | msgLen > maxLen = (msgBodies, taskIds, taskId : largeTaskIds, len, n) -- fits: include in batch - | batchLen <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) - -- doesn’t fit: stop adding further messages + -- batch overhead: '=' + count (2) + 2-byte length prefix per element + | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) + -- doesn't fit: stop adding further messages | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) where - MessageDeliveryTask {taskId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel = _messageFromChannel} = task - -- TODO [channels fwd] handle messageFromChannel (null memberId in XGrpMsgForward) - msgBody = - let fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs - cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} - in chatMsgToBody cm + MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task + msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody - len' - | n == 0 = msgLen - | otherwise = msgLen + len + 1 -- 1 accounts for comma - batchLen - | n == 0 = len' - | otherwise = len' + 2 -- 2 accounts for opening and closing brackets + len' = len + msgLen toResult :: ([ByteString], [Int64], [Int64], Int, Int) -> (ByteString, [Int64], [Int64]) toResult (msgBodies, taskIds, largeTaskIds, _, _) = - (encodeMessages (reverse msgBodies), reverse taskIds, reverse largeTaskIds) - encodeMessages :: [ByteString] -> ByteString - encodeMessages = \case - [] -> mempty - [msg] -> msg - msgs -> B.concat ["[", B.intercalate "," msgs, "]"] + let encoded = encodeBinaryBatch (reverse msgBodies) + in (encoded, reverse taskIds, reverse largeTaskIds) + +-- | Encode a batch element for relay groups: >[/]. +encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString +encodeFwdElement fwd verifiedMsg = ">" <> smpEncode fwd <> encodeBatchElement signedMsg_ msgBody + where + (_, signedMsg_, msgBody) = verifiedMsgParts verifiedMsg + +encodeBatch :: BatchMode -> [ByteString] -> ByteString +encodeBatch _ [] = mempty +encodeBatch _ [msg] = msg +encodeBatch BMJson msgs = B.concat ["[", B.intercalate "," msgs, "]"] +encodeBatch BMBinary msgs = B.cons '=' $ smpEncodeList (map Large msgs) + +-- Always uses batch format (no single-element shortcut) since elements may have F prefix. +encodeBinaryBatch :: [ByteString] -> ByteString +encodeBinaryBatch [] = mempty +encodeBinaryBatch msgs = B.cons '=' $ smpEncodeList (map Large msgs) + +-- Returns length the batch would have if encoded. +-- `len` - the total length of all `n` encoded elements (including signature prefixes) +batchLen :: BatchMode -> Int -> Int -> Int +batchLen _ _ 0 = 0 +batchLen _ len 1 = len +batchLen BMJson len n = len + n + 1 -- (n - 1) commas + 2 brackets +batchLen BMBinary len n = len + n * 2 + 2 -- 2-byte length prefix per element + '=' + count diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index fedb6cd8e0..e2e878033d 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -227,6 +227,7 @@ ciRequiresAttention content = case msgDirection @d of RGEMemberCreatedContact -> False RGEMemberProfileUpdated {} -> False RGENewMemberPendingReview -> True + RGEMsgBadSignature -> False CIRcvConnEvent _ -> True CIRcvChatFeature {} -> False CIRcvChatPreference {} -> False @@ -349,6 +350,7 @@ rcvGroupEventToText = \case RGEMemberCreatedContact -> "started direct connection with you" RGEMemberProfileUpdated {} -> "updated profile" RGENewMemberPendingReview -> "new member wants to join the group" + RGEMsgBadSignature -> "message rejected: bad signature" sndGroupEventToText :: SndGroupEvent -> Text sndGroupEventToText = \case diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index adacb06ee4..6406cc74f6 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -32,6 +32,7 @@ data RcvGroupEvent | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv | RGEMemberProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRGroupMemberUpdated | RGENewMemberPendingReview + | RGEMsgBadSignature deriving (Show) data SndGroupEvent diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 9e77cc07b3..018457c7e7 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -255,6 +255,7 @@ mobileChatOpts dbOptions = logFile = Nothing, tbqSize = 4096, deviceName = Nothing, + chatRelay = False, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Just "", diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 08c7b84087..3bbfb02d0a 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -45,8 +46,11 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Types (User) +import Simplex.Chat.Protocol (RelayProfile (..)) +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_) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Encoding.String @@ -180,14 +184,16 @@ conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAccept data UserOperatorServers = UserOperatorServers { operator :: Maybe ServerOperator, smpServers :: [UserServer 'PSMP], - xftpServers :: [UserServer 'PXFTP] + xftpServers :: [UserServer 'PXFTP], + chatRelays :: [UserChatRelay] } deriving (Show) data UpdatedUserOperatorServers = UpdatedUserOperatorServers { operator :: Maybe ServerOperator, smpServers :: [AUserServer 'PSMP], - xftpServers :: [AUserServer 'PXFTP] + xftpServers :: [AUserServer 'PXFTP], + chatRelays :: [AUserChatRelay] } deriving (Show) @@ -196,25 +202,34 @@ data ValidatedProtoServer p = ValidatedProtoServer {unVPS :: Either Text (ProtoS class UserServersClass u where type AServer u = (s :: ProtocolType -> Type) | s -> u + type AChatRelay u = (s :: Type) | s -> u operator' :: u -> Maybe ServerOperator aUserServer' :: AServer u p -> AUserServer p servers' :: UserProtocol p => SProtocolType p -> u -> [AServer u p] + chatRelays' :: u -> [AChatRelay u] + aUserChatRelay' :: AChatRelay u -> AUserChatRelay instance UserServersClass UserOperatorServers where type AServer UserOperatorServers = UserServer' 'DBStored + type AChatRelay UserOperatorServers = UserChatRelay' 'DBStored operator' UserOperatorServers {operator} = operator aUserServer' = AUS SDBStored servers' p UserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers + chatRelays' UserOperatorServers {chatRelays} = chatRelays + aUserChatRelay' = AUCR SDBStored instance UserServersClass UpdatedUserOperatorServers where type AServer UpdatedUserOperatorServers = AUserServer + type AChatRelay UpdatedUserOperatorServers = AUserChatRelay operator' UpdatedUserOperatorServers {operator} = operator aUserServer' = id servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers + chatRelays' UpdatedUserOperatorServers {chatRelays} = chatRelays + aUserChatRelay' = id type UserServer p = UserServer' 'DBStored p @@ -238,12 +253,52 @@ presetServerAddress :: UserServer' s p -> ProtocolServer p presetServerAddress UserServer {server = ProtoServerWithAuth srv _} = srv {-# INLINE presetServerAddress #-} +type UserChatRelay = UserChatRelay' 'DBStored + +type NewUserChatRelay = UserChatRelay' 'DBNew + +data AUserChatRelay = forall s. AUCR (SDBStored s) (UserChatRelay' s) + +deriving instance Show AUserChatRelay + +data UserChatRelay' s = UserChatRelay + { chatRelayId :: DBEntityId' s, + address :: ShortLinkContact, + relayProfile :: RelayProfile, + domains :: [Text], + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } + 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, + name :: Text + } + deriving (Show) + data PresetOperator = PresetOperator { operator :: Maybe NewServerOperator, smp :: [NewUserServer 'PSMP], useSMP :: Int, xftp :: [NewUserServer 'PXFTP], - useXFTP :: Int + useXFTP :: Int, + chatRelays :: [NewUserChatRelay], + useChatRelays :: Int } deriving (Show) @@ -262,17 +317,32 @@ operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of presetServer' :: Bool -> ProtocolServer p -> NewUserServer p presetServer' enabled = presetServer enabled . (`ProtoServerWithAuth` Nothing) +{-# INLINE presetServer' #-} presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p presetServer = newUserServer_ True +{-# INLINE presetServer #-} newUserServer :: ProtoServerWithAuth p -> NewUserServer p newUserServer = newUserServer_ False True +{-# INLINE newUserServer #-} newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} +presetChatRelay :: Bool -> RelayProfile -> [Text] -> ShortLinkContact -> NewUserChatRelay +presetChatRelay = newChatRelay_ True +{-# INLINE presetChatRelay #-} + +newChatRelay :: RelayProfile -> [Text] -> ShortLinkContact -> NewUserChatRelay +newChatRelay = newChatRelay_ False True +{-# INLINE newChatRelay #-} + +newChatRelay_ :: Bool -> Bool -> RelayProfile -> [Text] -> ShortLinkContact -> NewUserChatRelay +newChatRelay_ preset enabled relayProfile domains !address = + UserChatRelay {chatRelayId = DBNewEntity, address, relayProfile, domains, preset, tested = Nothing, enabled, deleted = False} + -- This function should be used inside DB transaction to update conditions in the database -- it evaluates to (current conditions, and conditions to add) usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) @@ -300,8 +370,8 @@ usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case presetUserServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [UpdatedUserOperatorServers] presetUserServers = mapMaybe $ \(presetOp_, op) -> mkUS op <$> presetOp_ where - mkUS op PresetOperator {smp, xftp} = - UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) + mkUS op PresetOperator {smp, xftp, chatRelays} = + UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) (map (AUCR SDBNew) chatRelays) -- This function should be used inside DB transaction to update operators. -- It allows to add/remove/update preset operators in the database preserving enabled and roles settings, @@ -321,13 +391,14 @@ updatedServerOperators presetOps storedOps = -- This function should be used inside DB transaction to update servers. updatedUserServers :: (Maybe PresetOperator, UserOperatorServers) -> UpdatedUserOperatorServers -updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers}) = - UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp'} +updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers, chatRelays}) = + UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp', chatRelays = cRelays'} where stored = map (AUS SDBStored) - (smp', xftp') = case presetOp_ of - Nothing -> (stored smpServers, stored xftpServers) - Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers) + storedRelays = map (AUCR SDBStored) + (smp', xftp', cRelays') = case presetOp_ of + Nothing -> (stored smpServers, stored xftpServers, storedRelays chatRelays) + Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers, updatedRelays chatRelays) where updated :: forall p. UserProtocol p => SProtocolType p -> [UserServer p] -> [AUserServer p] updated p srvs = map userServer presetSrvs <> stored (filter customServer srvs) @@ -335,17 +406,34 @@ updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpSe storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p) storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs customServer :: UserServer p -> Bool - customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv) + customServer srv@UserServer {preset} = not preset && all (`S.notMember` presetHosts) (srvHost srv) presetSrvs :: [NewUserServer p] presetSrvs = pServers p presetOp presetHosts :: Set TransportHost presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs userServer :: NewUserServer p -> AUserServer p userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs) + updatedRelays :: [UserChatRelay] -> [AUserChatRelay] + updatedRelays relays = map userRelay presetRelays <> storedRelays (filter customRelay relays) + where + customRelay :: UserChatRelay -> Bool + customRelay UserChatRelay {preset, address} = + not preset && not (any (sameShortLinkContact address . chatRelayAddress) presetRelays) + presetRelays :: [NewUserChatRelay] + presetRelays = + let PresetOperator {chatRelays = crs} = presetOp + in crs + userRelay :: NewUserChatRelay -> AUserChatRelay + userRelay relay@UserChatRelay {address} = + maybe (AUCR SDBNew relay) (AUCR SDBStored) $ + find (sameShortLinkContact address . chatRelayAddress) relays srvHost :: UserServer' s p -> NonEmpty TransportHost srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv +chatRelayAddress :: UserChatRelay' s -> ShortLinkContact +chatRelayAddress UserChatRelay {address} = address + agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] -> [UserServer' s p] -> [ServerCfg p] agentServerCfgs p opDomains = mapMaybe agentServer where @@ -378,46 +466,57 @@ instance Box ((,) (Maybe a)) where box = (Nothing,) unbox = snd -groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] -groupByOperator (ops, smpSrvs, xftpSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs) +groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> IO [UserOperatorServers] +groupByOperator (ops, smpSrvs, xftpSrvs, chatRelays) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs, chatRelays) -- For the initial app start this function relies on tuple being Functor/Box -- to preserve the information about operator being DBNew or DBStored -groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [(Maybe PresetOperator, UserOperatorServers)] +groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> IO [(Maybe PresetOperator, UserOperatorServers)] groupByOperator' = groupByOperator_ {-# INLINE groupByOperator' #-} -groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [f UserOperatorServers] -groupByOperator_ (ops, smpSrvs, xftpSrvs) = do +groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> IO [f UserOperatorServers] +groupByOperator_ (ops, smpSrvs, xftpSrvs, cRelays) = do let ops' = mapMaybe sequence ops customOp_ = find (isNothing . unbox) ops ss <- mapM ((\op -> (serverDomains (unbox op),) <$> newIORef (mkUS . Just <$> op))) ops' custom <- newIORef $ maybe (box $ mkUS Nothing) (mkUS <$>) customOp_ mapM_ (addServer ss custom addSMP) (reverse smpSrvs) mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs) + mapM_ (addChatRelay ss custom) cRelays opSrvs <- mapM (readIORef . snd) ss customSrvs <- readIORef custom pure $ opSrvs <> [customSrvs] where - mkUS op = UserOperatorServers op [] [] + mkUS op = UserOperatorServers op [] [] [] addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () addServer ss custom add srv = let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss in atomicModifyIORef'_ v (add srv <$>) addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} + addChatRelay :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> UserChatRelay -> IO () + addChatRelay ss custom chatRelay = + let v = maybe custom snd $ find (\(ds, _) -> any (`elem` domains chatRelay) ds) ss + in atomicModifyIORef'_ v (addCRelay <$>) + where + addCRelay s@UserOperatorServers {chatRelays} = (s :: UserOperatorServers) {chatRelays = chatRelay : chatRelays} data UserServersError = USENoServers {protocol :: AProtocolType, user :: Maybe User} | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} + | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ShortLinkContact} deriving (Show) -validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> [UserServersError] -validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others +data UserServersWarning = USWNoChatRelays {user :: Maybe User} + deriving (Show) + +validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> ([UserServersError], [UserServersWarning]) +validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs others, currUserWarns <> concatMap otherUserWarns others) where - currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr + currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr <> chatRelayErrs curr otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] noServersErrs p user uss @@ -426,7 +525,6 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others where p' = AProtocolType p noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss - opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator' srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] @@ -437,13 +535,39 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others duplicateErr_ (AUS _ srv@UserServer {server}) = USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) <$> find (`S.member` duplicateHosts) (srvHost srv) - duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts + duplicateHosts = snd $ foldl' addDuplicate (S.empty, S.empty) allHosts allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs - addHost (hs, dups) h - | h `S.member` hs = (hs, S.insert h dups) - | otherwise = (S.insert h hs, dups) userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] userServers p = map aUserServer' . concatMap (servers' p) + chatRelayErrs :: UserServersClass u => [u] -> [UserServersError] + chatRelayErrs uss = concatMap duplicateErrs_ cRelays + where + cRelays = filter (\(AUCR _ UserChatRelay {deleted}) -> not deleted) $ userChatRelays uss + duplicateErrs_ (AUCR _ UserChatRelay {relayProfile = RelayProfile {displayName}, address}) = + [USEDuplicateChatRelayAddress displayName address | address `elem` duplicateAddresses] + duplicateAddresses = snd $ foldl' addAddress ([], []) allAddresses + allAddresses = map (\(AUCR _ UserChatRelay {address}) -> address) cRelays + addAddress :: ([ShortLinkContact], [ShortLinkContact]) -> ShortLinkContact -> ([ShortLinkContact], [ShortLinkContact]) + addAddress (xs, dups) x + | any (sameShortLinkContact x) xs = (xs, x : dups) + | otherwise = (x : xs, dups) + currUserWarns = noChatRelaysWarns Nothing curr + otherUserWarns (user, uss) = noChatRelaysWarns (Just user) uss + noChatRelaysWarns :: UserServersClass u => Maybe User -> [u] -> [UserServersWarning] + noChatRelaysWarns user uss + | noChatRelays opEnabled = [USWNoChatRelays user] + | otherwise = [] + where + noChatRelays cond = not $ any relayEnabled $ userChatRelays $ filter cond uss + relayEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted + userChatRelays :: UserServersClass u => [u] -> [AUserChatRelay] + userChatRelays = map aUserChatRelay' . concatMap chatRelays' + opEnabled :: UserServersClass u => u -> Bool + opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' + addDuplicate :: Ord a => (Set a, Set a) -> a -> (Set a, Set a) + addDuplicate (xs, dups) x + | x `S.member` xs = (xs, S.insert x dups) + | otherwise = (S.insert x xs, dups) $(JQ.deriveJSON defaultJSON ''UsageConditions) @@ -470,9 +594,23 @@ instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where instance ProtocolTypeI p => FromJSON (AUserServer p) where parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v) +instance ToJSON (UserChatRelay' s) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserChatRelay') + toJSON = $(JQ.mkToJSON defaultJSON ''UserChatRelay') + +instance DBStoredI s => FromJSON (UserChatRelay' s) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserChatRelay') + +instance FromJSON AUserChatRelay where + parseJSON v = (AUCR SDBStored <$> parseJSON v) <|> (AUCR SDBNew <$> parseJSON v) + $(JQ.deriveJSON defaultJSON ''UserOperatorServers) instance FromJSON UpdatedUserOperatorServers where parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USW") ''UserServersWarning) + +$(JQ.deriveJSON defaultJSON ''GroupRelay) diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index d3d727ea05..23c9157121 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -7,9 +7,12 @@ module Simplex.Chat.Operators.Presets where import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L +import Simplex.Chat.Library.Internal (simplexChatImage) import Simplex.Chat.Operators +import Simplex.Chat.Protocol (mkRelayProfile) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.Entity +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ProtocolType (..), SMPServer) operatorSimpleXChat :: NewServerOperator @@ -87,6 +90,12 @@ disabledSimplexChatSMPServers = "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ] +simplexChatRelays :: [NewUserChatRelay] +simplexChatRelays = + [ presetChatRelay True (mkRelayProfile "SimpleX Chat Relay 1" $ Just simplexChatImage) ["simplex.im"] (either error id $ strDecode "https://smp5.simplex.im/r#Fp5RWXkiRFg-hgcDwC2v-MWnPfvEf42RgCqREntW0mw"), + presetChatRelay True (mkRelayProfile "SimpleX Chat Relay 2" $ Just simplexChatImage) ["simplex.im"] (either error id $ strDecode "https://smp6.simplex.im/r#_qlQfogHGDJ8MAF2wKmkglRBM-xHR142gDJstKiGRQQ") + ] + fluxSMPServers :: [NewUserServer 'PSMP] fluxSMPServers = map (presetServer' True) (L.toList fluxSMPServers_) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 6995971c99..08a765077f 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -65,6 +65,7 @@ data CoreChatOpts = CoreChatOpts logFile :: Maybe FilePath, tbqSize :: Natural, deviceName :: Maybe Text, + chatRelay :: Bool, highlyAvailable :: Bool, yesToUpMigrations :: Bool, migrationBackupPath :: Maybe FilePath, @@ -234,6 +235,11 @@ coreChatOptsP appDir defaultDbName = do <> metavar "DEVICE" <> help "Device name to use in connections with remote hosts and controller" ) + chatRelay <- + switch + ( long "relay" + <> help "Run as a chat relay client" + ) highlyAvailable <- switch ( long "ha" @@ -276,6 +282,7 @@ coreChatOptsP appDir defaultDbName = do logFile, tbqSize, deviceName, + chatRelay, highlyAvailable, yesToUpMigrations, migrationBackupPath, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index e7a52f5153..6d7a094430 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE KindSignatures #-} @@ -21,7 +22,7 @@ module Simplex.Chat.Protocol where import Control.Applicative ((<|>)) -import Control.Monad ((<=<)) +import Control.Monad (when, (<=<)) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE @@ -34,15 +35,17 @@ import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (fromRight) +import Data.Int (Int64) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isJust, isNothing) import Data.String import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) +import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) @@ -54,6 +57,7 @@ import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) @@ -243,7 +247,7 @@ data MsgRef = MsgRef { msgId :: Maybe SharedMsgId, sentAt :: UTCTime, sent :: Bool, - memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received + memberId :: Maybe MemberId -- present in group message references, Nothing for channel messages } deriving (Eq, Show) @@ -310,12 +314,109 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) -type MessageFromChannel = Bool +-- Can be extended to support profile identity keys (e.g., secp256k1 for Nostr) +data KeyRef = KRMember + deriving (Eq, Show) + +data ChatBinding = CBGroup + deriving (Eq, Show) + +data MsgSignature = MsgSignature KeyRef C.ASignature + deriving (Show) + +data SignedMsg = SignedMsg + { chatBinding :: ChatBinding, + signatures :: L.NonEmpty MsgSignature, + signedBody :: ByteString -- exact bytes that were signed + } + deriving (Show) + +-- | Post-verification message. Encodes the invariant that signature +-- has been checked (or wasn't required). Store and forward functions +-- accept only VerifiedMsg, preventing unverified messages from being persisted. +data VerifiedMsg e + = VMUnsigned (ChatMessage e) + | VMSigned MsgSigStatus SignedMsg (ChatMessage e) + +data ParsedMsg e = ParsedMsg (Maybe GrpMsgForward) (Maybe SignedMsg) (ChatMessage e) + +data AParsedMsg = forall e. MsgEncodingI e => APMsg (SMsgEncoding e) (ParsedMsg e) + +data FwdSender + = FwdMember MemberId ContactName + | FwdChannel + deriving (Eq, Show) + +data GrpMsgForward = GrpMsgForward + { fwdSender :: FwdSender, + fwdBrokerTs :: UTCTime + } + deriving (Eq, Show) + + +instance Encoding FwdSender where + smpEncode = \case + FwdMember memberId memberName -> smpEncode ('M', memberId, memberName) + FwdChannel -> "C" + smpP = + A.anyChar >>= \case + 'M' -> uncurry FwdMember <$> smpP + 'C' -> pure FwdChannel + c -> fail $ "invalid FwdSender tag: " <> show c + +instance Encoding GrpMsgForward where + smpEncode GrpMsgForward {fwdSender, fwdBrokerTs} = + smpEncode (fwdSender, utcToSystemTime fwdBrokerTs) + smpP = do + fwdSender <- smpP + fwdBrokerTs <- systemToUTCTime <$> smpP + pure GrpMsgForward {fwdSender, fwdBrokerTs} + +instance Encoding KeyRef where + smpEncode = \case + KRMember -> "M" + smpP = + A.anyChar >>= \case + 'M' -> pure KRMember + c -> fail $ "invalid KeyRef tag: " <> show c + +instance Encoding ChatBinding where + smpEncode CBGroup = "G" + smpP = + A.anyChar >>= \case + 'G' -> pure CBGroup + c -> fail $ "invalid ChatBinding: " <> show c + +instance ToField ChatBinding where toField = toField . decodeLatin1 . smpEncode + +instance FromField ChatBinding where fromField = fromTextField_ $ eitherToMaybe . smpDecode . encodeUtf8 + +instance Encoding MsgSignature where + smpEncode (MsgSignature keyRef sig) = smpEncode (keyRef, C.signatureBytes sig) + smpP = MsgSignature <$> smpP <*> (C.decodeSignature <$?> smpP) + +-- Wire format: ()* +instance Encoding SignedMsg where + smpEncode SignedMsg {chatBinding, signatures, signedBody} = smpEncode (chatBinding, signatures, Tail signedBody) + smpP = do + (chatBinding, signatures, Tail signedBody) <- smpP + pure SignedMsg {chatBinding, signatures, signedBody} + +-- | Generic signing context — data, not function. +-- Callers construct per-event; createSndMessages uses mechanically. +data MsgSigning = MsgSigning + { bindingTag :: ChatBinding, + bindingData :: ByteString, + keyRef :: KeyRef, + privKey :: C.PrivateKeyEd25519 + } + + data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -325,6 +426,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json + XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json @@ -332,6 +434,9 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json + XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json + XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -347,7 +452,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json - XGrpMsgForward :: MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json + XGrpMsgForward :: GrpMsgForward -> ChatMessage 'Json -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json XInfoProbeOk :: Probe -> ChatMsgEvent 'Json @@ -626,7 +731,8 @@ data ExtMsgContent = ExtMsgContent file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool, - scope :: Maybe MsgScope + scope :: Maybe MsgScope, + asGroup :: Maybe Bool } deriving (Eq, Show) @@ -671,26 +777,52 @@ encodeChatMessage maxSize msg = do else ECMEncoded body AMBinary m -> ECMEncoded $ strEncode m -parseChatMessages :: ByteString -> [Either String AChatMessage] +parseChatMessages :: ByteString -> [Either String AParsedMsg] parseChatMessages "" = [Left "empty string"] parseChatMessages msg = case B.head msg of 'X' -> decodeCompressed (B.tail msg) c -> parseUncompressed c msg where parseUncompressed c s = case c of - '{' -> [ACMsg SJson <$> J.eitherDecodeStrict' s] '[' -> case J.eitherDecodeStrict' s of - Right v -> map parseItem v + Right v -> map (fmap plainMsg . parseItem) v Left e -> [Left e] - _ -> [ACMsg SBinary <$> (appBinaryToCM =<< strDecode s)] + '=' -> decodeBinaryBatch (B.tail s) + _ -> [parseAll (elementP Nothing) s] + plainMsg = aParsedMsg Nothing Nothing + aParsedMsg fwd sm (ACMsg enc cm) = APMsg enc (ParsedMsg fwd sm cm) + parseMsg s = ACMsg SJson <$> J.eitherDecodeStrict' s + msgP :: A.Parser AChatMessage + msgP = parseMsg <$?> A.takeByteString parseItem :: J.Value -> Either String AChatMessage parseItem v = ACMsg SJson <$> JT.parseEither parseJSON v - decodeCompressed :: ByteString -> [Either String AChatMessage] - decodeCompressed s' = case smpDecode s' of + decodeCompressed :: ByteString -> [Either String AParsedMsg] + decodeCompressed s = case smpDecode s of Left e -> [Left e] - Right (compressed :: L.NonEmpty Compressed) -> concatMap (either (pure . Left) parseUncompressed' . decompress1 maxDecompressedMsgLength) compressed + Right (compressed :: L.NonEmpty Compressed) -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1 maxDecompressedMsgLength) compressed parseUncompressed' "" = [Left "empty string"] parseUncompressed' s = parseUncompressed (B.head s) s + -- Binary batch format: '=' ( )* + decodeBinaryBatch :: ByteString -> [Either String AParsedMsg] + decodeBinaryBatch s = case parseAll smpListP s of + Left e -> [Left e] + Right msgs -> map parseBatchElement msgs + parseBatchElement :: Large -> Either String AParsedMsg + parseBatchElement (Large s) = parseAll (elementP Nothing) s + elementP :: Maybe GrpMsgForward -> A.Parser AParsedMsg + elementP fwd = A.peekChar' >>= \case + '/' -> A.char '/' *> do + tag <- smpP + sigs <- smpP + (body, acm) <- A.match msgP + pure $ aParsedMsg fwd (Just $ SignedMsg tag sigs body) acm + '>' -> A.char '>' *> do + when (isJust fwd) $ fail "nested forward elements not supported" + elementP . Just =<< smpP + '{' -> aParsedMsg fwd Nothing <$> msgP + -- 'F' must match BFileChunk_ tag encoding + 'F' -> aParsedMsg fwd Nothing . ACMsg SBinary <$> (appBinaryToCM <$?> strP) + c -> fail $ "invalid element prefix: " <> show c compressedBatchMsgBody_ :: MsgBody -> ByteString compressedBatchMsgBody_ = markCompressedBatch . smpEncode . (L.:| []) . compress1 @@ -716,10 +848,11 @@ parseMsgContainer v = live <- v .:? "live" mentions <- fromMaybe M.empty <$> (v .:? "mentions") scope <- v .:? "scope" - pure ExtMsgContent {content, mentions, file, ttl, live, scope} + asGroup <- v .:? "asGroup" + pure ExtMsgContent {content, mentions, file, ttl, live, scope, asGroup} extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent -extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing Nothing +extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing Nothing Nothing justTrue :: Bool -> Maybe Bool justTrue True = Just True @@ -772,8 +905,8 @@ msgContainerJSON = \case MCSimple mc -> o $ msgContent mc where o = JM.fromList - msgContent ExtMsgContent {content, mentions, file, ttl, live, scope} = - ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) $ ("scope" .=? scope) ["content" .= content] + msgContent ExtMsgContent {content, mentions, file, ttl, live, scope, asGroup} = + ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) $ ("scope" .=? scope) $ ("asGroup" .=? asGroup) ["content" .= content] nonEmptyMap :: Map k v -> Maybe (Map k v) nonEmptyMap m = if M.null m then Nothing else Just m @@ -822,6 +955,7 @@ data CMEventTag (e :: MsgEncoding) where XFileCancel_ :: CMEventTag 'Json XInfo_ :: CMEventTag 'Json XContact_ :: CMEventTag 'Json + XMember_ :: CMEventTag 'Json XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json @@ -829,6 +963,9 @@ data CMEventTag (e :: MsgEncoding) where XGrpLinkReject_ :: CMEventTag 'Json XGrpLinkMem_ :: CMEventTag 'Json XGrpLinkAcpt_ :: CMEventTag 'Json + XGrpRelayInv_ :: CMEventTag 'Json + XGrpRelayAcpt_ :: CMEventTag 'Json + XGrpRelayTest_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -875,6 +1012,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" + XMember_ -> "x.member" XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" @@ -882,6 +1020,9 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpLinkReject_ -> "x.grp.link.reject" XGrpLinkMem_ -> "x.grp.link.mem" XGrpLinkAcpt_ -> "x.grp.link.acpt" + XGrpRelayInv_ -> "x.grp.relay.inv" + XGrpRelayAcpt_ -> "x.grp.relay.acpt" + XGrpRelayTest_ -> "x.grp.relay.test" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -929,6 +1070,7 @@ instance StrEncoding ACMEventTag where "x.file.cancel" -> XFileCancel_ "x.info" -> XInfo_ "x.contact" -> XContact_ + "x.member" -> XMember_ "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ @@ -936,6 +1078,9 @@ instance StrEncoding ACMEventTag where "x.grp.link.reject" -> XGrpLinkReject_ "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.link.acpt" -> XGrpLinkAcpt_ + "x.grp.relay.inv" -> XGrpRelayInv_ + "x.grp.relay.acpt" -> XGrpRelayAcpt_ + "x.grp.relay.test" -> XGrpRelayTest_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -979,6 +1124,7 @@ toCMEventTag msg = case msg of XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact {} -> XContact_ + XMember {} -> XMember_ XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ @@ -986,6 +1132,9 @@ toCMEventTag msg = case msg of XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ XGrpLinkAcpt {} -> XGrpLinkAcpt_ + XGrpRelayInv _ -> XGrpRelayInv_ + XGrpRelayAcpt _ -> XGrpRelayAcpt_ + XGrpRelayTest {} -> XGrpRelayTest_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1048,6 +1197,37 @@ hasDeliveryReceipt = \case XCallInv_ -> True _ -> False +-- | Events that must have a valid signature in relay groups. +requiresSignature :: CMEventTag e -> Bool +requiresSignature = \case + XGrpDel_ -> True + XGrpInfo_ -> True + XGrpPrefs_ -> True + XGrpMemDel_ -> True + XGrpMemRole_ -> True + XGrpMemRestrict_ -> True + XGrpLeave_ -> True + XInfo_ -> True + _ -> False + +-- TODO [relays] relay: vectors tracking which members received which other member profiles/keys. +-- TODO - don't forward XGrpLeave/XInfo to members who haven't seen sender's profile/key. +-- TODO - unverifiedAllowed is a temporary workaround postponing targeted event forwarding. + +-- Allow signed but unverified XGrpLeave/XInfo between subscribers when sender's key is unknown. +-- Owner keys are always known, so subscribers are required to verify from owners. +-- Likewise, subscriber keys are always known to owners, so owners are required to verify from subscribers. +unverifiedAllowed :: GroupMember -> GroupMember -> CMEventTag e -> Bool +unverifiedAllowed membership member = \case + XGrpLeave_ -> membersNoKey + XInfo_ -> membersNoKey + _ -> False + where + membersNoKey = + memberRole' membership < GRModerator + && memberRole' member < GRModerator + && isNothing (memberPubKey member) + appBinaryToCM :: AppMessageBinary -> Either String (ChatMessage 'Binary) appBinaryToCM AppMessageBinary {msgId, tag, body} = do eventTag <- strDecode $ B.singleton tag @@ -1079,7 +1259,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do ttl <- opt "ttl" live <- opt "live" scope <- opt "scope" - pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} + asGroup <- opt "asGroup" + pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" <*> opt "scope" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> opt "scope" <*> p "reaction" <*> p "add" @@ -1096,6 +1277,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do reqContent <- opt "content" let requestMsg = (,) <$> reqMsgId <*> reqContent pure XContact {profile, contactReqId, welcomeMsgId, requestMsg} + XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" @@ -1103,6 +1285,12 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" + XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation" + XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" + XGrpRelayTest_ -> do + B64UrlByteString challenge <- p "challenge" + sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" + pure $ XGrpRelayTest challenge sig_ XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1118,7 +1306,12 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" - XGrpMsgForward_ -> XGrpMsgForward <$> p "memberId" <*> opt "memberName" <*> p "msg" <*> p "msgTs" + XGrpMsgForward_ -> do + fwdSender <- opt "memberId" >>= \case + Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName" + Nothing -> pure FwdChannel + fwdBrokerTs <- p "msgTs" + XGrpMsgForward (GrpMsgForward {fwdSender, fwdBrokerTs}) <$> p "msg" XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" @@ -1145,7 +1338,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] + XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId scope -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId scope reaction add -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] @@ -1155,6 +1348,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile] + XMember {profile, newMemberId, newMemberKey} -> o ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] @@ -1162,6 +1356,11 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] + XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv] + XGrpRelayAcpt relayLink -> o ["relayLink" .= relayLink] + XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString <$> sig_)) + ["challenge" .= B64UrlByteString challenge] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] @@ -1177,7 +1376,11 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] - XGrpMsgForward memberId memberName msg msgTs -> o $ ("memberName" .=? memberName) ["memberId" .= memberId, "msg" .= msg, "msgTs" .= msgTs] + XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs] + where + encodeFwdSender = \case + FwdMember memberId memberName -> (["memberId" .= memberId, "memberName" .= memberName] ++) + FwdChannel -> id XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] @@ -1198,6 +1401,20 @@ chatMsgToBody chatMsg = case encoding @e of SBinary -> chatMsgBinaryToBody chatMsg SJson -> LB.toStrict $ J.encode chatMsg +verifiedChatMsg :: VerifiedMsg e -> ChatMessage e +verifiedChatMsg = \case + VMUnsigned cm -> cm + VMSigned _ _ cm -> cm + +-- | Canonical bytes to store/forward, with optional signature. +-- Signed: original bytes (re-encoding would invalidate signature). +-- Unsigned: re-encoded from parsed ChatMessage (sanitizes stored content). +verifiedMsgParts :: MsgEncodingI e => VerifiedMsg e -> (Maybe MsgSigStatus, Maybe SignedMsg, ByteString) +verifiedMsgParts = \case + VMUnsigned chatMsg -> (Nothing, Nothing, chatMsgToBody chatMsg) + VMSigned s sm@SignedMsg {signedBody} _ -> (Just s, Just sm, signedBody) + + instance ToJSON (ChatMessage 'Json) where toJSON = (\(AMJson msg) -> toJSON msg) . chatToAppMessage @@ -1214,11 +1431,48 @@ data ContactShortLinkData = ContactShortLinkData } deriving (Show) +data PublicGroupData = PublicGroupData + { publicMemberCount :: Int64 + } + deriving (Eq, Show) + data GroupShortLinkData = GroupShortLinkData - { groupProfile :: GroupProfile + { groupProfile :: GroupProfile, + publicGroupData :: Maybe PublicGroupData } deriving (Show) $(JQ.deriveJSON defaultJSON ''ContactShortLinkData) +$(JQ.deriveJSON defaultJSON ''PublicGroupData) + $(JQ.deriveJSON defaultJSON ''GroupShortLinkData) + +data RelayShortLinkData = RelayShortLinkData + { relayProfile :: Profile + } + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayShortLinkData) + +data RelayProfile = RelayProfile + { displayName :: ContactName, + fullName :: Text, + shortDescr :: Maybe Text, + image :: Maybe ImageData + } + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''RelayProfile) + +toRelayProfile :: (ContactName, Text, Maybe Text, Maybe ImageData) -> RelayProfile +toRelayProfile (displayName, fullName, shortDescr, image) = RelayProfile {displayName, fullName, shortDescr, image} + +mkRelayProfile :: ContactName -> Maybe ImageData -> RelayProfile +mkRelayProfile displayName image = RelayProfile {displayName, fullName = "", shortDescr = Nothing, image} + +data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile} + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayAddressLinkData) + diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index e60c29f21a..f0c2502ff8 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,7 +75,7 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 6, 0] +minRemoteCtrlVersion = AppVersion [6, 5, 0, 12] -- when acting as controller minRemoteHostVersion :: AppVersion diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 3ae5e257b6..62183a0313 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -138,24 +138,26 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, 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, -- 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.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.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/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index c1da436f04..393e008835 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -29,18 +29,22 @@ module Simplex.Chat.Store.Delivery ) where +import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) +import qualified Data.List.NonEmpty as L import Data.Text (Text) import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Delivery import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Shared (MsgSigStatus (..)) import Simplex.Messaging.Agent.Store.AgentStore (getWorkItem, getWorkItems, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Util (firstRow') +import Simplex.Messaging.Encoding (smpDecode) +import Simplex.Messaging.Util (eitherToMaybe, firstRow') #if defined(dbPostgres) import Database.PostgreSQL.Simple (In (..), Only (..), (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) @@ -81,10 +85,10 @@ createMsgDeliveryTask db gInfo sender newTask = do created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (groupMemberId' sender, messageId, BI messageFromChannel, DTSNew, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (groupMemberId' sender, messageId, BI sentAsGroup, DTSNew, currentTs, currentTs)) where GroupInfo {groupId} = gInfo - NewMessageDeliveryTask {messageId, jobScope, messageFromChannel} = newTask + NewMessageDeliveryTask {messageId, taskContext = DeliveryTaskContext {jobScope, sentAsGroup}} = newTask deleteGroupDeliveryTasks :: DB.Connection -> GroupInfo -> IO () deleteGroupDeliveryTasks db GroupInfo {groupId} = @@ -125,7 +129,7 @@ getNextDeliveryTask db deliveryKey = do |] (groupId, workerScope, DTSNew) -type MessageDeliveryTaskRow = (Only Int64) :. DeliveryJobScopeRow :. (GroupMemberId, MemberId, ContactName, UTCTime, ChatMessage 'Json, BoolInt) +type MessageDeliveryTaskRow = (Only Int64) :. DeliveryJobScopeRow :. (GroupMemberId, MemberId, ContactName, UTCTime, Binary ByteString, Maybe ChatBinding, Maybe (Binary ByteString), BoolInt) getMsgDeliveryTask_ :: DB.Connection -> Int64 -> IO (Either StoreError MessageDeliveryTask) getMsgDeliveryTask_ db taskId = @@ -136,7 +140,7 @@ getMsgDeliveryTask_ db taskId = SELECT t.delivery_task_id, t.worker_scope, t.job_scope_spec_tag, t.job_scope_include_pending, t.job_scope_support_gm_id, - m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, t.message_from_channel + m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, msg.msg_chat_binding, msg.msg_signatures, t.message_from_channel FROM delivery_tasks t JOIN messages msg ON msg.message_id = t.message_id JOIN group_members m ON m.group_member_id = t.sender_group_member_id @@ -146,26 +150,37 @@ getMsgDeliveryTask_ db taskId = (Only taskId) where toTask :: MessageDeliveryTaskRow -> Either StoreError MessageDeliveryTask - toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, BI messageFromChannel)) = - case toJobScope_ jobScopeRow of - Just jobScope -> Right $ MessageDeliveryTask {taskId = taskId', jobScope, senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel} - Nothing -> Left $ SEInvalidDeliveryTask taskId' + toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, Binary msgBody, chatBinding_, sigs_, BI showGroupAsSender)) = + case (toJobScope_ jobScopeRow, J.eitherDecodeStrict' msgBody) of + (Just jobScope, Right chatMsg) -> + let fwdSender = if showGroupAsSender then FwdChannel else FwdMember senderMemberId senderMemberName + -- Re-parsed from msg_body: validates stored content against current code. + -- Signed: original bytes preserved (re-encoding would invalidate signature). + -- Unsigned: re-encoded from parsed ChatMessage on forward (sanitizes content). + verifiedMsg = case (chatBinding_, decodeSigs sigs_) of + (Just cb, Just sigs) -> VMSigned MSSVerified (SignedMsg cb sigs msgBody) chatMsg + _ -> VMUnsigned chatMsg + in Right $ MessageDeliveryTask {taskId = taskId', jobScope, senderGMId, fwdSender, brokerTs, verifiedMsg} + (Nothing, _) -> Left $ SEInvalidDeliveryTask taskId' + (_, Left _) -> Left $ SEInvalidDeliveryTask taskId' + decodeSigs :: Maybe (Binary ByteString) -> Maybe (L.NonEmpty MsgSignature) + decodeSigs = (>>= eitherToMaybe . smpDecode . (\(Binary bs) -> bs)) markDeliveryTaskFailed_ :: DB.Connection -> Int64 -> IO () markDeliveryTaskFailed_ db taskId = DB.execute db "UPDATE delivery_tasks SET failed = 1 where delivery_task_id = ?" (Only taskId) --- TODO [channels fwd] possible optimization is to read and add tasks to batch iteratively to avoid reading too many tasks +-- TODO [relays] possible optimization is to read and add tasks to batch iteratively to avoid reading too many tasks -- passed MessageDeliveryTask defines the jobScope to search for getNextDeliveryTasks :: DB.Connection -> GroupInfo -> MessageDeliveryTask -> IO (Either StoreError [Either StoreError MessageDeliveryTask]) getNextDeliveryTasks db gInfo task = getWorkItems "message delivery task" getTaskIds (getMsgDeliveryTask_ db) (markDeliveryTaskFailed_ db) where - GroupInfo {groupId, useRelays} = gInfo + GroupInfo {groupId} = gInfo MessageDeliveryTask {jobScope, senderGMId} = task getTaskIds :: IO [Int64] getTaskIds - | isTrue useRelays = + | useRelays' gInfo = map fromOnly <$> DB.query db @@ -316,7 +331,7 @@ updateDeliveryJobStatus_ db jobId status errReason_ = do "UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ?" (status, errReason_, currentTs, jobId) --- TODO [channels fwd] possible improvement is to prioritize owners and "active" members +-- TODO [relays] possible improvement is to prioritize owners and "active" members getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do gmIds :: [Int64] <- diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index a517a7365b..9128404244 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -9,6 +9,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -30,6 +31,9 @@ module Simplex.Chat.Store.Direct createDirectConnection, createIncognitoProfile, createConnReqConnection, + createRelayMemberConnectionAsync, + createRelayTestConnection, + updateConnLinkData, setPreparedGroupStartedConnection, getProfileById, getConnReqContactXContactId, @@ -110,7 +114,7 @@ import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedCon import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Crypto.Ratchet (PQSupport) +import Simplex.Messaging.Crypto.Ratchet (PQSupport, pattern PQSupportOff) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) #if defined(dbPostgres) @@ -153,10 +157,12 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> Maybe PreparedChatEntity -> ConnReqContact -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO Connection +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> Maybe PreparedChatEntity -> ConnReqContact -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe IncognitoProfile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO Connection createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xContactId incognitoProfile groupLinkId subMode chatV pqSup = do currentTs <- getCurrentTime - customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile + customUserProfileId <- forM incognitoProfile $ \case + NewIncognito p -> createIncognitoProfile_ db userId currentTs p + ExistingIncognito LocalProfile {profileId = pId} -> pure pId let connStatus = ConnPrepared DB.execute db @@ -175,7 +181,9 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta ) connId <- insertedRowId db case preparedEntity_ of - Just (PCEGroup gInfo _) -> updatePreparedGroup gInfo customUserProfileId currentTs + -- For relay groups, setPreparedGroupLinkInfo_ is called via updatePreparedRelayedGroup before the relay loop + Just (PCEGroup gInfo _) | not (useRelays' gInfo) -> + setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId Nothing currentTs _ -> pure () pure Connection @@ -213,16 +221,61 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta Just (PCEContact Contact {contactId}) -> (ConnContact, Just contactId, Nothing, Just contactId) Just (PCEGroup _ GroupMember {groupMemberId}) -> (ConnMember, Nothing, Just groupMemberId, Just groupMemberId) Nothing -> (ConnContact, Nothing, Nothing, Nothing) - updatePreparedGroup GroupInfo {groupId, membership} customUserProfileId currentTs = do - DB.execute - db - "UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ?, conn_link_prepared_connection = ?, updated_at = ? WHERE group_id = ?" - (cReq, cReqHash, BI True, currentTs, groupId) - when (isJust customUserProfileId) $ - DB.execute - db - "UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE group_member_id = ?" - (customUserProfileId, currentTs, groupMemberId' membership) + +createRelayMemberConnectionAsync :: DB.Connection -> User -> GroupInfo -> GroupMember -> ShortLinkContact -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createRelayMemberConnectionAsync db user@User {userId} gInfo GroupMember {groupMemberId} relayLink (cmdId, agentConnId) subMode = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + group_member_id, via_short_link_contact, custom_user_profile_id, via_group_link, + created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, ConnNew, ConnMember, BI True) + :. (groupMemberId, relayLink, customUserProfileId_, BI True) + :. (currentTs, currentTs, BI (subMode == SMOnlyCreate)) + ) + connId <- insertedRowId db + setCommandConnId db user cmdId connId + where + customUserProfileId_ = localProfileId <$> incognitoMembershipProfile gInfo + +createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + relay_test, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnContact) + :. (chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (BI True, currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId + +updateConnLinkData :: DB.Connection -> User -> Connection -> ConnReqContact -> ConnReqUriHash -> Maybe GroupLinkId -> VersionChat -> PQSupport -> IO () +updateConnLinkData db User {userId} Connection {connId} cReq cReqHash groupLinkId_ chatV pqSup = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections + SET via_contact_uri = ?, via_contact_uri_hash = ?, group_link_id = ?, + conn_chat_version = ?, pq_support = ?, pq_encryption = ?, + updated_at = ? + WHERE user_id = ? AND connection_id = ? + |] + (cReq, cReqHash, groupLinkId_, chatV, pqSup, pqSup, currentTs, userId, connId) setPreparedGroupStartedConnection :: DB.Connection -> GroupId -> IO () setPreparedGroupStartedConnection db groupId = do @@ -582,7 +635,7 @@ setUserChatsRead db User {userId} = do DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) - DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ?" (CISRcvRead, updatedAt, userId, CISRcvNew) + DB.execute db "UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND item_status = ?" (CISRcvRead, updatedAt, userId, CISRcvNew) updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index eac046666b..951fce8958 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -380,14 +380,16 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs) pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} -createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer -createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do +createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ -- cryptoArgs = Nothing here, the decision to encrypt is made when receiving it xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False, userApprovedRelays = False}) <$> rfd_ fileProtocol = if isJust rfd_ then FPXFTP else FPSMP + grpMemberId_ = groupMemberId' <$> m_ + senderName = maybe gName (\GroupMember {localDisplayName = c} -> c) m_ fileId <- liftIO $ do DB.execute db @@ -398,8 +400,8 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId, cryptoArgs = Nothing} + (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs) + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64 createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do @@ -528,11 +530,12 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + LEFT JOIN groups g ON g.group_id = f.group_id WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) @@ -541,10 +544,10 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays)) = - case contactName_ <|> memberName_ <|> standaloneName_ of + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) = + case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> case fileStatus' of diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 7ae47adab5..93fdf1868a 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -41,7 +41,6 @@ module Simplex.Chat.Store.Groups createGroupRejectedViaLink, setGroupInvitationChatItemId, getGroup, - getGroupInfo, getGroupInfoByUserContactLinkConnReq, getGroupInfoViaUserShortLink, getGroupViaShortLinkToConnect, @@ -60,22 +59,41 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByIndex, getGroupMemberByMemberId, + getCreateUnknownGMByMemberId, getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, - getGroupRelays, + getGroupRelayMembers, getGroupMembersForExpiration, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, deleteGroup, + getInProgressGroups, getBaseGroupDetails, getContactGroupPreferences, getGroupInvitation, createNewContactMember, + createGroupRelayRecord, + getGroupRelayById, + getGroupRelayByGMId, + getGroupRelays, + getConnectedGroupRelays, + createRelayForOwner, + getCreateRelayForMember, + createRelayConnection, + updateRelayStatus, + updateRelayStatusFromTo, + setRelayLinkAccepted, + setRelayLinkConfId, + getRelayConfId, + updateRelayMemberData, + setGroupInProgressDone, + createRelayRequestGroup, + updateRelayOwnStatusFromTo, createNewContactMemberAsync, createJoiningMember, getMemberJoinRequest, @@ -86,6 +104,11 @@ module Simplex.Chat.Store.Groups getMemberInvitation, createMemberConnection, createMemberConnectionAsync, + updatePreparedRelayedGroup, + updatePublicMemberCount, + setPublicMemberCount, + updateGroupMemberKeys, + updateRelayGroupKeys, updateGroupMemberStatus, updateGroupMemberStatusById, updateGroupMemberAccepted, @@ -103,6 +126,7 @@ module Simplex.Chat.Store.Groups setMemberVectorRelationConnected, getMemberRelationsVector, createIntroReMember, + createIntroReMemberConn, createIntroToMemberContact, getMatchingContacts, getMatchingMembers, @@ -133,6 +157,8 @@ module Simplex.Chat.Store.Groups getXGrpLinkMemReceived, setXGrpLinkMemReceived, createNewUnknownGroupMember, + createLinkOwnerMember, + updatePreparedChannelMember, updateUnknownMemberAnnounced, updateUserMemberProfileSentAt, setGroupCustomData, @@ -155,6 +181,7 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as B import Data.Char (toLower) import Data.Either (rights) +import Data.Functor (($>)) import Data.Int (Int64) import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) @@ -164,6 +191,7 @@ import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages +import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared @@ -172,9 +200,10 @@ import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRel import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), UserId) +import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), 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) @@ -190,11 +219,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) +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)) = - 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)) +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 @@ -309,36 +338,57 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo -createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do - let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile +createNewGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo +createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do + let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile + (groupType_, groupLink_, publicGroupId_) = case publicGroup of + Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) + Nothing -> (Nothing, Nothing, Nothing) fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + let (rootPrivKey_, rootPubKey_, memberPrivKey_) = case groupKeys of + Nothing -> (Nothing, Nothing, Nothing) + Just GroupKeys {groupRootKey, memberPrivKey} -> + let (rpk, rpub) = case groupRootKey of + GRKPrivate pk -> (Just pk, Nothing) + GRKPublic k -> (Nothing, Just k) + in (rpk, rpub, Just memberPrivKey) 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_type, group_link, public_group_id, + user_id, preferences, member_admission, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) + :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute db [sql| INSERT INTO groups - (local_display_name, user_id, group_profile_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?) + (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, + root_priv_key, root_pub_key, member_priv_key, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) + ( (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) + :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_) + ) insertedRowId db - memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs vr + let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo { groupId, - useRelays = BoolDef False, + useRelays = BoolDef useRelays, + relayOwnStatus = Nothing, localDisplayName = ldn, groupProfile, localAlias = "", @@ -354,10 +404,11 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - groupSummary = GroupSummary 1, + groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_}, customData = Nothing, membersRequireAttention = 0, - viaGroupLinkUri = Nothing + viaGroupLinkUri = Nothing, + groupKeys } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -370,9 +421,9 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db vr user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership - MemberIdRole {memberId = invMemberId, memberRole = memberRole'} = invitedMember - liftIO . when (memberId /= invMemberId || memberRole /= memberRole') $ - DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (invMemberId, memberRole', groupMemberId) + MemberIdRole {memberId = invMemberId, memberRole = invMemberRole} = invitedMember + liftIO . when (memberId /= invMemberId || memberRole /= invMemberRole) $ + DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (invMemberId, invMemberRole, groupMemberId) gInfo' <- if p' == groupProfile then pure gInfo @@ -407,13 +458,14 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs vr + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing Nothing currentTs hostVRange + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo { groupId, useRelays = BoolDef False, + relayOwnStatus = Nothing, localDisplayName, groupProfile, localAlias = "", @@ -429,10 +481,11 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - groupSummary = GroupSummary 2, + groupSummary = GroupSummary {currentMembers = 2, publicMemberCount = Nothing}, customData = Nothing, membersRequireAttention = 0, - viaGroupLinkUri = Nothing + viaGroupLinkUri = Nothing, + groupKeys = Nothing }, groupMemberId ) @@ -465,8 +518,8 @@ getUpdateNextIndexInGroup_ db groupId = |] (Only groupId) -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> Maybe C.PublicKeyEd25519 -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId memberPubKey createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId (indexInGroup, localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of (Just profile@LocalProfile {displayName}, Just profileId) -> do @@ -497,7 +550,9 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + memberPubKey, + relayLink = Nothing } where memberChatVRange@(VersionRange minV maxV) = vr @@ -511,12 +566,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) + :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, memberPubKey, createdAt, createdAt) :. (minV, maxV) ) pure (indexInGroup, localDisplayName) @@ -530,12 +585,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, memberPubKey, createdAt, createdAt) :. (minV, maxV) ) pure (indexInGroup, incognitoLdn) @@ -546,22 +601,32 @@ 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 -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> ExceptT StoreError IO (GroupInfo, GroupMember) -createPreparedGroup db vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) +createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) - (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing currentTs - hostMemberId <- insertHost_ currentTs groupId groupLDN - let userMember = MemberIdRole (MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id") GRMember - membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing currentTs vr - hostMember <- getGroupMember db vr user groupId hostMemberId - when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember + (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs + hostMemberId_ <- + if useRelays + then pure Nothing + else Just <$> insertHost_ currentTs groupId groupLDN + userMemberId <- + if useRelays + then liftIO $ MemberId <$> encodedRandomBytes gVar 12 + else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" + 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 + forM_ hostMember_ $ \hostMember -> + when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember g <- getGroupInfo db vr user groupId - pure (g, hostMember) + pure (g, hostMember_) where insertHost_ currentTs groupId groupLDN = do - let memberId = MemberId $ encodeUtf8 groupLDN <> "_host_unknown_id" - hostProfile = profileFromName $ nameFromMemberId memberId + randHostId <- liftIO $ encodedRandomBytes gVar 12 + let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId + hostProfile = profileFromName $ nameFromBS randHostId (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -595,12 +660,12 @@ updateBusinessChatInfo db groupId businessChatInfo = |] (businessChatInfoRow businessChatInfo :. (Only groupId)) -updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> User -> ExceptT StoreError IO GroupInfo -updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember newUser@User {userId = newUserId} = do +updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMember -> User -> ExceptT StoreError IO GroupInfo +updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember_ newUser@User {userId = newUserId} = do currentTs <- liftIO getCurrentTime updateGroup gInfo currentTs liftIO $ updateMembership membership currentTs - updateHostMember hostMember currentTs + forM_ hostMember_ $ \hostMember -> updateHostMember hostMember currentTs getGroupInfo db vr newUser groupId where updateGroup GroupInfo {localDisplayName = oldGroupLDN, groupProfile = GroupProfile {displayName = groupDisplayName}} currentTs = @@ -675,7 +740,7 @@ updatePreparedUserAndHostMembers' db vr user - gInfo@GroupInfo {groupId, groupProfile = gp, businessChat} + gInfo@GroupInfo {groupId, membership, groupProfile = gp, businessChat} hostMember fromMember fromMemberProfile @@ -684,7 +749,9 @@ updatePreparedUserAndHostMembers' business membershipStatus = do currentTs <- liftIO getCurrentTime - liftIO $ updateUserMember currentTs + -- For channels, don't regress membership status if already connected via another relay + unless (memberStatus membership == GSMemConnected) $ + liftIO $ updateUserMember currentTs hostMember' <- updateHostMember currentTs when (gp /= groupProfile) $ void $ updateGroupProfile db user gInfo groupProfile @@ -694,8 +761,7 @@ updatePreparedUserAndHostMembers' pure (gInfo', hostMember') where updateUserMember currentTs = do - let GroupInfo {membership} = gInfo - MemberIdRole memberId memberRole = invitedMember + let MemberIdRole memberId memberRole = invitedMember DB.execute db [sql| @@ -748,11 +814,12 @@ createGroupViaLink' business membershipStatus = do currentTs <- liftIO getCurrentTime - (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business currentTs + (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing Nothing currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId currentTs vr + -- TODO [member keys] this is currently not used with public groups. If it needs to be used, member keys need to be added + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs vr liftIO $ setViaGroupLinkUri db groupId connId (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where @@ -774,15 +841,25 @@ createGroupViaLink' ) insertedRowId db -createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> UTCTime -> ExceptT StoreError IO (GroupId, Text) -createGroup_ db userId groupProfile prepared business currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile +createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (GroupId, Text) +createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus publicMemberCount_ currentTs = ExceptT $ do + let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile + (groupType_, groupLink_, publicGroupId_) = case publicGroup of + Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) + Nothing -> (Nothing, Nothing, Nothing) 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_type, group_link, public_group_id, + user_id, preferences, member_admission, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) + :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute db @@ -790,10 +867,10 @@ createGroup_ db userId groupProfile prepared business currentTs = ExceptT $ do INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus, publicMemberCount_)) groupId <- insertedRowId db pure (groupId, localDisplayName) @@ -901,6 +978,15 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) +getInProgressGroups :: DB.Connection -> VersionRangeChat -> User -> UTCTime -> IO [GroupInfo] +getInProgressGroups db vr user@User {userId} createdAtCutoff = do + groupIds <- map fromOnly <$> + DB.query + db + "SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ?" + (userId, createdAtCutoff) + rights <$> mapM (runExceptT . getGroupInfo db vr user) groupIds + getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do map (toGroupInfo vr userContactId []) @@ -1017,6 +1103,18 @@ 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 -> ContactName -> GroupMemberRole -> Bool -> ExceptT StoreError IO (Maybe (GroupMember, Bool)) +getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole allowCreate = do + liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + Right m -> pure $ Just (m, False) + Left (SEGroupMemberNotFoundByMemberId _) + | allowCreate -> do + let name = if T.null memberName then nameFromMemberId memberId else memberName + m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole + pure $ Just (m, True) + | otherwise -> pure Nothing + Left e -> throwError e + getScopeMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberId -> ExceptT StoreError IO GroupMemberId getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberId | scopeMemberId == memberId' membership = pure $ groupMemberId' membership @@ -1073,14 +1171,13 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) --- TODO [channels fwd] retrieve relays based on knowledge about member from protocol, not role (isMemberRelay) -getGroupRelays :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupRelays db vr user@User {userId, userContactId} GroupInfo {groupId} = do +getGroupRelayMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupRelayMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") - (userId, groupId, userContactId, GRAdmin) + (userId, groupId, userContactId, GRRelay) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1150,7 +1247,9 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, memberChatVRange = peerChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + memberPubKey = Nothing, + relayLink = Nothing } where insertMember_ = do @@ -1171,6 +1270,310 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, ) pure indexInGroup +createGroupRelayRecord :: DB.Connection -> GroupInfo -> GroupMember -> UserChatRelay -> ExceptT StoreError IO GroupRelay +createGroupRelayRecord db GroupInfo {groupId} GroupMember {groupMemberId} UserChatRelay {chatRelayId} = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_relays + (group_id, group_member_id, chat_relay_id, relay_status, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (groupId, groupMemberId, chatRelayId, RSNew, currentTs, currentTs) + relayId <- liftIO $ insertedRowId db + getGroupRelayById db relayId + +getGroupRelayById :: DB.Connection -> Int64 -> ExceptT StoreError IO GroupRelay +getGroupRelayById db relayId = + ExceptT . firstRow toGroupRelay (SEGroupRelayNotFound relayId) $ + DB.query + db + (groupRelayQuery <> " WHERE gr.group_relay_id = ?") + (Only relayId) + +getGroupRelayByGMId :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO GroupRelay +getGroupRelayByGMId db groupMemberId = + ExceptT . firstRow toGroupRelay (SEGroupRelayNotFoundByMemberId groupMemberId) $ + DB.query + db + (groupRelayQuery <> " WHERE gr.group_member_id = ?") + (Only groupMemberId) + +getGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay] +getGroupRelays db GroupInfo {groupId} = + map toGroupRelay + <$> DB.query + db + (groupRelayQuery <> " WHERE gr.group_id = ?") + (Only groupId) + +getConnectedGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay] +getConnectedGroupRelays db GroupInfo {groupId} = + map toGroupRelay + <$> DB.query + db + ( groupRelayQuery + <> " " + <> [sql| + JOIN group_members m ON m.group_member_id = gr.group_member_id + WHERE gr.group_id = ? + AND m.member_status = ? + AND gr.relay_status IN (?,?) + |] + ) + (groupId, GSMemConnected, RSAccepted, RSActive) + +groupRelayQuery :: Query +groupRelayQuery = + [sql| + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, 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, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay +toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink)) = + let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), 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 {relayProfile = RelayProfile {displayName}} = do + currentTs <- liftIO getCurrentTime + let relayProfile = profileFromName displayName + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs + groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, memProfileId, currentTs, currentTs) + ) + liftIO $ insertedRowId db + getGroupMemberById db vr user groupMemberId + +getCreateRelayForMember :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember +getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = + liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure + where + getGroupMemberByRelayLink = + maybeFirstRow (toContactMember vr user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.relay_link = ?") + (groupId, relayLink) + createRelayMember = do + currentTs <- liftIO getCurrentTime + randRelayId <- liftIO $ encodedRandomBytes gVar 12 + let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId + relayProfile = profileFromName $ nameFromBS randRelayId + (localDisplayName, profileId) <- createNewMemberProfile_ db user relayProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + groupMemberId <- liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) + ) + insertedRowId db + getGroupMember db vr user groupId groupMemberId + +createRelayConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayConnection db vr user@User {userId} groupMemberId agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + group_member_id, conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnMember) + :. (groupMemberId, chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId + +updateRelayStatus :: DB.Connection -> GroupRelay -> RelayStatus -> IO GroupRelay +updateRelayStatus db relay@GroupRelay {groupRelayId} relayStatus = + updateRelayStatus_ db groupRelayId relayStatus $> relay {relayStatus} + +updateRelayStatusFromTo :: DB.Connection -> GroupRelay -> RelayStatus -> RelayStatus -> IO GroupRelay +updateRelayStatusFromTo db relay@GroupRelay {groupRelayId} fromStatus toStatus = do + maybeFirstRow fromOnly (DB.query db "SELECT relay_status FROM group_relays WHERE group_relay_id = ?" (Only groupRelayId)) >>= \case + Just status | status == fromStatus -> updateRelayStatus_ db groupRelayId toStatus $> relay {relayStatus = toStatus} + _ -> pure relay + +updateRelayStatus_ :: DB.Connection -> Int64 -> RelayStatus -> IO () +updateRelayStatus_ db relayId relayStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ?" (relayStatus, currentTs, relayId) + +setRelayLinkAccepted :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) +setRelayLinkAccepted db vr user m (MemberKey relayKey) profile = do + let gmId = groupMemberId' m + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute + db + [sql| + UPDATE group_relays + SET relay_status = ?, updated_at = ? + WHERE group_member_id = ? + |] + (RSAccepted, currentTs, gmId) + liftIO $ DB.execute + db + [sql| + UPDATE group_members + SET member_pub_key = ?, updated_at = ? + WHERE group_member_id = ? + |] + (relayKey, currentTs, gmId) + void $ updateMemberProfile db user m profile + (,) <$> getGroupMemberById db vr user gmId <*> getGroupRelayByGMId db gmId + +setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () +setRelayLinkConfId db m confId relayLink = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_relays + SET conf_id = ?, relay_link = ?, updated_at = ? + WHERE group_member_id = ? + |] + (confId, relayLink, currentTs, groupMemberId' m) + DB.execute + db + [sql| + UPDATE group_members + SET relay_link = ?, updated_at = ? + WHERE group_member_id = ? + |] + (relayLink, currentTs, groupMemberId' m) + +getRelayConfId :: DB.Connection -> GroupMember -> ExceptT StoreError IO ConfirmationId +getRelayConfId db m = + ExceptT . firstRow fromOnly (SEGroupRelayNotFoundByMemberId $ groupMemberId' m) $ + DB.query + db + [sql| + SELECT conf_id + FROM group_relays + WHERE group_member_id = ? AND conf_id IS NOT NULL + |] + (Only (groupMemberId' m)) + +updateRelayMemberData :: DB.Connection -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () +updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET member_id = ?, member_pub_key = ?, updated_at = ? + WHERE group_member_id = ? + |] + (memberId, relayKey, currentTs, groupMemberId' m) + void $ updateMemberProfile db user m profile + +setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () +setGroupInProgressDone db GroupInfo {groupId} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" + (currentTs, groupId) + +createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange = do + currentTs <- liftIO getCurrentTime + -- Create group with placeholder profile + let Profile {displayName = fromMemberLDN} = fromMemberProfile + placeholderProfile = GroupProfile + { displayName = "relay_request_" <> fromMemberLDN, + fullName = "", + shortDescr = Nothing, + description = Nothing, + image = Nothing, + publicGroup = Nothing, + groupPreferences = Nothing, + memberAdmission = Nothing + } + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) Nothing currentTs + -- Store relay request data for recovery + liftIO $ setRelayRequestData_ groupId + ownerMemberId <- insertOwner_ currentTs groupId + let relayMember = MemberIdRole relayMemberId GRRelay + -- TODO [member keys] should relays use member keys? + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing Nothing currentTs vr + ownerMember <- getGroupMember db vr user groupId ownerMemberId + g <- getGroupInfo db vr user groupId + pure (g, ownerMember) + where + setRelayRequestData_ groupId = + DB.execute + db + [sql| + UPDATE groups + SET relay_request_inv_id = ?, + relay_request_group_link = ?, + relay_request_peer_chat_min_version = ?, + relay_request_peer_chat_max_version = ? + WHERE group_id = ? + |] + (Binary invId, groupLink, minVersion reqChatVRange, maxVersion reqChatVRange, groupId) + insertOwner_ currentTs groupId = do + let MemberIdRole {memberId, memberRole} = fromMember + (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db + +updateRelayOwnStatusFromTo :: DB.Connection -> GroupInfo -> RelayStatus -> RelayStatus -> IO GroupInfo +updateRelayOwnStatusFromTo db gInfo@GroupInfo {groupId} fromStatus toStatus = do + maybeFirstRow fromOnly (DB.query db "SELECT relay_own_status FROM groups WHERE group_id = ?" (Only groupId)) >>= \case + Just status | status == fromStatus -> updateRelayOwnStatus_ db gInfo toStatus $> gInfo {relayOwnStatus = Just toStatus} + _ -> pure gInfo + +updateRelayOwnStatus_ :: DB.Connection -> GroupInfo -> RelayStatus -> IO () +updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ?" (relayStatus, currentTs, groupId) + createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = createWithRandomId' db gVar $ \memId -> runExceptT $ do @@ -1198,7 +1601,7 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db gVar @@ -1207,9 +1610,11 @@ createJoiningMember cReqChatVRange Profile {displayName, fullName, shortDescr, image, contactLink, preferences} cReqXContactId_ + cReqMemberId_ welcomeMsgId_ memberRole - memberStatus = do + memberStatus + memberKey_ = do currentTs <- liftIO getCurrentTime ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ @@ -1218,12 +1623,25 @@ createJoiningMember "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) profileId <- liftIO $ insertedRowId db - createWithRandomId' db gVar $ \memId -> runExceptT $ do - insertMember_ ldn profileId (MemberId memId) currentTs - groupMemberId <- liftIO $ insertedRowId db - pure (groupMemberId, MemberId memId) + case cReqMemberId_ of + Just memberId -> do + checkMemberNotExists memberId + insertMember_ ldn profileId memberId currentTs + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, memberId) + Nothing -> + createWithRandomId' db gVar $ \memId -> runExceptT $ do + insertMember_ ldn profileId (MemberId memId) currentTs + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, MemberId memId) where VersionRange minV maxV = cReqChatVRange + -- TODO [relays] relay: TBC communicate rejection + checkMemberNotExists :: MemberId -> ExceptT StoreError IO () + checkMemberNotExists memberId = do + exists <- liftIO $ fromOnly . head <$> DB.query db "SELECT EXISTS (SELECT 1 FROM group_members WHERE group_id = ? AND member_id = ?)" (groupId, memberId) + when exists $ throwError SEDuplicateMemberId + memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey_ insertMember_ ldn profileId memberId currentTs = do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1232,12 +1650,12 @@ createJoiningMember [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) + :. (userId, ldn, Nothing :: (Maybe Int64), profileId, memberPubKey_, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) :. (minV, maxV) ) @@ -1298,7 +1716,8 @@ createBusinessRequestGroup (groupProfileId, ldn, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer) groupId <- liftIO $ insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr + -- TODO [member keys] we could support member keys in business groups to allow binding agreements (though identity keys would be better for it. + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs vr pure (groupId, membership) VersionRange minV maxV = cReqChatVRange insertClientMember_ currentTs groupId membership = @@ -1360,6 +1779,87 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode setCommandConnId db user cmdId connId +-- Set group link info, incognito profile, membership keys before connecting to relays. +-- This is called once before connecting to relays, unlike createConnReqConnection -> setPreparedGroupLinkInfo_, +-- which is used in single-connection flows. +updatePreparedRelayedGroup :: + DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> + C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> Maybe Int64 -> + ExceptT StoreError IO GroupInfo +updatePreparedRelayedGroup db vr user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do + currentTs <- liftIO getCurrentTime + customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile + liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId publicMemberCount_ currentTs + liftIO $ updateGroupMemberKeys db (groupId' gInfo) rootPubKey memberPrivKey (groupMemberId' $ membership gInfo) + getGroupInfo db vr user (groupId' gInfo) + +updatePublicMemberCount :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo +updatePublicMemberCount db vr user GroupInfo {groupId} = do + liftIO $ do + totalCount <- fromMaybe 0 <$> maybeFirstRow fromOnly + (DB.query db "SELECT summary_current_members_count FROM groups WHERE group_id = ?" (Only groupId)) + relayCount <- fromMaybe 0 <$> maybeFirstRow fromOnly + (DB.query + db + [sql| + SELECT COUNT(1) FROM group_members + WHERE group_id = ? AND member_role = ? + AND member_status IN (?,?,?,?,?,?,?) + |] + (groupId, GRRelay, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator)) + let publicCount = max 0 (totalCount - relayCount) :: Int64 + currentTs <- getCurrentTime + DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) + getGroupInfo db vr user groupId + +setPublicMemberCount :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupInfo +setPublicMemberCount db vr user GroupInfo {groupId} publicCount = do + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) + getGroupInfo db vr user groupId + +updateGroupMemberKeys :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () +updateGroupMemberKeys db groupId rootPubKey memberPrivKey membershipGMId = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" + (rootPubKey, memberPrivKey, currentTs, groupId) + DB.execute + db + "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" + (C.publicKey memberPrivKey, currentTs, membershipGMId) + +updateRelayGroupKeys :: DB.Connection -> User -> GroupInfo -> PublicGroupProfile -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> [OwnerAuth] -> ExceptT StoreError IO () +updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, groupLink, publicGroupId} rootPubKey memberPrivKey owners = do + currentTs <- liftIO getCurrentTime + let membershipGMId = groupMemberId' $ membership gInfo + groupId = groupId' gInfo + liftIO $ do + DB.execute + db + [sql| + UPDATE group_profiles SET group_type = ?, group_link = ?, public_group_id = ?, updated_at = ? + WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?) + |] + (groupType, groupLink, publicGroupId, currentTs, userId, groupId) + DB.execute + db + "UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" + (rootPubKey, memberPrivKey, currentTs, groupId) + DB.execute + db + "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" + (C.publicKey memberPrivKey, currentTs, membershipGMId) + -- TODO [relays] relay: if not found, create owner record (multi-owner) + forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do + ownerGMId <- getGroupMemberIdViaMemberId db user gInfo (MemberId ownerId) + liftIO $ + DB.execute + db + "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" + (ownerKey, currentTs, ownerGMId) + updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () updateGroupMemberStatus db userId GroupMember {groupMemberId} = updateGroupMemberStatusById db userId groupMemberId @@ -1491,7 +1991,7 @@ createNewMember_ User {userId, userContactId} GroupInfo {groupId} NewGroupMember - { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile, + { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile memKey, memCategory = memberCategory, memStatus = memberStatus, memRestriction, @@ -1505,19 +2005,22 @@ createNewMember_ let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange + memberPubKey = (\(MemberKey k) -> k) <$> memKey indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute db [sql| INSERT INTO group_members - (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, + member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, memRestriction, invitedById, memInvitedByGroupMemberId) - :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty) + :. (memRestriction, invitedById, memInvitedByGroupMemberId) + :. (userId, localDisplayName, memberContactId, memberContactProfileId, memberPubKey, createdAt, createdAt) :. (minV, maxV) ) groupMemberId <- liftIO $ insertedRowId db @@ -1542,7 +2045,9 @@ createNewMember_ memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + memberPubKey, + relayLink = Nothing } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1647,27 +2152,35 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db - user@User {userId} + user gInfo + memInfo@(MemberInfo _ _ _ memberProfile _) + memRestrictions_ = do + currentTs <- liftIO getCurrentTime + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + let memRestriction = restriction <$> memRestrictions_ + newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + createNewMember_ db user gInfo newMember currentTs + +createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMemberConn + db + user@User {userId} _host@GroupMember {memberContactId, activeConn} + reMember@GroupMember {groupMemberId} chatV - memInfo@(MemberInfo _ _ memChatVRange memberProfile) - memRestrictions_ + (MemberInfo _ _ memChatVRange _ _) (groupCmdId, groupAgentConnId) subMode = do let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn - memRestriction = restriction <$> memRestrictions_ currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode + conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode liftIO $ setCommandConnId db user groupCmdId groupConnId - pure (member :: GroupMember) {activeConn = Just conn} + pure (reMember :: GroupMember) {activeConn = Just conn} createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do @@ -1711,7 +2224,7 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -1724,19 +2237,24 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, pure $ Right (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences} where fullGroupPreferences = mergeGroupPreferences groupPreferences + (groupType_, groupLink_) = case publicGroup of + Just PublicGroupProfile {groupType, groupLink} -> (Just groupType, Just groupLink) + Nothing -> (Nothing, Nothing) updateGroupProfile_ currentTs = DB.execute db [sql| UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, + group_type = ?, group_link = ?, + preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? ) |] - (newName, fullName, shortDescr, description, image, groupPreferences, memberAdmission, currentTs, userId, groupId) + ((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. (groupPreferences, memberAdmission, currentTs, userId, groupId)) updateGroup_ ldn currentTs = do DB.execute db @@ -1774,23 +2292,16 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName DB.query db [sql| - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.preferences, gp.member_admission + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) - toGroupProfile (displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission) = - GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} - -getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do - chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ - DB.query - db - (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (groupId, userId, userContactId) + toGroupProfile (displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_, groupPreferences, memberAdmission) = + GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_, groupPreferences, memberAdmission} getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do @@ -2422,8 +2933,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 @@ -2438,7 +2949,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) ) @@ -2447,8 +2958,57 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g where VersionRange minV maxV = vr +createLinkOwnerMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> C.PublicKeyEd25519 -> ExceptT StoreError IO GroupMember +createLinkOwnerMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId ownerKey = do + currentTs <- liftIO getCurrentTime + let memberProfile = profileFromName $ nameFromMemberId memberId + (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, GROwner, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, ownerKey, currentTs, currentTs) + :. (minV, maxV) + ) + groupMemberId <- liftIO $ insertedRowId db + getGroupMemberById db vr user groupMemberId + where + VersionRange minV maxV = vr + +-- member_pub_key is not updated here — introduced members are owners +-- whose keys are loaded from link data (trusted out-of-band). +-- Updating from an in-band message would allow a compromised relay to substitute keys. +updatePreparedChannelMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember +updatePreparedChannelMember db vr user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do + _ <- updateMemberProfile db user member profile + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET member_role = ?, + member_status = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + |] + (memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) + getGroupMemberById db vr user groupMemberId + where + VersionRange minV maxV = maybe memberChatVRange fromChatVRange v + updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} status = do +updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -2462,15 +3022,17 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb invited_by_group_member_id = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, + member_pub_key = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? |] ( (memberRole, GCPostMember, status, groupMemberId' invitingMember) - :. (minV, maxV, currentTs, userId, groupMemberId) + :. (minV, maxV, memberPubKey_, currentTs, userId, groupMemberId) ) getGroupMemberById db vr user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v + memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f77c836b0d..a2c91af86b 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -151,7 +151,7 @@ import Data.Char (toLower) import Data.Either (fromRight, rights) import Data.Int (Int64) import Data.List (foldl', sortBy) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -179,14 +179,17 @@ import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM #if defined(dbPostgres) import Database.PostgreSQL.Simple (FromRow, In (..), Only (..), Query, ToRow, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) +import Database.PostgreSQL.Simple.ToField (ToField) #else import Database.SQLite.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) import Database.SQLite.Simple.QQ (sql) +import Database.SQLite.Simple.ToField (ToField) #endif deleteContactCIs :: DB.Connection -> User -> Contact -> IO () @@ -218,24 +221,29 @@ deleteGroupChatItemsMessages db User {userId} GroupInfo {groupId} = do DB.execute db "DELETE FROM chat_item_reactions WHERE group_id = ?" (Only groupId) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_content_tag != 'chatBanner'" (userId, groupId) -createNewSndMessage :: MsgEncodingI e => DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> ChatMsgEvent e -> (SharedMsgId -> EncodedChatMessage) -> ExceptT StoreError IO SndMessage -createNewSndMessage db gVar connOrGroupId chatMsgEvent encodeMessage = +createNewSndMessage :: MsgEncodingI e => DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> ChatMsgEvent e -> Maybe MsgSigning -> (SharedMsgId -> EncodedChatMessage) -> ExceptT StoreError IO SndMessage +createNewSndMessage db gVar connOrGroupId chatMsgEvent msgSigning_ encodeMessage = createWithRandomId' db gVar $ \sharedMsgId -> case encodeMessage (SharedMsgId sharedMsgId) of ECMLarge -> pure $ Left SELargeMsg ECMEncoded msgBody -> do + let signedMsg_ = signBody <$> msgSigning_ + signBody MsgSigning {bindingTag, bindingData, keyRef, privKey} = + let sig = C.ASignature C.SEd25519 $ C.sign' privKey (smpEncode bindingTag <> bindingData <> msgBody) + in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig :| [], signedBody = msgBody} createdAt <- getCurrentTime DB.execute db [sql| INSERT INTO messages ( - msg_sent, chat_msg_event, msg_body, connection_id, group_id, + msg_sent, chat_msg_event, msg_body, msg_chat_binding, msg_signatures, connection_id, group_id, shared_msg_id, shared_msg_id_user, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (MDSnd, toCMEventTag chatMsgEvent, DB.Binary msgBody, connId_, groupId_, DB.Binary sharedMsgId, Just (BI True), createdAt, createdAt) + ((MDSnd, toCMEventTag chatMsgEvent, DB.Binary msgBody, chatBinding <$> signedMsg_, DB.Binary . smpEncode . signatures <$> signedMsg_, connId_, groupId_) + :. (DB.Binary sharedMsgId, Just (BI True), createdAt, createdAt)) msgId <- insertedRowId db - pure $ Right SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody} + pure $ Right SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody, signedMsg_} where (connId_, groupId_) = case connOrGroupId of ConnectionId connId -> (Just connId, Nothing) @@ -287,7 +295,7 @@ getLastRcvMsgInfo db connId = RcvMsgInfo {msgId, msgDeliveryId, msgDeliveryStatus, agentMsgId, agentMsgMeta} createNewRcvMessage :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewRcvMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage -createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody, brokerTs} sharedMsgId_ authorMember forwardedByMember = +createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs} sharedMsgId_ authorMember forwardedByMember = case connOrGroupId of ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing GroupId groupId -> case sharedMsgId_ of @@ -315,14 +323,15 @@ createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody, broke db [sql| INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, broker_ts, created_at, updated_at, connection_id, group_id, + (msg_sent, chat_msg_event, msg_body, msg_chat_binding, msg_signatures, broker_ts, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, brokerTs, currentTs, currentTs, connId_, groupId_) + ((MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, chatBinding <$> signedMsg_, DB.Binary . smpEncode . signatures <$> signedMsg_, brokerTs, currentTs, currentTs, connId_, groupId_) :. (sharedMsgId_, authorMember, forwardedByMember)) msgId <- insertedRowId db - pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} + pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgSigned, forwardedByMember} + (msgSigned, signedMsg_, msgBody) = verifiedMsgParts verifiedMsg updateSndMsgDeliveryStatus :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> IO () updateSndMsgDeliveryStatus db connId agentMsgId sndMsgDeliveryStatus = do @@ -353,7 +362,7 @@ getPendingGroupMessages db groupMemberId = <$> DB.query db [sql| - SELECT pgm.message_id, m.shared_msg_id, m.msg_body + SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.msg_chat_binding, m.msg_signatures FROM pending_group_messages pgm JOIN messages m USING (message_id) WHERE pgm.group_member_id = ? @@ -361,8 +370,9 @@ getPendingGroupMessages db groupMemberId = |] (Only groupMemberId) where - pendingGroupMessage (msgId, sharedMsgId, msgBody) = - SndMessage {msgId, sharedMsgId, msgBody} + pendingGroupMessage (msgId, sharedMsgId, msgBody, chatBinding_ :: Maybe ChatBinding, sigs_ :: Maybe ByteString) = + let signedMsg_ = SignedMsg <$> chatBinding_ <*> (sigs_ >>= eitherToMaybe . smpDecode) <*> pure msgBody + in SndMessage {msgId, sharedMsgId, msgBody, signedMsg_} deletePendingGroupMessage :: DB.Connection -> Int64 -> MessageId -> IO () deletePendingGroupMessage db groupMemberId messageId = @@ -525,9 +535,9 @@ setSupportChatMemberAttention db vr user g m memberAttention = do m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId -createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live hasLink createdAt = - createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink createdAt = + createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (MSSVerified <$ signedMsg_) createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -542,8 +552,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv Nothing -> (Just False, Nothing) createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection False (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do + let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False + ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgSigned createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -557,16 +568,18 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw CDDirectRcv _ -> (Just $ not sent, Nothing) CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ -> (Just $ Just userMemberId == memberId, memberId) + CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> + (Just $ Just userMemberId == memberId, memberId) createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs = - createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing + createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgSigned createdAt = do DB.execute db [sql| @@ -575,20 +588,20 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, show_group_as_sender, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, item_viewed, show_group_as_sender, msg_signed, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI showGroupAsSender) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgSigStatus) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, mcTag_, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI itemViewed, BI showGroupAsSender, msgSigned) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId) idsRow = case chatDirection of @@ -596,12 +609,14 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) CDGroupRcv GroupInfo {groupId} _ GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId, Nothing) CDGroupSnd GroupInfo {groupId} _ -> (Nothing, Just groupId, Nothing, Nothing) + CDChannelRcv GroupInfo {groupId} _ -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) groupScope :: Maybe (Maybe GroupChatScopeInfo) groupScope = case chatDirection of CDGroupRcv _ scope _ -> Just scope CDGroupSnd _ scope -> Just scope + CDChannelRcv _ scope -> Just scope _ -> Nothing groupScopeRow :: (Maybe GroupChatScopeTag, Maybe GroupMemberId) groupScopeRow = case groupScope of @@ -609,8 +624,13 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share _ -> (Nothing, Nothing) includeInHistory :: Bool includeInHistory = case groupScope of - Just Nothing -> isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) + Just Nothing -> isJust mcTag_ && mcTag_ /= Just MCReport_ _ -> False + itemViewed :: Bool + itemViewed = case msgDirection @d of + SMDSnd -> isJust mcTag_ + SMDRcv -> False + mcTag_ = msgContentTag <$> ciMsgContent ciContent forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) forwardedFromRow = case itemForwarded of Nothing -> @@ -640,6 +660,12 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId | otherwise -> getGroupChatItemQuote_ groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing + CDChannelRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s -> + case memberId of + Just mId + | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId + | otherwise -> getGroupChatItemQuote_ groupId mId + _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content @@ -679,7 +705,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.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 @@ -889,7 +915,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> do - let q = baseQuery <> " WHERE g.user_id = ?" + let q = baseQuery <> " WHERE g.user_id = ? AND g.creating_in_progress = 0" p = baseParams :. Only userId queryWithPagination q p CLQFilters {favorite = True, unread = False} -> do @@ -897,7 +923,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseQuery <> " " <> [sql| - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 |] p = baseParams :. Only userId @@ -907,7 +933,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseQuery <> " " <> [sql| - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] p = baseParams :. Only userId @@ -917,7 +943,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseQuery <> " " <> [sql| - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.favorite = 1 OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] @@ -929,7 +955,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = <> " " <> [sql| JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND ( LOWER(g.local_display_name) LIKE '%' || ? || '%' OR LOWER(gp.display_name) LIKE '%' || ? || '%' @@ -1045,7 +1071,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1078,7 +1104,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @'CTLocal deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False msgSigned createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1303,7 +1329,8 @@ getDirectChatInitial_ db user ct contentFilter count = do Just minUnreadItemId -> do unreadCount <- liftIO $ getContactUnreadCount_ db user ct let stats = emptyChatStats {unreadCount, minUnreadItemId} - getDirectChatAround' db user ct contentFilter minUnreadItemId count "" stats + pivotId <- liftIO $ fromMaybe minUnreadItemId <$> getContactMaxViewedItemId_ db user ct + getDirectChatAround' db user ct contentFilter pivotId count "" stats Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" getContactStats_ :: DB.Connection -> User -> Contact -> IO ChatStats @@ -1326,6 +1353,21 @@ getContactMinUnreadId_ db User {userId} Contact {contactId} = |] (userId, contactId, CISRcvNew) +-- max viewed item: sent content or received read (excludes born-read events) +getContactMaxViewedItemId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId) +getContactMaxViewedItemId_ db User {userId} Contact {contactId} = + fmap join . maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_viewed = 1 + ORDER BY created_at DESC, chat_item_id DESC + LIMIT 1 + |] + (userId, contactId) + getContactUnreadCount_ :: DB.Connection -> User -> Contact -> IO Int getContactUnreadCount_ db User {userId} Contact {contactId} = fromOnly . head @@ -1633,7 +1675,8 @@ getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do Just minUnreadItemId -> do unreadCounts <- getGroupUnreadCount_ db user g scopeInfo_ Nothing stats <- liftIO $ getStats minUnreadItemId unreadCounts - getGroupChatAround' db user g scopeInfo_ contentFilter minUnreadItemId count "" stats + pivotId <- fromMaybe minUnreadItemId <$> getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter + getGroupChatAround' db user g scopeInfo_ contentFilter pivotId count "" stats Nothing -> do stats <- liftIO $ getStats 0 (0, 0) (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats @@ -1652,14 +1695,23 @@ getGroupStats_ db user g scopeInfo_ = do getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g scopeInfo_ contentFilter = fmap join . maybeFirstRow fromOnly $ - queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery orderLimit + queryUnreadGroupItems db user g scopeInfo_ contentFilter " item_status = ? " CISRcvNew baseQuery orderLimit where baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" +-- max viewed item: sent content or received read (excludes born-read events) +getGroupMaxViewedItemId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) +getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter = + fmap join . maybeFirstRow fromOnly $ + queryUnreadGroupItems db user g scopeInfo_ contentFilter " item_viewed = ? " (BI True) baseQuery orderLimit + where + baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " + orderLimit = " ORDER BY item_ts DESC, chat_item_id DESC LIMIT 1" + getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Int, Int) getGroupUnreadCount_ db user g scopeInfo_ contentFilter = - head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery "" + head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter " item_status = ? " CISRcvNew baseQuery "" where baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? " @@ -1671,27 +1723,27 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) -queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r] -queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit = +queryUnreadGroupItems :: (ToField p, FromRow r) => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> p -> Query -> Query -> ExceptT StoreError IO [r] +queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter statusCond statusParam baseQuery orderLimit = case (scopeInfo_, contentFilter) of (Nothing, Nothing) -> liftIO $ DB.query db - (baseQuery <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? " <> orderLimit) - (userId, groupId, CISRcvNew) + (baseQuery <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND " <> statusCond <> orderLimit) + (userId, groupId, statusParam) (Nothing, Just mcTag) -> liftIO $ DB.query db - (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) - (userId, groupId, mcTag, CISRcvNew) + (baseQuery <> " AND msg_content_tag = ? AND " <> statusCond <> orderLimit) + (userId, groupId, mcTag, statusParam) (Just GCSIMemberSupport {groupMember_ = m}, Nothing) -> liftIO $ DB.query db - (baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? AND item_status = ? " <> orderLimit) - (userId, groupId, GCSTMemberSupport_, groupMemberId' <$> m, CISRcvNew) + (baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? AND " <> statusCond <> orderLimit) + (userId, groupId, GCSTMemberSupport_, groupMemberId' <$> m, statusParam) (Just _scope, Just _mcTag) -> throwError $ SEInternalError "group scope and content filter are not supported together" @@ -1952,7 +2004,7 @@ updateDirectChatItemsRead db User {userId} contactId = do DB.execute db [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? AND item_status = ? |] (CISRcvRead, currentTs, userId, contactId, CISRcvNew) @@ -1997,7 +2049,7 @@ setDirectChatItemRead_ db User {userId} contactId itemId currentTs = DB.execute db [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? |] (CISRcvRead, currentTs, userId, contactId, CISRcvNew, itemId) @@ -2017,7 +2069,7 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId} = do DB.execute db [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? AND item_status = ? |] @@ -2031,7 +2083,7 @@ updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, member DB.execute db [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? AND item_status = ? @@ -2109,7 +2161,7 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scop DB.query db [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention |] @@ -2192,14 +2244,14 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do DB.execute db [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND note_folder_id = ? AND item_status = ? |] (CISRcvRead, currentTs, userId, noteFolderId, CISRcvNew) type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgSigStatus) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) @@ -2223,7 +2275,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -2256,7 +2308,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False msgSigned createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2294,7 +2346,7 @@ toGroupChatItem ( ( (itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow - :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink) + :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_) ) :. (forwardedByMember, BI showGroupAsSender) @@ -2313,6 +2365,12 @@ toGroupChatItem Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus) (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Nothing, Just (AFS SMDRcv fileStatus)) + | showGroupAsSender -> + Right $ cItem SMDRcv CIChannelRcv ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Nothing, Nothing) + | showGroupAsSender -> + Right $ cItem SMDRcv CIChannelRcv ciStatus ciContent Nothing (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus) (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> @@ -2339,7 +2397,7 @@ toGroupChatItem _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs forwardedByMember showGroupAsSender createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs forwardedByMember showGroupAsSender msgSigned createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2612,7 +2670,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -2668,7 +2726,7 @@ groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemId, mentions <- getGroupCIMentions db itemId case itemSharedMsgId of Just sharedMsgId -> do - let GroupMember {memberId} = chatItemMember g ci + let memberId = memberId' <$> chatItemMember g ci reactions <- getGroupCIReactions db g memberId sharedMsgId pure $ CChatItem md ci {reactions, mentions} Nothing -> pure $ if null mentions then cci else CChatItem md ci {mentions} @@ -2913,8 +2971,8 @@ markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} |] (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) -getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do +getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> Maybe GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2922,11 +2980,11 @@ getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupM [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? ORDER BY chat_item_id DESC LIMIT 1 |] - (userId, groupId, groupMemberId, sharedMsgId) + (userId, groupId, groupMemberId_, sharedMsgId) getGroupCIWithReactions db user g itemId getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) @@ -2967,7 +3025,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember, showGroupAsSender @@ -2977,7 +3035,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.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 @@ -2985,17 +3043,15 @@ 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.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.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 gsm ON gsm.group_member_id = i.group_scope_group_member_id - LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id @@ -3078,7 +3134,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -3256,7 +3312,7 @@ getDirectCIReactions db Contact {contactId} itemSharedMsgId = |] (contactId, itemSharedMsgId) -getGroupCIReactions :: DB.Connection -> GroupInfo -> MemberId -> SharedMsgId -> IO [CIReactionCount] +getGroupCIReactions :: DB.Connection -> GroupInfo -> Maybe MemberId -> SharedMsgId -> IO [CIReactionCount] getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = map toCIReaction <$> DB.query @@ -3264,7 +3320,7 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = [sql| SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) FROM chat_item_reactions - WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + WHERE group_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? GROUP BY reaction |] (groupId, itemMemberId, itemSharedMsgId) @@ -3298,7 +3354,7 @@ getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemShar reactions <- getDirectCIReactions db ct itemSharedMId pure $ AChatItem SCTDirect md chat ci {reactions} GroupChat g _s -> do - let GroupMember {memberId} = chatItemMember g ci + let memberId = memberId' <$> chatItemMember g ci reactions <- getGroupCIReactions db g memberId itemSharedMId pure $ AChatItem SCTGroup md chat ci {reactions} _ -> pure aci @@ -3312,10 +3368,10 @@ deleteDirectCIReactions_ db contactId ChatItem {meta = CIMeta {itemSharedMsgId}} deleteGroupCIReactions_ :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupCIReactions_ db g@GroupInfo {groupId} ci@ChatItem {meta = CIMeta {itemSharedMsgId}} = forM_ itemSharedMsgId $ \itemSharedMId -> do - let GroupMember {memberId} = chatItemMember g ci + let memberId = memberId' <$> chatItemMember g ci DB.execute db - "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ?" + "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ?" (groupId, itemSharedMId, memberId) toCIReaction :: (MsgReaction, BoolInt, Int) -> CIReactionCount @@ -3353,7 +3409,7 @@ setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs |] (contactId' ct, itemSharedMId, BI sent, reaction) -getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] +getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> Maybe MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = map fromOnly <$> DB.query @@ -3361,11 +3417,11 @@ getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = [sql| SELECT reaction FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + WHERE group_id = ? AND group_member_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? AND reaction_sent = ? |] (groupId, groupMemberId' m, itemMemberId, itemSharedMId, BI sent) -setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () +setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> Maybe MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs | add = DB.execute @@ -3381,7 +3437,7 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti db [sql| DELETE FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND reaction_sent = ? AND reaction = ? |] (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 25db5158d0..c4812e75f4 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -26,7 +26,9 @@ import Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations import Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables import Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices import Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link -import Simplex.Chat.Store.Postgres.Migrations.M20260201_client_services +import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays +import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed +import Simplex.Chat.Store.Postgres.Migrations.M20260407_client_services import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -53,7 +55,9 @@ schemaMigrations = ("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables), ("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices), ("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link), - ("20260201_client_services", m20260201_client_services, Just down_m20260201_client_services) + ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), + ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), + ("20260407_client_services", m20260407_client_services, Just down_m20260407_client_services) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs new file mode 100644 index 0000000000..e4928f5465 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs @@ -0,0 +1,126 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260222_chat_relays :: Text +m20260222_chat_relays = + [r| +CREATE TABLE chat_relays( + chat_relay_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + address BYTEA NOT NULL, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL DEFAULT '', + short_descr TEXT, + image TEXT, + domains TEXT NOT NULL, + preset SMALLINT NOT NULL DEFAULT 0, + tested SMALLINT, + enabled SMALLINT NOT NULL DEFAULT 1, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + deleted SMALLINT NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (now()), + updated_at TEXT NOT NULL DEFAULT (now()) +); +CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays(user_id, address); + +ALTER TABLE users ADD COLUMN is_user_chat_relay SMALLINT NOT NULL DEFAULT 0; + +ALTER TABLE groups + ADD COLUMN use_relays SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN creating_in_progress SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN relay_own_status TEXT, + ADD COLUMN relay_request_inv_id BYTEA, + ADD COLUMN relay_request_group_link BYTEA, + ADD COLUMN relay_request_peer_chat_min_version INTEGER, + ADD COLUMN relay_request_peer_chat_max_version INTEGER, + ADD COLUMN relay_request_failed SMALLINT DEFAULT 0, + ADD COLUMN relay_request_err_reason TEXT, + ADD COLUMN root_priv_key BYTEA, + ADD COLUMN root_pub_key BYTEA, + ADD COLUMN member_priv_key BYTEA, + ADD COLUMN public_member_count BIGINT; + +ALTER TABLE group_profiles + ADD COLUMN group_type TEXT, + ADD COLUMN group_link BYTEA, + ADD COLUMN public_group_id BYTEA; + +CREATE TABLE group_relays( + group_relay_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + chat_relay_id BIGINT NOT NULL REFERENCES chat_relays ON DELETE CASCADE, + relay_status TEXT NOT NULL, + relay_link BYTEA, + conf_id BYTEA, + created_at TEXT NOT NULL DEFAULT (now()), + updated_at TEXT NOT NULL DEFAULT (now()) +); +CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays(group_member_id); +CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); + +ALTER TABLE group_members + ADD COLUMN relay_link BYTEA, + ADD COLUMN member_pub_key BYTEA; + +ALTER TABLE messages ADD COLUMN msg_chat_binding TEXT; +ALTER TABLE messages ADD COLUMN msg_signatures BYTEA; + +ALTER TABLE chat_items ADD COLUMN msg_signed TEXT; + +ALTER TABLE connections ADD COLUMN relay_test SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20260222_chat_relays :: Text +down_m20260222_chat_relays = + [r| +UPDATE group_members SET member_role = 'observer' WHERE member_role = 'relay'; + +ALTER TABLE users DROP COLUMN is_user_chat_relay; + +ALTER TABLE groups + DROP COLUMN use_relays, + DROP COLUMN creating_in_progress, + DROP COLUMN relay_own_status, + DROP COLUMN relay_request_inv_id, + DROP COLUMN relay_request_group_link, + DROP COLUMN relay_request_peer_chat_min_version, + DROP COLUMN relay_request_peer_chat_max_version, + DROP COLUMN relay_request_failed, + DROP COLUMN relay_request_err_reason, + DROP COLUMN root_priv_key, + DROP COLUMN root_pub_key, + DROP COLUMN member_priv_key, + DROP COLUMN public_member_count; + +ALTER TABLE group_profiles + DROP COLUMN group_type, + DROP COLUMN group_link, + DROP COLUMN public_group_id; + +DROP INDEX idx_group_relays_group_id; +DROP INDEX idx_group_relays_group_member_id; +DROP INDEX idx_group_relays_chat_relay_id; +DROP TABLE group_relays; + +DROP INDEX idx_chat_relays_user_id; +DROP INDEX idx_chat_relays_user_id_address; +DROP TABLE chat_relays; + +ALTER TABLE group_members + DROP COLUMN relay_link, + DROP COLUMN member_pub_key; + +ALTER TABLE messages DROP COLUMN msg_chat_binding; +ALTER TABLE messages DROP COLUMN msg_signatures; + +ALTER TABLE chat_items DROP COLUMN msg_signed; + +ALTER TABLE connections DROP COLUMN relay_test; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260403_item_viewed.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260403_item_viewed.hs new file mode 100644 index 0000000000..466c664d8f --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260403_item_viewed.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260403_item_viewed :: Text +m20260403_item_viewed = + [r| +ALTER TABLE chat_items ADD COLUMN item_viewed SMALLINT NOT NULL DEFAULT 0; +CREATE INDEX idx_chat_items_contacts_item_viewed ON chat_items(user_id, contact_id, item_viewed, created_at); +CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items(user_id, group_id, item_viewed, item_ts); +|] + +down_m20260403_item_viewed :: Text +down_m20260403_item_viewed = + [r| +DROP INDEX idx_chat_items_contacts_item_viewed; +DROP INDEX idx_chat_items_groups_item_viewed; +ALTER TABLE chat_items DROP COLUMN item_viewed; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260201_client_services.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260407_client_services.hs similarity index 57% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260201_client_services.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260407_client_services.hs index b888d3a86a..581dab1285 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260201_client_services.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260407_client_services.hs @@ -1,19 +1,19 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20260201_client_services where +module Simplex.Chat.Store.Postgres.Migrations.M20260407_client_services where import Data.Text (Text) import Text.RawString.QQ (r) -m20260201_client_services :: Text -m20260201_client_services = +m20260407_client_services :: Text +m20260407_client_services = [r| ALTER TABLE users ADD COLUMN client_service SMALLINT NOT NULL DEFAULT 0; |] -down_m20260201_client_services :: Text -down_m20260201_client_services = +down_m20260407_client_services :: Text +down_m20260407_client_services = [r| ALTER TABLE users DROP COLUMN client_service; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index a2931a78d5..fa368bf87f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -343,7 +343,9 @@ CREATE TABLE test_chat_schema.chat_items ( group_scope_tag text, group_scope_group_member_id bigint, show_group_as_sender smallint DEFAULT 0 NOT NULL, - has_link smallint DEFAULT 0 NOT NULL + has_link smallint DEFAULT 0 NOT NULL, + msg_signed text, + item_viewed smallint DEFAULT 0 NOT NULL ); @@ -359,6 +361,36 @@ ALTER TABLE test_chat_schema.chat_items ALTER COLUMN chat_item_id ADD GENERATED +CREATE TABLE test_chat_schema.chat_relays ( + chat_relay_id bigint NOT NULL, + address bytea NOT NULL, + display_name text NOT NULL, + full_name text DEFAULT ''::text NOT NULL, + short_descr text, + image text, + domains text NOT NULL, + preset smallint DEFAULT 0 NOT NULL, + tested smallint, + enabled smallint DEFAULT 1 NOT NULL, + user_id bigint NOT NULL, + deleted smallint DEFAULT 0 NOT NULL, + created_at text DEFAULT now() NOT NULL, + updated_at text DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.chat_relays ALTER COLUMN chat_relay_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.chat_relays_chat_relay_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.chat_tags ( chat_tag_id bigint NOT NULL, user_id bigint, @@ -449,7 +481,8 @@ CREATE TABLE test_chat_schema.connections ( quota_err_counter bigint DEFAULT 0 NOT NULL, short_link_inv bytea, via_short_link_contact bytea, - via_contact_uri bytea + via_contact_uri bytea, + relay_test smallint DEFAULT 0 NOT NULL ); @@ -783,7 +816,9 @@ CREATE TABLE test_chat_schema.group_members ( member_xcontact_id bytea, member_welcome_shared_msg_id bytea, index_in_group bigint DEFAULT 0 NOT NULL, - member_relations_vector bytea + member_relations_vector bytea, + relay_link bytea, + member_pub_key bytea ); @@ -811,7 +846,10 @@ CREATE TABLE test_chat_schema.group_profiles ( preferences text, description text, member_admission text, - short_descr text + short_descr text, + group_type text, + group_link bytea, + public_group_id bytea ); @@ -827,6 +865,31 @@ ALTER TABLE test_chat_schema.group_profiles ALTER COLUMN group_profile_id ADD GE +CREATE TABLE test_chat_schema.group_relays ( + group_relay_id bigint NOT NULL, + group_id bigint NOT NULL, + group_member_id bigint NOT NULL, + chat_relay_id bigint NOT NULL, + relay_status text NOT NULL, + relay_link bytea, + conf_id bytea, + created_at text DEFAULT now() NOT NULL, + updated_at text DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.group_relays ALTER COLUMN group_relay_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.group_relays_group_relay_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.group_snd_item_statuses ( group_snd_item_status_id bigint NOT NULL, chat_item_id bigint NOT NULL, @@ -883,7 +946,20 @@ CREATE TABLE test_chat_schema.groups ( conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, via_group_link_uri bytea, summary_current_members_count bigint DEFAULT 0 NOT NULL, - member_index bigint DEFAULT 0 NOT NULL + member_index bigint DEFAULT 0 NOT NULL, + use_relays smallint DEFAULT 0 NOT NULL, + creating_in_progress smallint DEFAULT 0 NOT NULL, + relay_own_status text, + relay_request_inv_id bytea, + relay_request_group_link bytea, + relay_request_peer_chat_min_version integer, + relay_request_peer_chat_max_version integer, + relay_request_failed smallint DEFAULT 0, + relay_request_err_reason text, + root_priv_key bytea, + root_pub_key bytea, + member_priv_key bytea, + public_member_count bigint ); @@ -935,7 +1011,9 @@ CREATE TABLE test_chat_schema.messages ( shared_msg_id_user smallint, author_group_member_id bigint, forwarded_by_group_member_id bigint, - broker_ts timestamp with time zone + broker_ts timestamp with time zone, + msg_chat_binding text, + msg_signatures bytea ); @@ -1351,6 +1429,7 @@ CREATE TABLE test_chat_schema.users ( ui_themes text, active_order bigint DEFAULT 0 NOT NULL, auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, + is_user_chat_relay smallint DEFAULT 0 NOT NULL, client_service smallint DEFAULT 0 NOT NULL ); @@ -1435,6 +1514,11 @@ ALTER TABLE ONLY test_chat_schema.chat_items +ALTER TABLE ONLY test_chat_schema.chat_relays + ADD CONSTRAINT chat_relays_pkey PRIMARY KEY (chat_relay_id); + + + ALTER TABLE ONLY test_chat_schema.chat_tags ADD CONSTRAINT chat_tags_pkey PRIMARY KEY (chat_tag_id); @@ -1550,6 +1634,11 @@ ALTER TABLE ONLY test_chat_schema.group_profiles +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_pkey PRIMARY KEY (group_relay_id); + + + ALTER TABLE ONLY test_chat_schema.group_snd_item_statuses ADD CONSTRAINT group_snd_item_statuses_pkey PRIMARY KEY (group_snd_item_status_id); @@ -1819,6 +1908,10 @@ CREATE INDEX idx_chat_items_contacts_has_link_created_at ON test_chat_schema.cha +CREATE INDEX idx_chat_items_contacts_item_viewed ON test_chat_schema.chat_items USING btree (user_id, contact_id, item_viewed, created_at); + + + CREATE INDEX idx_chat_items_contacts_msg_content_tag_created_at ON test_chat_schema.chat_items USING btree (user_id, contact_id, msg_content_tag, created_at); @@ -1891,6 +1984,10 @@ CREATE INDEX idx_chat_items_groups_item_ts ON test_chat_schema.chat_items USING +CREATE INDEX idx_chat_items_groups_item_viewed ON test_chat_schema.chat_items USING btree (user_id, group_id, item_viewed, item_ts); + + + CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON test_chat_schema.chat_items USING btree (user_id, group_id, msg_content_tag, item_deleted, item_sent); @@ -1935,6 +2032,14 @@ CREATE INDEX idx_chat_items_user_id_item_status ON test_chat_schema.chat_items U +CREATE INDEX idx_chat_relays_user_id ON test_chat_schema.chat_relays USING btree (user_id); + + + +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON test_chat_schema.chat_relays USING btree (user_id, address); + + + CREATE INDEX idx_chat_tags_chats_chat_tag_id ON test_chat_schema.chat_tags_chats USING btree (chat_tag_id); @@ -2207,6 +2312,18 @@ CREATE INDEX idx_group_profiles_user_id ON test_chat_schema.group_profiles USING +CREATE INDEX idx_group_relays_chat_relay_id ON test_chat_schema.group_relays USING btree (chat_relay_id); + + + +CREATE INDEX idx_group_relays_group_id ON test_chat_schema.group_relays USING btree (group_id); + + + +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON test_chat_schema.group_relays USING btree (group_member_id); + + + CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON test_chat_schema.group_snd_item_statuses USING btree (chat_item_id); @@ -2561,6 +2678,11 @@ ALTER TABLE ONLY test_chat_schema.chat_items +ALTER TABLE ONLY test_chat_schema.chat_relays + ADD CONSTRAINT chat_relays_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.chat_tags_chats ADD CONSTRAINT chat_tags_chats_chat_tag_id_fkey FOREIGN KEY (chat_tag_id) REFERENCES test_chat_schema.chat_tags(chat_tag_id) ON DELETE CASCADE; @@ -2881,6 +3003,21 @@ ALTER TABLE ONLY test_chat_schema.group_profiles +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_chat_relay_id_fkey FOREIGN KEY (chat_relay_id) REFERENCES test_chat_schema.chat_relays(chat_relay_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_group_member_id_fkey FOREIGN KEY (group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.group_snd_item_statuses ADD CONSTRAINT group_snd_item_statuses_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 40218dbdb6..da45b43f8f 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -25,6 +25,7 @@ module Simplex.Chat.Store.Profiles getUsers, setActiveUser, getUser, + getRelayUser, getUserIdByName, getUserByAConnId, getUserByASndFileId, @@ -57,6 +58,8 @@ module Simplex.Chat.Store.Profiles getContactWithoutConnViaShortAddress, updateUserAddressSettings, getProtocolServers, + getChatRelays, + getChatRelayById, insertProtocolServer, getUpdateServerOperators, getServerOperators, @@ -125,8 +128,8 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User -createUserRecordAt db (AgentUserId auId) clientService Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} activeUser currentTs = +createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User +createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} activeUser currentTs = checkConstraint SEDuplicateName . liftIO $ do when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" let showNtfs = True @@ -136,8 +139,10 @@ createUserRecordAt db (AgentUserId auId) clientService Profile {displayName, ful order <- getNextActiveOrder db DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?,?,?)" - ((auId, displayName, BI activeUser, order, BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService) :. (currentTs, currentTs)) + "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?)" + ( (auId, displayName, BI activeUser, BI userChatRelay, order) + :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService, currentTs, currentTs) + ) userId <- insertedRowId db DB.execute db @@ -154,7 +159,7 @@ createUserRecordAt db (AgentUserId auId) clientService Profile {displayName, ful (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI clientService, Nothing) + pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -210,6 +215,11 @@ getUser db userId = ExceptT . firstRow toUser (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) +getRelayUser :: DB.Connection -> ExceptT StoreError IO User +getRelayUser db = + ExceptT . firstRow toUser SERelayUserNotFound $ + DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1") + getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 getUserIdByName db uName = ExceptT . firstRow fromOnly (SEUserNotFoundByName uName) $ @@ -618,6 +628,61 @@ serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) auth = safeDecodeUtf8 . unBasicAuth <$> auth_ in (protocol, host, port, keyHash, auth) +getChatRelays :: DB.Connection -> User -> IO [UserChatRelay] +getChatRelays db User {userId} = + map toChatRelay + <$> DB.query + db + [sql| + SELECT chat_relay_id, address, display_name, full_name, short_descr, image, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? AND deleted = 0 + |] + (Only userId) + +toChatRelay :: (DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay +toChatRelay (chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset, tested, BI enabled) = + UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} + +getChatRelayById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserChatRelay +getChatRelayById db User {userId} relayId = + ExceptT . firstRow toChatRelay (SEUserChatRelayNotFound relayId) $ + DB.query + db + [sql| + SELECT chat_relay_id, address, display_name, full_name, short_descr, image, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? AND chat_relay_id = ? AND deleted = 0 + |] + (userId, relayId) + +insertChatRelay :: DB.Connection -> User -> UTCTime -> NewUserChatRelay -> IO UserChatRelay +insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {displayName, fullName, shortDescr, image}, domains, preset, tested, enabled} = do + crId <- + fromOnly . head + <$> DB.query + db + [sql| + INSERT INTO chat_relays + (address, display_name, full_name, short_descr, image, domains, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING chat_relay_id + |] + ((address, displayName, fullName, shortDescr, image, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, userId) :. (ts, ts)) + pure (relay :: NewUserChatRelay) {chatRelayId = DBEntityId crId} + +updateChatRelay :: DB.Connection -> UTCTime -> UserChatRelay -> IO () +updateChatRelay db ts UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {displayName, fullName, shortDescr, image}, domains, preset, tested, enabled} = + DB.execute + db + [sql| + UPDATE chat_relays + SET address = ?, display_name = ?, full_name = ?, short_descr = ?, image = ?, domains = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE chat_relay_id = ? + |] + ((address, displayName, fullName, shortDescr, image, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, ts) :. Only chatRelayId) + getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions getServerOperators db = do currentConditions <- getCurrentUsageConditions db @@ -629,12 +694,13 @@ getServerOperators db = do let conditionsAction = usageConditionsAction ops currentConditions now pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} -getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) getUserServers db user = - (,,) + (,,,) <$> (map Just . serverOperators <$> getServerOperators db) <*> liftIO (getProtocolServers db SPSMP user) <*> liftIO (getProtocolServers db SPXFTP user) + <*> liftIO (getChatRelays db user) setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO () setServerOperators db ops = do @@ -847,20 +913,58 @@ setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers -setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do +setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers, chatRelays} = do mapM_ (updateServerOperator db ts) operator - smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers - xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers - pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'} + smpSrvs' <- catMaybes <$> mapM (upsertOrDeleteSrv SPSMP) smpServers + xftpSrvs' <- catMaybes <$> mapM (upsertOrDeleteSrv SPXFTP) xftpServers + cRelays' <- catMaybes <$> mapM upsertOrDeleteCRelay chatRelays + pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs', chatRelays = cRelays'} where - upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) - upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of + upsertOrDeleteSrv :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) + upsertOrDeleteSrv p (AUS _ s@UserServer {serverId, deleted}) = case serverId of DBNewEntity | deleted -> pure Nothing | otherwise -> Just <$> insertProtocolServer db p user ts s DBEntityId srvId | 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, address, deleted}) = case chatRelayId of + DBNewEntity + | deleted -> pure Nothing + | 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 + referenced <- fromOnly . head <$> DB.query db "SELECT EXISTS (SELECT 1 FROM group_relays WHERE chat_relay_id = ?)" (Only relayId) + if referenced + then DB.execute db "UPDATE chat_relays SET deleted = 1, updated_at = ? WHERE chat_relay_id = ?" (ts, relayId) + 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 {relayProfile = RelayProfile {displayName, fullName, shortDescr, image}, domains, preset, tested, enabled} = + DB.execute db + [sql| + UPDATE chat_relays + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, domains = ?, + preset = ?, tested = ?, enabled = ?, deleted = 0, updated_at = ? + WHERE chat_relay_id = ? + |] + (displayName, fullName, shortDescr, image, 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 new file mode 100644 index 0000000000..3858281878 --- /dev/null +++ b/src/Simplex/Chat/Store/RelayRequests.hs @@ -0,0 +1,105 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Chat.Store.RelayRequests + ( hasPendingRelayRequests, + getNextPendingRelayRequest, + setRelayRequestErr, + ) +where + +import Data.Maybe (fromMaybe) +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 +import Simplex.Messaging.Util (firstRow') +import Simplex.Messaging.Version +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +hasPendingRelayRequests :: DB.Connection -> IO Bool +hasPendingRelayRequests db = + fromOnly . head + <$> DB.query + db + [sql| + SELECT EXISTS ( + SELECT 1 + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + LIMIT 1 + ) + |] + (Only RSInvited) + +getNextPendingRelayRequest :: DB.Connection -> IO (Either StoreError (Maybe (GroupId, RelayRequestData))) +getNextPendingRelayRequest db = + getWorkItem "relay request" getNextRequestGroupId getRelayRequestData (markRelayRequestFailed db) + where + getNextRequestGroupId :: IO (Maybe GroupId) + getNextRequestGroupId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT group_id + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + ORDER BY group_id ASC + LIMIT 1 + |] + (Only RSInvited) + getRelayRequestData :: GroupId -> IO (Either StoreError (GroupId, RelayRequestData)) + getRelayRequestData groupId = + firstRow' toRelayRequestData (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT + relay_request_inv_id, relay_request_group_link, + relay_request_peer_chat_min_version, relay_request_peer_chat_max_version + FROM groups + WHERE group_id = ? + |] + (Only groupId) + where + toRelayRequestData :: (Maybe InvitationId, Maybe ShortLinkContact, Maybe VersionChat, Maybe VersionChat) -> Either StoreError (GroupId, RelayRequestData) + toRelayRequestData = \case + (Just relayInvId, Just reqGroupLink, Just minV, Just maxV) -> + Right (groupId, RelayRequestData {relayInvId, reqGroupLink, reqChatVRange = fromMaybe (versionToRange maxV) $ safeVersionRange minV maxV}) + _ -> Left $ SEInternalError "missing relay request data" + +markRelayRequestFailed :: DB.Connection -> GroupId -> IO () +markRelayRequestFailed db groupId = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET relay_request_failed = 1, updated_at = ? WHERE group_id = ?" + (currentTs, groupId) + +setRelayRequestErr :: DB.Connection -> GroupId -> Text -> IO () +setRelayRequestErr db groupId errReason = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET relay_request_err_reason = ?, updated_at = ? WHERE group_id = ?" + (errReason, currentTs, groupId) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index aef6965b21..cdbdf1bc0a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -149,7 +149,9 @@ import Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations import Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables import Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link -import Simplex.Chat.Store.SQLite.Migrations.M20260201_client_services +import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays +import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed +import Simplex.Chat.Store.SQLite.Migrations.M20260407_client_services import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -299,7 +301,9 @@ schemaMigrations = ("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables), ("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices), ("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link), - ("20260201_client_services", m20260201_client_services, Just down_m20260201_client_services) + ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), + ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), + ("20260407_client_services", m20260407_client_services, Just down_m20260407_client_services) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs index 17ecb97649..18e9c5f6b6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs @@ -10,7 +10,7 @@ m20230511_reactions = [sql| CREATE TABLE chat_item_reactions ( chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT, - item_member_id BLOB, -- member that created item, NULL for items in direct chats + item_member_id BLOB, shared_msg_id BLOB NOT NULL, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs index 0b70fb9dcb..dce9574fe3 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs @@ -5,10 +5,6 @@ module Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- TODO [channels fwd] add later in new migration for MemberProfileUpdate delivery jobs: --- TODO - ALTER TABLE group_members ADD COLUMN last_profile_delivery_ts TEXT; --- TODO - ALTER TABLE group_members ADD COLUMN join_ts TEXT; - -- How columns correspond to types: -- both tables: @@ -21,7 +17,7 @@ import Database.SQLite.Simple.QQ (sql) -- delivery_tasks table: -- - sender_group_member_id <-> GroupMemberId (sender of the original message that created task), -- - message_id <-> MessageId (reference to the original message that created task), --- - message_from_channel <-> Maybe MessageFromChannel (for MessageDeliveryTask), +-- - message_from_channel <-> ShowGroupAsSender (for MessageDeliveryTask), -- - task_status <-> DeliveryTaskStatus, -- - task_err_reason <-> Maybe Text (set when task status is DTSError, not encoded in status to allow filtering by DTSError in queries). diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs new file mode 100644 index 0000000000..a106e184d1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- TODO [relays] TBC schema improvement - relay_link is duplicate on group_relays and group_members for owner +-- - chat_relays - user's list of chat relays to choose from (similar to protocol_servers) +-- - users.is_user_chat_relay - indicates that the user can serve as a chat relay +-- (TBC usage, e.g. agree to invitations to be relay) +-- - group_relays - group owner's list of relays for a group +-- - group_relays.relay_link - links for all relays of a group are included in GroupShortLinkData +-- - group_relays.relay_status - group owner's status for each relay (RelayStatus) +-- - group_relays.chat_relay_id - associates group_relays record with a chat_relays record, +-- chat_relays.deleted is to keep associated record if user removes chat relay from configuration, +-- but has group relays using it +-- - group_members.relay_link - relay link, saved on member record for user joining group +-- - groups.relay_own_status - indicates for a relay client that it is chat relay for the group (RelayStatus) +-- - groups.relay_request_* - relay request "work item" fields +m20260222_chat_relays :: Query +m20260222_chat_relays = + [sql| +CREATE TABLE chat_relays( + chat_relay_id INTEGER PRIMARY KEY, + address BLOB NOT NULL, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL DEFAULT '', + short_descr TEXT, + image TEXT, + domains TEXT NOT NULL, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays(user_id, address); + +ALTER TABLE users ADD COLUMN is_user_chat_relay INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN use_relays INTEGER NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN creating_in_progress INTEGER NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN relay_own_status TEXT; +ALTER TABLE groups ADD COLUMN relay_request_inv_id BLOB; +ALTER TABLE groups ADD COLUMN relay_request_group_link BLOB; +ALTER TABLE groups ADD COLUMN relay_request_peer_chat_min_version INTEGER; +ALTER TABLE groups ADD COLUMN relay_request_peer_chat_max_version INTEGER; +ALTER TABLE groups ADD COLUMN relay_request_failed INTEGER DEFAULT 0; +ALTER TABLE groups ADD COLUMN relay_request_err_reason TEXT; +ALTER TABLE groups ADD COLUMN root_priv_key BLOB; +ALTER TABLE groups ADD COLUMN root_pub_key BLOB; +ALTER TABLE groups ADD COLUMN member_priv_key BLOB; +ALTER TABLE groups ADD COLUMN public_member_count INTEGER; + +ALTER TABLE group_profiles ADD COLUMN group_type TEXT; +ALTER TABLE group_profiles ADD COLUMN group_link BLOB; +ALTER TABLE group_profiles ADD COLUMN public_group_id BLOB; + +CREATE TABLE group_relays( + group_relay_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + chat_relay_id INTEGER NOT NULL REFERENCES chat_relays ON DELETE CASCADE, + relay_status TEXT NOT NULL, + relay_link BLOB, + conf_id BLOB, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays(group_member_id); +CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); + +ALTER TABLE group_members ADD COLUMN relay_link BLOB; +ALTER TABLE group_members ADD COLUMN member_pub_key BLOB; + +ALTER TABLE messages ADD COLUMN msg_chat_binding TEXT; +ALTER TABLE messages ADD COLUMN msg_signatures BLOB; + +ALTER TABLE chat_items ADD COLUMN msg_signed TEXT; + +ALTER TABLE connections ADD COLUMN relay_test INTEGER NOT NULL DEFAULT 0; +|] + +down_m20260222_chat_relays :: Query +down_m20260222_chat_relays = + [sql| +UPDATE group_members SET member_role = 'observer' WHERE member_role = 'relay'; + +ALTER TABLE users DROP COLUMN is_user_chat_relay; + +ALTER TABLE groups DROP COLUMN use_relays; +ALTER TABLE groups DROP COLUMN creating_in_progress; +ALTER TABLE groups DROP COLUMN relay_own_status; +ALTER TABLE groups DROP COLUMN relay_request_inv_id; +ALTER TABLE groups DROP COLUMN relay_request_group_link; +ALTER TABLE groups DROP COLUMN relay_request_peer_chat_min_version; +ALTER TABLE groups DROP COLUMN relay_request_peer_chat_max_version; +ALTER TABLE groups DROP COLUMN relay_request_failed; +ALTER TABLE groups DROP COLUMN relay_request_err_reason; +ALTER TABLE groups DROP COLUMN root_priv_key; +ALTER TABLE groups DROP COLUMN root_pub_key; +ALTER TABLE groups DROP COLUMN member_priv_key; +ALTER TABLE groups DROP COLUMN public_member_count; + +ALTER TABLE group_profiles DROP COLUMN group_type; +ALTER TABLE group_profiles DROP COLUMN group_link; +ALTER TABLE group_profiles DROP COLUMN public_group_id; + +DROP INDEX idx_group_relays_group_id; +DROP INDEX idx_group_relays_group_member_id; +DROP INDEX idx_group_relays_chat_relay_id; +DROP TABLE group_relays; + +DROP INDEX idx_chat_relays_user_id; +DROP INDEX idx_chat_relays_user_id_address; +DROP TABLE chat_relays; + +ALTER TABLE group_members DROP COLUMN relay_link; +ALTER TABLE group_members DROP COLUMN member_pub_key; + +ALTER TABLE messages DROP COLUMN msg_chat_binding; +ALTER TABLE messages DROP COLUMN msg_signatures; + +ALTER TABLE chat_items DROP COLUMN msg_signed; + +ALTER TABLE connections DROP COLUMN relay_test; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260403_item_viewed.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260403_item_viewed.hs new file mode 100644 index 0000000000..4facc0a684 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260403_item_viewed.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260403_item_viewed :: Query +m20260403_item_viewed = + [sql| +ALTER TABLE chat_items ADD COLUMN item_viewed INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_chat_items_contacts_item_viewed ON chat_items(user_id, contact_id, item_viewed, created_at); +CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items(user_id, group_id, item_viewed, item_ts); +|] + +down_m20260403_item_viewed :: Query +down_m20260403_item_viewed = + [sql| +DROP INDEX idx_chat_items_contacts_item_viewed; +DROP INDEX idx_chat_items_groups_item_viewed; +ALTER TABLE chat_items DROP COLUMN item_viewed; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260201_client_services.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260407_client_services.hs similarity index 56% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260201_client_services.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260407_client_services.hs index 6cbc4e02ab..ea160c9c4f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260201_client_services.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260407_client_services.hs @@ -1,18 +1,18 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20260201_client_services where +module Simplex.Chat.Store.SQLite.Migrations.M20260407_client_services where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20260201_client_services :: Query -m20260201_client_services = +m20260407_client_services :: Query +m20260407_client_services = [sql| ALTER TABLE users ADD COLUMN client_service INTEGER NOT NULL DEFAULT 0; |] -down_m20260201_client_services :: Query -down_m20260201_client_services = +down_m20260407_client_services :: Query +down_m20260407_client_services = [sql| ALTER TABLE users DROP COLUMN client_service; |] 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 14c82934c3..fdcac9134a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -293,6 +293,15 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + INSERT INTO client_services + (user_id, host, port, server_key_hash, service_cert_hash, service_cert, service_priv_key) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT (user_id, host, port, server_key_hash) DO NOTHING + RETURNING 1 + +Plan: + Query: INSERT INTO conn_confirmations (confirmation_id, conn_id, sender_key, e2e_snd_pub_key, ratchet_state, sender_conn_info, smp_reply_queues, smp_client_version, accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0); @@ -546,19 +555,6 @@ Query: Plan: SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) -Query: - INSERT INTO client_services - (user_id, host, port, server_key_hash, service_cert_hash, service_cert, service_priv_key) - VALUES (?,?,?,?,?,?,?) - ON CONFLICT (user_id, host, port, server_key_hash) - DO UPDATE SET - service_cert_hash = EXCLUDED.service_cert_hash, - service_cert = EXCLUDED.service_cert, - service_priv_key = EXCLUDED.service_priv_key, - service_id = NULL - -Plan: - Query: INSERT INTO connections (user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?) @@ -953,7 +949,7 @@ Query: FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id - WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? + WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? AND q.rcv_service_assoc = 0 ORDER BY q.rcv_id LIMIT ? Plan: SEARCH s USING PRIMARY KEY (host=? AND port=?) SEARCH q USING PRIMARY KEY (host=? AND port=?) @@ -965,7 +961,7 @@ Query: FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id - WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? AND q.rcv_service_assoc = 0 + WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? ORDER BY q.rcv_id LIMIT ? Plan: SEARCH s USING PRIMARY KEY (host=? AND port=?) SEARCH q USING PRIMARY KEY (host=? AND port=?) @@ -977,7 +973,7 @@ Query: FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id - WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? + WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? ORDER BY q.rcv_id LIMIT ? Plan: SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=? AND host=? AND port=?) SEARCH c USING PRIMARY KEY (conn_id=?) 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 29e62577f6..9ed4c7ce9a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -30,6 +30,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -82,6 +83,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -140,24 +142,26 @@ SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, 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, -- 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.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.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 @@ -175,6 +179,21 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + +Plan: +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next_for_job_scope (group_id=? AND worker_scope=? AND job_scope_spec_tag=? AND job_scope_include_pending=? AND job_scope_support_gm_id=? AND failed=? AND task_status=?) + Query: SELECT delivery_task_id FROM delivery_tasks @@ -265,6 +284,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -294,45 +314,47 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -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_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +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_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -490,6 +512,75 @@ Query: Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +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_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +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_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, @@ -497,6 +588,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -526,11 +618,12 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -559,9 +652,9 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO messages ( - msg_sent, chat_msg_event, msg_body, connection_id, group_id, + msg_sent, chat_msg_event, msg_body, msg_chat_binding, msg_signatures, connection_id, group_id, shared_msg_id, shared_msg_id_user, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -590,6 +683,16 @@ Query: Plan: SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT + relay_request_inv_id, relay_request_group_link, + relay_request_peer_chat_min_version, relay_request_peer_chat_max_version + FROM groups + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT COUNT(1) FROM ( @@ -765,7 +868,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_item_viewed (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -776,7 +879,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_item_viewed (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -864,7 +967,9 @@ Plan: SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? AND worker_scope=? AND failed=? AND task_status=?) Query: - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.preferences, gp.member_admission + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? @@ -873,6 +978,18 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT group_id + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + ORDER BY group_id ASC + LIMIT 1 + +Plan: +SCAN groups + Query: SELECT i.chat_item_id FROM chat_items i @@ -895,7 +1012,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.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 @@ -947,7 +1064,7 @@ SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) Query: - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention @@ -990,7 +1107,7 @@ SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_ Query: DELETE FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND reaction_sent = ? AND reaction = ? Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) @@ -1010,13 +1127,22 @@ Query: Plan: Query: - INSERT INTO group_members - (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + INSERT INTO chat_relays + (address, display_name, full_name, short_descr, image, domains, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING chat_relay_id Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_relay_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1043,28 +1169,74 @@ 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_members + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, + member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +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_type, group_link, public_group_id, + 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, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, + root_priv_key, root_pub_key, member_priv_key, public_member_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: - INSERT INTO groups - (local_display_name, user_id, group_profile_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?) - -Plan: - Query: INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, broker_ts, created_at, updated_at, connection_id, group_id, + (msg_sent, chat_msg_event, msg_body, msg_chat_binding, msg_signatures, broker_ts, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1088,7 +1260,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -1105,7 +1277,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember, showGroupAsSender @@ -1115,7 +1287,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.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 @@ -1123,17 +1295,15 @@ 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.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.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 gsm ON gsm.group_member_id = i.group_scope_group_member_id - LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id @@ -1146,7 +1316,6 @@ Query: Plan: SEARCH i USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN -SEARCH gsm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH ri USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) LEFT-JOIN @@ -1161,7 +1330,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -1217,6 +1386,14 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) INDEX 2 SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) +Query: + SELECT COUNT(1) FROM group_members + WHERE group_id = ? AND member_role = ? + AND member_status IN (?,?,?,?,?,?,?) + +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) + Query: SELECT agent_conn_id FROM ( SELECT @@ -1248,7 +1425,7 @@ SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? ORDER BY chat_item_id DESC LIMIT 1 @@ -1292,7 +1469,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_item_viewed (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1445,11 +1622,12 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + LEFT JOIN groups g ON g.group_id = f.group_id WHERE f.user_id = ? AND f.file_id = ? Plan: @@ -1457,6 +1635,7 @@ SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH r USING INTEGER PRIMARY KEY (rowid=?) SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT r.probe, r.contact_id, g.group_id, r.group_member_id @@ -1472,7 +1651,7 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? AND item_status = ? @@ -1480,6 +1659,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 display_name = ?, full_name = ?, short_descr = ?, image = ?, 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 ( @@ -1551,7 +1739,9 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, + group_type = ?, group_link = ?, + preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -1563,6 +1753,35 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET relay_request_inv_id = ?, + relay_request_group_link = ?, + relay_request_peer_chat_min_version = ?, + relay_request_peer_chat_max_version = ? + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + relay_test, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + group_member_id, conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO connections ( user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, @@ -1596,6 +1815,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1622,6 +1842,13 @@ 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_relays + (group_id, group_member_id, chat_relay_id, relay_status, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at, delivery_status) @@ -2220,7 +2447,7 @@ Query: ) ReportCount ON ReportCount.group_id = g.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND ( LOWER(g.local_display_name) LIKE '%' || ? || '%' OR LOWER(gp.display_name) LIKE '%' || ? || '%' @@ -2272,7 +2499,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.favorite = 1 OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) ORDER BY g.chat_ts DESC LIMIT ? @@ -2318,7 +2545,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2363,7 +2590,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: @@ -2408,7 +2635,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2453,7 +2680,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2498,7 +2725,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: @@ -2543,7 +2770,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2587,7 +2814,7 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id>?) @@ -2629,7 +2856,7 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id>?) @@ -2671,7 +2898,7 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id>?) @@ -3019,7 +3246,7 @@ Query: SELECT t.delivery_task_id, t.worker_scope, t.job_scope_spec_tag, t.job_scope_include_pending, t.job_scope_support_gm_id, - m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, t.message_from_channel + m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, msg.msg_chat_binding, msg.msg_signatures, t.message_from_channel FROM delivery_tasks t JOIN messages msg ON msg.message_id = t.message_id JOIN group_members m ON m.group_member_id = t.sender_group_member_id @@ -3048,6 +3275,21 @@ Query: Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) +Query: + SELECT EXISTS ( + SELECT 1 + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + LIMIT 1 + ) + +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SCAN groups + Query: SELECT agent_conn_id FROM connections @@ -3060,6 +3302,13 @@ Query: Plan: SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) +Query: + SELECT agent_conn_id FROM connections + WHERE user_id = ? AND relay_test = 1 AND created_at < ? + +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) + Query: SELECT c.agent_conn_id FROM connections c @@ -3153,6 +3402,16 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_viewed = 1 + ORDER BY created_at DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_item_viewed (user_id=? AND contact_id=? AND item_viewed=?) + Query: SELECT chat_item_id FROM chat_items @@ -3210,6 +3469,22 @@ Query: Plan: SEARCH chat_item_versions USING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +Query: + SELECT chat_relay_id, address, display_name, full_name, short_descr, image, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? AND chat_relay_id = ? AND deleted = 0 + +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT chat_relay_id, address, display_name, full_name, short_descr, image, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? AND deleted = 0 + +Plan: +SEARCH chat_relays USING INDEX idx_chat_relays_user_id_address (user_id=?) + Query: SELECT chat_tag_id, chat_tag_emoji, chat_tag_text FROM chat_tags @@ -3228,6 +3503,14 @@ Query: Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT conf_id + FROM group_relays + WHERE group_member_id = ? AND conf_id IS NOT NULL + +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) + Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, @@ -3339,6 +3622,30 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT group_member_id + FROM group_members + WHERE group_id = ? + AND contact_id IS DISTINCT FROM ? + AND group_member_id IS DISTINCT FROM ? + AND member_status IN (?,?,?,?,?,?) + AND group_member_id > ? ORDER BY group_member_id ASC LIMIT ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT group_member_id + FROM group_members + WHERE group_id = ? + AND contact_id IS DISTINCT FROM ? + AND group_member_id IS DISTINCT FROM ? + AND member_status IN (?,?,?,?,?,?) + ORDER BY group_member_id ASC LIMIT ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +USE TEMP B-TREE FOR ORDER BY + Query: SELECT group_member_id, group_snd_item_status, via_proxy FROM group_snd_item_statuses @@ -3404,7 +3711,7 @@ SEARCH m USING INDEX idx_group_members_user_id (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT pgm.message_id, m.shared_msg_id, m.msg_body + SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.msg_chat_binding, m.msg_signatures FROM pending_group_messages pgm JOIN messages m USING (message_id) WHERE pgm.group_member_id = ? @@ -3451,7 +3758,7 @@ SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_ Query: SELECT reaction FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + WHERE group_id = ? AND group_member_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? AND reaction_sent = ? Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) @@ -3469,7 +3776,7 @@ USE TEMP B-TREE FOR GROUP BY Query: SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) FROM chat_item_reactions - WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + WHERE group_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? GROUP BY reaction Plan: @@ -3643,6 +3950,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_id = ?, member_pub_key = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_relations_vector = ? @@ -3651,6 +3966,27 @@ Query: Plan: SCAN group_members +Query: + UPDATE group_members + SET member_role = ?, + member_status = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles SET group_type = ?, group_link = ?, public_group_id = ?, updated_at = ? + WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET member_index = member_index + 1 @@ -4204,12 +4540,12 @@ Query: user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, show_group_as_sender, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, item_viewed, show_group_as_sender, msg_signed, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4427,21 +4763,21 @@ Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? AND item_status = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) Query: - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? AND item_status = ? @@ -4449,7 +4785,7 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) Query: - UPDATE chat_items SET item_status = ?, updated_at = ? + UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND note_folder_id = ? AND item_status = ? Plan: @@ -4599,6 +4935,14 @@ Query: Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_pub_key = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_relations_vector = ?, updated_at = ? @@ -4639,6 +4983,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET relay_link = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET show_messages = ?, updated_at = ? @@ -4674,6 +5026,22 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_relays + SET conf_id = ?, relay_link = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) + +Query: + UPDATE group_relays + SET relay_status = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) + Query: UPDATE group_snd_item_statuses SET group_snd_item_status = ?, updated_at = ? @@ -4841,18 +5209,20 @@ SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_me Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, 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.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 @@ -4875,18 +5245,20 @@ SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, 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.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 @@ -4902,18 +5274,20 @@ SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, 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.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 @@ -4961,7 +5335,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.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, @@ -4988,7 +5362,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.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, @@ -5007,7 +5381,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.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, @@ -5026,7 +5400,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.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, @@ -5045,7 +5419,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.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, @@ -5064,7 +5438,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.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, @@ -5083,7 +5457,26 @@ 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.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, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.relay_link = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + 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.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, @@ -5102,7 +5495,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.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, @@ -5121,7 +5514,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.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, @@ -5135,13 +5528,32 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + 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.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, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5150,7 +5562,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.contact_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_contacts_item_viewed (user_id=? AND contact_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5168,7 +5580,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.group_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=? AND group_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5189,6 +5601,56 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, 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 + + JOIN group_members m ON m.group_member_id = gr.group_member_id + WHERE gr.group_id = ? + AND m.member_status = ? + AND gr.relay_status IN (?,?) + +Plan: +SEARCH gr USING INDEX idx_group_relays_group_id (group_id=?) +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, 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 gr USING INDEX idx_group_relays_group_id (group_id=?) +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, 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 gr USING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, 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 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 FROM group_members m @@ -5260,7 +5722,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5272,7 +5734,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5285,7 +5747,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5298,7 +5760,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5312,7 +5774,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5325,7 +5787,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5338,7 +5800,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5351,7 +5813,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5364,7 +5826,19 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes + FROM users u + JOIN contacts uct ON uct.contact_id = u.contact_id + JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id + WHERE u.is_user_chat_relay = 1 +Plan: +SCAN u +SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5551,7 +6025,7 @@ Query: DELETE FROM chat_item_reactions WHERE group_id = ? Plan: SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) -Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ? +Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ? Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) @@ -5561,7 +6035,7 @@ SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_ Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_item_viewed (user_id=? AND contact_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5585,7 +6059,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND item_content_tag != 'chatBanner' Plan: -SEARCH chat_items USING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) +SEARCH chat_items USING INDEX idx_chat_items_contacts_item_viewed (user_id=? AND contact_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5597,7 +6071,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=? AND group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5621,7 +6095,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_content_tag != 'chatBanner' Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_has_link_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_item_viewed (user_id=? AND group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5643,6 +6117,11 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) +Query: DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ? +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_relay_id=?) + Query: DELETE FROM commands WHERE user_id = ? AND command_id = ? Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) @@ -5737,6 +6216,7 @@ SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=? Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -5766,6 +6246,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -5795,6 +6276,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM groups WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_id (group_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_group_id (group_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_group_id (group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_group_id (group_id=?) @@ -5904,6 +6386,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) SEARCH chat_tags USING COVERING INDEX idx_chat_tags_user_id (user_id=?) SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) SEARCH received_probes USING COVERING INDEX idx_received_probes_user_id (user_id=?) @@ -5915,7 +6398,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?) SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?) SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=?) SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?) SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) SEARCH connections USING COVERING INDEX idx_connections_to_subscribe (user_id=?) @@ -6033,7 +6516,7 @@ Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?,?,?) +Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?) Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) @@ -6057,9 +6540,9 @@ SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) -Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: SEARCH chat_items USING COVERING 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=?) @@ -6079,7 +6562,19 @@ Query: SELECT EXISTS (SELECT 1 FROM chat_items WHERE user_id = ? AND contact_id Plan: SCAN CONSTANT ROW SCALAR SUBQUERY 1 -SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_has_link_created_at (user_id=? AND contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_item_viewed (user_id=? AND contact_id=?) + +Query: SELECT EXISTS (SELECT 1 FROM group_members WHERE group_id = ? AND member_id = ?) +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SEARCH group_members USING COVERING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) + +Query: SELECT EXISTS (SELECT 1 FROM group_relays WHERE chat_relay_id = ?) +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_relay_id=?) Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? Plan: @@ -6101,10 +6596,14 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) -Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_viewed = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT 1 +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_viewed (user_id=? AND group_id=? AND item_viewed=?) + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? LIMIT 1 Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) @@ -6125,6 +6624,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=?) @@ -6207,12 +6710,16 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL Plan: SCAN groups +Query: SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ? +Plan: +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) + Query: SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ? Plan: SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) @@ -6223,7 +6730,7 @@ SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT group_id, conn_full_link_to_connect FROM groups WHERE user_id = ? AND conn_short_link_to_connect = ? Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 Plan: @@ -6237,6 +6744,10 @@ Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ? +Plan: +SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) + Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) @@ -6257,6 +6768,10 @@ Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT member_status FROM group_members WHERE local_display_name = ? +Plan: +SCAN group_members + Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6273,6 +6788,14 @@ Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connectio Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT relay_own_status FROM groups WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT relay_status FROM group_relays WHERE group_relay_id = ? +Plan: +SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6281,6 +6804,10 @@ Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT summary_current_members_count FROM groups WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -6301,6 +6828,10 @@ Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND item_status = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_user_id_item_status (user_id=? AND item_status=?) + Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) @@ -6309,10 +6840,6 @@ Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? A Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ? -Plan: -SEARCH chat_items USING INDEX idx_chat_items_user_id_item_status (user_id=? AND item_status=?) - Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) @@ -6325,6 +6852,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=?) @@ -6425,6 +6956,10 @@ Query: UPDATE contacts SET xcontact_id = ? WHERE contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE delivery_jobs SET cursor_group_member_id = ?, updated_at = ? WHERE delivery_job_id = ? +Plan: +SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ? Plan: SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) @@ -6485,6 +7020,10 @@ Query: UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE grou Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6509,6 +7048,10 @@ Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE g Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ? +Plan: +SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) @@ -6529,6 +7072,10 @@ Query: UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) @@ -6545,10 +7092,22 @@ Query: UPDATE groups SET members_require_attention=1 WHERE group_id=? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET send_rcpts = NULL Plan: SCAN groups @@ -6563,13 +7122,13 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ?, conn_link_prepared_connection = ?, updated_at = ? WHERE group_id = ? +Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ?, conn_link_prepared_connection = ?, public_member_count = ?, updated_at = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index e3c25e8fba..801a1b9f0d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -39,6 +39,7 @@ CREATE TABLE users( ui_themes TEXT, active_order INTEGER NOT NULL DEFAULT 0, auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, + is_user_chat_relay INTEGER NOT NULL DEFAULT 0, client_service INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) @@ -122,7 +123,10 @@ CREATE TABLE group_profiles( preferences TEXT, description TEXT NULL, member_admission TEXT, - short_descr TEXT + short_descr TEXT, + group_type TEXT, + group_link BLOB, + public_group_id BLOB ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -157,7 +161,20 @@ CREATE TABLE groups( conn_link_prepared_connection INTEGER NOT NULL DEFAULT 0, via_group_link_uri BLOB, summary_current_members_count INTEGER NOT NULL DEFAULT 0, - member_index INTEGER NOT NULL DEFAULT 0, -- received + member_index INTEGER NOT NULL DEFAULT 0, + use_relays INTEGER NOT NULL DEFAULT 0, + creating_in_progress INTEGER NOT NULL DEFAULT 0, + relay_own_status TEXT, + relay_request_inv_id BLOB, + relay_request_group_link BLOB, + relay_request_peer_chat_min_version INTEGER, + relay_request_peer_chat_max_version INTEGER, + relay_request_failed INTEGER DEFAULT 0, + relay_request_err_reason TEXT, + root_priv_key BLOB, + root_pub_key BLOB, + member_priv_key BLOB, + public_member_count INTEGER, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -199,6 +216,8 @@ CREATE TABLE group_members( member_welcome_shared_msg_id BLOB, index_in_group INTEGER NOT NULL DEFAULT 0, member_relations_vector BLOB, + relay_link BLOB, + member_pub_key BLOB, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -322,6 +341,7 @@ CREATE TABLE connections( short_link_inv BLOB, via_short_link_contact BLOB, via_contact_uri BLOB, + relay_test INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -388,7 +408,9 @@ CREATE TABLE messages( shared_msg_id_user INTEGER, author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, - broker_ts TEXT + broker_ts TEXT, + msg_chat_binding TEXT, + msg_signatures BLOB ) STRICT; CREATE TABLE pending_group_messages( pending_group_message_id INTEGER PRIMARY KEY, @@ -442,7 +464,9 @@ CREATE TABLE chat_items( group_scope_tag TEXT, group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, show_group_as_sender INTEGER NOT NULL DEFAULT 0, - has_link INTEGER NOT NULL DEFAULT 0 + has_link INTEGER NOT NULL DEFAULT 0, + msg_signed TEXT, + item_viewed INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -525,7 +549,7 @@ CREATE TABLE chat_item_versions( ) STRICT; CREATE TABLE chat_item_reactions( chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT, - item_member_id BLOB, -- member that created item, NULL for items in direct chats + item_member_id BLOB, shared_msg_id BLOB NOT NULL, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, @@ -725,6 +749,33 @@ CREATE TABLE connections_sync( should_sync INTEGER NOT NULL DEFAULT 0, last_sync_ts TEXT ) STRICT; +CREATE TABLE chat_relays( + chat_relay_id INTEGER PRIMARY KEY, + address BLOB NOT NULL, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL DEFAULT '', + short_descr TEXT, + image TEXT, + domains TEXT NOT NULL, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE TABLE group_relays( + group_relay_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + chat_relay_id INTEGER NOT NULL REFERENCES chat_relays ON DELETE CASCADE, + relay_status TEXT NOT NULL, + relay_link BLOB, + conf_id BLOB, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1219,6 +1270,28 @@ CREATE INDEX idx_chat_items_note_folder_has_link_created_at ON chat_items( has_link, created_at ); +CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays( + user_id, + address +); +CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays( + group_member_id +); +CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); +CREATE INDEX idx_chat_items_contacts_item_viewed ON chat_items( + user_id, + contact_id, + item_viewed, + created_at +); +CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items( + user_id, + group_id, + item_viewed, + item_ts +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c61bdfb0b6..cf630eae02 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -16,6 +16,7 @@ module Simplex.Chat.Store.Shared where +import Control.Applicative ((<|>)) import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad @@ -75,6 +76,7 @@ data ChatLockEntity data StoreError = SEDuplicateName | SEUserNotFound {userId :: UserId} + | SERelayUserNotFound | SEUserNotFoundByName {contactName :: ContactName} | SEUserNotFoundByContactId {contactId :: ContactId} | SEUserNotFoundByGroupId {groupId :: GroupId} @@ -102,6 +104,7 @@ data StoreError | SEInvalidMemberRelationUpdate | SEGroupWithoutUser | SEDuplicateGroupMember + | SEDuplicateMemberId | SEGroupAlreadyJoined | SEGroupInvitationNotFound | SENoteFolderAlreadyExists {noteFolderId :: NoteFolderId} @@ -150,6 +153,9 @@ data StoreError | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} | SEOperatorNotFound {serverOperatorId :: Int64} | SEUsageConditionsNotFound + | SEUserChatRelayNotFound {chatRelayId :: Int64} + | SEGroupRelayNotFound {groupRelayId :: Int64} + | SEGroupRelayNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEInvalidQuote | SEInvalidMention | SEInvalidDeliveryTask {taskId :: Int64} @@ -533,15 +539,15 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.client_service, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, Maybe UIThemeEntityOverrides) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI clientService, uiThemes)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, clientService = BoolDef clientService, uiThemes} +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) -> User +toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes)) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences @@ -657,22 +663,26 @@ type PreparedGroupRow = (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt, type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow +type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -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) +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe 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, Maybe ShortLinkContact) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences - groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} + publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ + groupKeys = toGroupKeys publicGroupId_ groupKeysRow + groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow - groupSummary = GroupSummary {currentMembers} - in GroupInfo {groupId, useRelays = BoolDef False, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} + groupSummary = GroupSummary {currentMembers, publicMemberCount} + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -680,8 +690,19 @@ toPreparedGroup = \case Just PreparedGroup {connLinkToConnect = CCLink fullLink shortLink_, connLinkPreparedConnection, connLinkStartedConnection, welcomeSharedMsgId, requestSharedMsgId} _ -> Nothing +toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupProfile +toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) = + Just PublicGroupProfile {groupType, groupLink, publicGroupId} +toPublicGroupProfile _ _ _ = Nothing + +toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys +toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) = + (\grk -> GroupKeys {publicGroupId, groupRootKey = grk, memberPrivKey}) + <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) +toGroupKeys _ _ = 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)) = +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_ @@ -708,7 +729,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.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, @@ -738,18 +759,20 @@ groupInfoQueryFields = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, 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.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 @@ -834,6 +857,27 @@ addGroupChatTags db g@GroupInfo {groupId} = do chatTags <- getGroupChatTags db groupId pure (g :: GroupInfo) {chatTags} +getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do + chatTags <- getGroupChatTags db groupId + firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ + DB.query + db + (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (groupId, userId, userContactId) + +setPreparedGroupLinkInfo_ :: DB.Connection -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Int64 -> Maybe Int64 -> UTCTime -> IO () +setPreparedGroupLinkInfo_ db GroupInfo {groupId, membership} cReq cReqHash customUserProfileId publicMemberCount_ currentTs = do + DB.execute + db + "UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ?, conn_link_prepared_connection = ?, public_member_count = ?, updated_at = ? WHERE group_id = ?" + (cReq, cReqHash, BI True, publicMemberCount_, currentTs, groupId) + when (isJust customUserProfileId) $ + DB.execute + db + "UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE group_member_id = ?" + (customUserProfileId, currentTs, groupMemberId' membership) + setViaGroupLinkUri :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkUri db groupId connId = do r <- @@ -855,3 +899,18 @@ setViaGroupLinkUri db groupId connId = do deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO () deleteConnectionRecord db User {userId} cId = do DB.execute db "DELETE FROM connections WHERE user_id = ? AND connection_id = ?" (userId, cId) + +getStaleRelayTestConns :: DB.Connection -> User -> UTCTime -> IO [ConnId] +getStaleRelayTestConns db User {userId} cutoffTs = + map fromOnly <$> + DB.query + db + [sql| + SELECT agent_conn_id FROM connections + WHERE user_id = ? AND relay_test = 1 AND created_at < ? + |] + (userId, cutoffTs) + +deleteConnectionByAgentConnId :: DB.Connection -> User -> ConnId -> IO () +deleteConnectionByAgentConnId db User {userId} acId = + DB.execute db "DELETE FROM connections WHERE user_id = ? AND agent_conn_id = ?" (userId, acId) diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index e432343839..21781229e4 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -15,7 +15,7 @@ import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators -import Simplex.Chat.Operators.Presets (operatorSimpleXChat) +import Simplex.Chat.Operators.Presets (operatorSimpleXChat, simplexChatRelays) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output @@ -50,7 +50,9 @@ terminalChatConfig = ], useSMP = 3, xftp = map (presetServer True) $ L.toList defaultXFTPServers, - useXFTP = 3 + useXFTP = 3, + chatRelays = simplexChatRelays, + useChatRelays = 2 } ], ntf = _defaultNtfServers, diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index b7eebd141a..e0ee10aff9 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -67,6 +67,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do Right r' -> processResp cmd rh r' Left _ -> when (isMessage cmd) $ echo s printRespToTerminal ct cc False rh r + chatResponseNotification ct r mapM_ (startLiveMessage cmd) r where echo s = printToTerminal ct [plain s] @@ -79,7 +80,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo CRChatItemsDeleted u ((ChatItemDeletion (AChatItem _ _ cInfo _) _) : _) _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c - CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g + CRGroupDeletedUser u g _ -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" diff --git a/src/Simplex/Chat/Terminal/Notification.hs b/src/Simplex/Chat/Terminal/Notification.hs index 87bed5be1a..932eae32ef 100644 --- a/src/Simplex/Chat/Terminal/Notification.hs +++ b/src/Simplex/Chat/Terminal/Notification.hs @@ -6,25 +6,21 @@ module Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) where -import Control.Monad (void) import Data.List (isInfixOf) -import Data.Map (Map, fromList) -import qualified Data.Map as M -import Data.Maybe (fromMaybe, isJust) -import Data.Text (Text) +import Data.Maybe (isJust) import qualified Data.Text as T import Simplex.Messaging.Util (catchAll_) import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory) import System.FilePath (combine) import System.Info (os) -import System.Process (readCreateProcess, shell) +import System.Process (callProcess) -data Notification = Notification {title :: Text, text :: Text} +data Notification = Notification {title :: T.Text, text :: T.Text} initializeNotifications :: IO (Notification -> IO ()) initializeNotifications = hideException <$> case os of - "darwin" -> pure $ notify macScript + "darwin" -> pure macNotify "mingw32" -> initWinNotify "linux" -> doesFileExist "/proc/sys/kernel/osrelease" >>= \case @@ -45,44 +41,36 @@ hideException f a = f a `catchAll_` pure () initLinuxNotify :: IO (Notification -> IO ()) initLinuxNotify = do found <- isJust <$> findExecutable "notify-send" - pure $ if found then notify linuxScript else noNotifications + pure $ if found then linuxNotify else noNotifications -notify :: (Notification -> Text) -> Notification -> IO () -notify script notification = - void $ readCreateProcess (shell . T.unpack $ script notification) "" +linuxNotify :: Notification -> IO () +linuxNotify Notification {title, text} = + callProcess "notify-send" [T.unpack title, T.unpack text] -linuxScript :: Notification -> Text -linuxScript Notification {title, text} = "notify-send '" <> linuxEscape title <> "' '" <> linuxEscape text <> "'" +macNotify :: Notification -> IO () +macNotify Notification {title, text} = + callProcess "osascript" ["-e", "display notification \"" <> macEscape text <> "\" with title \"" <> macEscape title <> "\""] -linuxEscape :: Text -> Text -linuxEscape = replaceAll $ fromList [('\'', "'\\''")] - -macScript :: Notification -> Text -macScript Notification {title, text} = "osascript -e 'display notification \"" <> macEscape text <> "\" with title \"" <> macEscape title <> "\"'" - -macEscape :: Text -> Text -macEscape = replaceAll $ fromList [('"', "\\\""), ('\'', "")] +macEscape :: T.Text -> String +macEscape = concatMap esc . T.unpack + where + esc '\\' = "\\\\" + esc '"' = "\\\"" + esc c = [c] initWslNotify :: IO (Notification -> IO ()) -initWslNotify = notify . wslScript <$> savePowershellScript +initWslNotify = wslNotify <$> savePowershellScript -wslScript :: FilePath -> Notification -> Text -wslScript path Notification {title, text} = "powershell.exe \"" <> T.pack path <> " \\\"" <> wslEscape title <> "\\\" \\\"" <> wslEscape text <> "\\\"\"" - -wslEscape :: Text -> Text -wslEscape = replaceAll $ fromList [('`', "\\`\\`"), ('\\', "\\\\"), ('"', "\\`\\\"")] +wslNotify :: FilePath -> Notification -> IO () +wslNotify path Notification {title, text} = + callProcess "powershell.exe" ["-File", path, T.unpack title, T.unpack text] initWinNotify :: IO (Notification -> IO ()) -initWinNotify = notify . winScript <$> savePowershellScript +initWinNotify = winNotify <$> savePowershellScript -winScript :: FilePath -> Notification -> Text -winScript path Notification {title, text} = "powershell.exe \"" <> T.pack path <> " '" <> winRemoveQuotes title <> "' '" <> winRemoveQuotes text <> "'\"" - -winRemoveQuotes :: Text -> Text -winRemoveQuotes = replaceAll $ fromList [('`', ""), ('\'', ""), ('"', "")] - -replaceAll :: Map Char Text -> Text -> Text -replaceAll rules = T.concatMap $ \c -> T.singleton c `fromMaybe` M.lookup c rules +winNotify :: FilePath -> Notification -> IO () +winNotify path Notification {title, text} = + callProcess "powershell.exe" ["-File", path, T.unpack title, T.unpack text] savePowershellScript :: IO FilePath savePowershellScript = do diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index ca591903ff..03f644e641 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -180,7 +180,8 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) - (GroupChat g scopeInfo, CIGroupRcv m) -> sendNtf (fromGroup_ g scopeInfo m, text) + (GroupChat g scopeInfo, CIGroupRcv m) -> sendNtf (fromGroup_ g scopeInfo (Just m), text) + (GroupChat g scopeInfo, CIChannelRcv) -> sendNtf (fromGroup_ g scopeInfo Nothing, text) _ -> pure () where text = msgText mc formattedText @@ -208,10 +209,22 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") CEvtReceivedContactRequest u UserContactRequest {localDisplayName = n} _ -> when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") + CEvtDeletedMemberUser _u g m _withMessages _signed -> + sendNtf ("#" <> viewGroupName g, viewMemberName m <> " removed you from the group") _ -> pure () where sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification +chatResponseNotification :: ChatTerminal -> Either ChatError ChatResponse -> IO () +chatResponseNotification ChatTerminal {sendNotification} = \case + Right r -> case r of + CRUserContactLinkCreated {} -> sendNtf ("SimpleX", "contact link created") + CRUserProfileUpdated {} -> sendNtf ("SimpleX", "profile updated") + _ -> pure () + Left _ -> pure () + where + sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification + msgText :: MsgContent -> Maybe MarkdownList -> Text msgText (MCFile _) _ = "wants to send a file" msgText mc md_ = maybe (msgContentText mc) (mconcat . map hideSecret) md_ diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index aaef72eada..8611fa5d73 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -54,8 +54,10 @@ import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink, ConnectionLink, ConnectionMode (..), ConnectionRequestUri, CreatedConnLink, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8) @@ -134,6 +136,7 @@ data User = User sendRcptsSmallGroups :: Bool, autoAcceptMemberContacts :: Bool, userMemberProfileUpdatedAt :: Maybe UTCTime, + userChatRelay :: BoolDef, clientService :: BoolDef, uiThemes :: Maybe UIThemeEntityOverrides } @@ -142,13 +145,14 @@ data User = User data NewUser = NewUser { profile :: Maybe Profile, pastTimestamp :: Bool, + userChatRelay :: BoolDef, clientService :: BoolDef } deriving (Show) newtype B64UrlByteString = B64UrlByteString ByteString deriving (Eq, Show) - deriving newtype (FromField) + deriving newtype (FromField, Encoding) instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField $ Binary m @@ -446,9 +450,26 @@ data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} type GroupId = Int64 +data GroupRootKey + = GRKPrivate {rootPrivKey :: C.PrivateKeyEd25519} + | GRKPublic {rootPubKey :: C.PublicKeyEd25519} + deriving (Eq, Show) + +groupRootPubKey :: GroupRootKey -> C.PublicKeyEd25519 +groupRootPubKey (GRKPrivate pk) = C.publicKey pk +groupRootPubKey (GRKPublic pk) = pk + +data GroupKeys = GroupKeys + { publicGroupId :: B64UrlByteString, + groupRootKey :: GroupRootKey, + memberPrivKey :: C.PrivateKeyEd25519 + } + deriving (Eq, Show) + data GroupInfo = GroupInfo { groupId :: GroupId, useRelays :: BoolDef, + relayOwnStatus :: Maybe RelayStatus, -- status of the relay itself related to the group localDisplayName :: GroupName, groupProfile :: GroupProfile, localAlias :: Text, @@ -467,10 +488,20 @@ data GroupInfo = GroupInfo customData :: Maybe CustomData, groupSummary :: GroupSummary, membersRequireAttention :: Int, - viaGroupLinkUri :: Maybe ConnReqContact + viaGroupLinkUri :: Maybe ConnReqContact, + groupKeys :: Maybe GroupKeys } deriving (Eq, Show) +useRelays' :: GroupInfo -> Bool +useRelays' GroupInfo {useRelays} = isTrue useRelays + +sendAsGroup' :: GroupInfo -> Bool +sendAsGroup' gInfo@GroupInfo {membership} = useRelays' gInfo && memberRole' membership == GROwner + +groupId' :: GroupInfo -> GroupId +groupId' GroupInfo {groupId} = groupId + data BusinessChatType = BCBusiness -- used on the customer side | BCCustomer -- used on the business side @@ -502,7 +533,8 @@ groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary - { currentMembers :: Int64 + { currentMembers :: Int64, + publicMemberCount :: Maybe Int64 } deriving (Eq, Show) @@ -725,12 +757,37 @@ fromLocalProfile :: LocalProfile -> Profile fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} = Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +data GroupType + = GTChannel + | GTUnknown Text + deriving (Eq, Show) + +instance TextEncoding GroupType where + textEncode = \case + GTChannel -> "channel" + GTUnknown tag -> tag + textDecode s = Just $ case s of + "channel" -> GTChannel + tag -> GTUnknown tag + +instance FromField GroupType where fromField = fromTextField_ textDecode + +instance ToField GroupType where toField = toField . textEncode + +data PublicGroupProfile = PublicGroupProfile + { groupType :: GroupType, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString -- group identity = sha256(genesis root key), immutable + } + deriving (Eq, Show) + data GroupProfile = GroupProfile { displayName :: GroupName, fullName :: Text, shortDescr :: Maybe Text, -- short description limited to 160 characters description :: Maybe Text, -- this has been repurposed as welcome message image :: Maybe ImageData, + publicGroup :: Maybe PublicGroupProfile, groupPreferences :: Maybe GroupPreferences, memberAdmission :: Maybe GroupMemberAdmission } @@ -813,6 +870,15 @@ data GroupLinkRejection = GroupLinkRejection } deriving (Eq, Show) +-- sent by owner to relay when adding it to group +data GroupRelayInvitation = GroupRelayInvitation + { fromMember :: MemberIdRole, + fromMemberProfile :: Profile, + relayMemberId :: MemberId, + groupLink :: ShortLinkContact + } + deriving (Eq, Show) + data GroupRejectionReason = GRRLongName | GRRBlockedName @@ -852,11 +918,23 @@ data IntroInvitation = IntroInvitation } deriving (Eq, Show) +newtype MemberKey = MemberKey C.PublicKeyEd25519 + deriving (Eq, Show) + deriving newtype (StrEncoding) + +instance FromJSON MemberKey where + parseJSON = strParseJSON "MemberKey" + +instance ToJSON MemberKey where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberInfo = MemberInfo { memberId :: MemberId, memberRole :: GroupMemberRole, v :: Maybe ChatVersionRange, - profile :: Profile + profile :: Profile, + memberKey :: Maybe MemberKey } deriving (Eq, Show) @@ -948,7 +1026,16 @@ data GroupMember = GroupMember memberChatVRange :: VersionRangeChat, createdAt :: UTCTime, updatedAt :: UTCTime, - supportChat :: Maybe GroupSupportChat + supportChat :: Maybe GroupSupportChat, + memberPubKey :: Maybe C.PublicKeyEd25519, + relayLink :: Maybe ShortLinkContact + } + deriving (Eq, Show) + +data RelayRequestData = RelayRequestData + { relayInvId :: InvitationId, + reqGroupLink :: ShortLinkContact, + reqChatVRange :: VersionRangeChat } deriving (Eq, Show) @@ -975,10 +1062,11 @@ groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = GroupMemberRef {groupMemberId, profile = fromLocalProfile p} --- TODO [channels fwd] knowledge whether member is a relay should come from protocol, not implicitly via role --- TODO - in channels members should directly connect only to relays -isMemberRelay :: GroupMember -> Bool -isMemberRelay GroupMember {memberRole} = memberRole == GRAdmin +isRelay :: GroupMember -> Bool +isRelay m = memberRole' m == GRRelay + +memberRole' :: GroupMember -> GroupMemberRole +memberRole' GroupMember {memberRole} = memberRole memberConn :: GroupMember -> Maybe Connection memberConn GroupMember {activeConn} = activeConn @@ -1035,7 +1123,7 @@ data NewGroupMember = NewGroupMember newtype MemberId = MemberId {unMemberId :: ByteString} deriving (Eq, Ord, Show) - deriving newtype (FromField) + deriving newtype (Encoding, FromField) instance ToField MemberId where toField (MemberId m) = toField $ Binary m @@ -1052,7 +1140,10 @@ instance ToJSON MemberId where toEncoding = strToJEncoding nameFromMemberId :: MemberId -> ContactName -nameFromMemberId = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId +nameFromMemberId = nameFromBS . unMemberId + +nameFromBS :: ByteString -> ContactName +nameFromBS = T.take 7 . safeDecodeUtf8 . B64.encode data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show) @@ -1789,6 +1880,9 @@ data CommandFunction | CFAcceptContact | CFAckMessage -- not used | CFDeleteConn -- not used + | CFSetShortLink + | CFGetRelayDataJoin + | CFGetRelayDataAccept deriving (Eq, Show) instance FromField CommandFunction where fromField = fromTextField_ textDecode @@ -1806,6 +1900,9 @@ instance TextEncoding CommandFunction where "accept_contact" -> Just CFAcceptContact "ack_message" -> Just CFAckMessage "delete_conn" -> Just CFDeleteConn + "set_short_link" -> Just CFSetShortLink + "get_relay_data_join" -> Just CFGetRelayDataJoin + "get_relay_data_accept" -> Just CFGetRelayDataAccept _ -> Nothing textEncode = \case CFCreateConnGrpMemInv -> "create_conn" @@ -1817,6 +1914,9 @@ instance TextEncoding CommandFunction where CFAcceptContact -> "accept_contact" CFAckMessage -> "ack_message" CFDeleteConn -> "delete_conn" + CFSetShortLink -> "set_short_link" + CFGetRelayDataJoin -> "get_relay_data_join" + CFGetRelayDataAccept -> "get_relay_data_accept" commandExpectedResponse :: CommandFunction -> AEvtTag commandExpectedResponse = \case @@ -1829,6 +1929,9 @@ commandExpectedResponse = \case CFAcceptContact -> t JOINED_ CFAckMessage -> t OK_ CFDeleteConn -> t OK_ + CFSetShortLink -> t LINK_ + CFGetRelayDataJoin -> t LDATA_ + CFGetRelayDataAccept -> t LDATA_ where t = AEvtTag SAEConn @@ -1931,6 +2034,15 @@ instance ToField GroupMemberAdmission where instance FromField GroupMemberAdmission where fromField = fromTextField_ decodeJSON +instance FromJSON GroupType where + parseJSON = textParseJSON "GroupType" + +instance ToJSON GroupType where + toJSON = textToJSON + toEncoding = textToEncoding + +$(JQ.deriveJSON defaultJSON ''PublicGroupProfile) + $(JQ.deriveJSON defaultJSON ''GroupProfile) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "IB") ''InvitedBy) @@ -1963,7 +2075,11 @@ $(JQ.deriveToJSON defaultJSON ''GroupSummary) instance FromJSON GroupSummary where parseJSON = $(JQ.mkParseJSON defaultJSON ''GroupSummary) - omittedField = Just GroupSummary {currentMembers = 0} + omittedField = Just GroupSummary {currentMembers = 0, publicMemberCount = Nothing} + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GRK") ''GroupRootKey) + +$(JQ.deriveJSON defaultJSON ''GroupKeys) $(JQ.deriveJSON defaultJSON ''GroupInfo) @@ -1987,6 +2103,8 @@ $(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) $(JQ.deriveJSON defaultJSON ''GroupLinkRejection) +$(JQ.deriveJSON defaultJSON ''GroupRelayInvitation) + $(JQ.deriveJSON defaultJSON ''IntroInvitation) $(JQ.deriveJSON defaultJSON ''MemberRestrictions) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index 280fc32ea4..22cb73f325 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -1,18 +1,24 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON) import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole - = GRObserver -- connects to all group members and receives all messages, can't send messages + = GRUnknown Text -- unknown role from a newer client + | GRRelay -- chat relay: forwards messages, can't send its own messages + | GRObserver -- connects to all group members and receives all messages, can't send messages | GRAuthor -- reserved, unused | GRMember -- + can send messages to all group members | GRModerator -- + moderate messages and block members (excl. Admins and Owners) @@ -32,14 +38,17 @@ instance TextEncoding GroupMemberRole where GRMember -> "member" GRAuthor -> "author" GRObserver -> "observer" - textDecode = \case - "owner" -> Just GROwner - "admin" -> Just GRAdmin - "moderator" -> Just GRModerator - "member" -> Just GRMember - "author" -> Just GRAuthor - "observer" -> Just GRObserver - r -> Nothing + GRRelay -> "relay" + GRUnknown t -> t + textDecode = Just . \case + "owner" -> GROwner + "admin" -> GRAdmin + "moderator" -> GRModerator + "member" -> GRMember + "author" -> GRAuthor + "observer" -> GRObserver + "relay" -> GRRelay + t -> GRUnknown t instance FromJSON GroupMemberRole where parseJSON = textParseJSON "GroupMemberRole" @@ -68,3 +77,54 @@ 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 + +$(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) + +data MsgSigStatus = MSSVerified | MSSSignedNoKey + deriving (Eq, Show) + +instance TextEncoding MsgSigStatus where + textEncode = \case + MSSVerified -> "verified" + MSSSignedNoKey -> "no_key" + textDecode = \case + "verified" -> Just MSSVerified + "no_key" -> Just MSSSignedNoKey + _ -> Nothing + +instance ToField MsgSigStatus where toField = toField . textEncode + +instance FromField MsgSigStatus where fromField = fromTextField_ textDecode + +$(JQ.deriveJSON (enumJSON $ dropPrefix "MSS") ''MsgSigStatus) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c83ce6d2f7..f522c27a1a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -123,8 +123,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRChatContentTypes cts -> [plain $ "Chat content types: " <> T.intercalate ", " (map (safeDecodeUtf8 . strEncode) cts)] - CRChatTags u tags -> ttyUser u $ [viewJSON tags] + CRChatTags u tags -> ttyUser u [viewJSON tags] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure + CRChatRelayTestResult u relayProfile_ relayTestFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ relayTestFailure_ CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) CRUserServersValidation {} -> [] @@ -178,6 +179,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRUserContactLinkUpdated u UserContactLink {addressSettings} -> ttyUser u $ viewAddressSettings addressSettings 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 [] @@ -203,7 +206,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"] @@ -213,11 +216,11 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRUserContactLinkCreated u ccLink -> ttyUser u $ connReqContact_ "Your new chat address is created!" ccLink CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMembers u g members wm -> case members of - [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm] - mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm] + CRUserDeletedMembers u g members wm signed -> case members of + [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm <> signedStr signed] + mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm <> signedStr signed] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] + CRGroupDeletedUser u g signed -> ttyUser u [ttyGroup' g <> ": you deleted the group" <> signedStr signed] CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft @@ -236,9 +239,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m CRMemberSupportChatRead u g m -> ttyUser u $ viewSupportChatRead g m CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] - CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' - CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked - CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m + CRMembersRoleUser u g members r' signed -> ttyUser u $ viewMemberRoleUserChanged g members r' signed + CRMembersBlockedForAllUser u g members blocked signed -> ttyUser u $ viewMembersBlockedForAllUser g members blocked signed + CRGroupUpdated u g g' m signed -> ttyUser u $ viewGroupUpdated g g' m (if signed then Just MSSVerified else Nothing) CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g CRGroupLinkCreated u g gLink -> ttyUser u $ groupLink_ "Group link is created!" g gLink @@ -356,9 +359,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte Just CIFile {fileSource = Just (CryptoFile fp _)} -> Just fp _ -> Nothing testViewItem :: CChatItem c -> Maybe GroupMember -> Text - testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText}}) membership_ = + testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText, msgSigned}}) membership_ = let deleted_ = maybe "" (\t -> " [" <> t <> "]") (chatItemDeletedText ci membership_) - in itemText <> deleted_ + in itemText <> sigStatusStr msgSigned <> deleted_ unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] @@ -370,6 +373,15 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte | otherwise = [] withMessages wm = if wm then " with all messages" else "" +sigStatusStr :: IsString a => Maybe MsgSigStatus -> a +sigStatusStr = \case + Just MSSVerified -> " (signed)" + Just MSSSignedNoKey -> " (signed, no key to verify)" + Nothing -> "" + +signedStr :: IsString a => Bool -> a +signedStr signed = if signed then " (signed)" else "" + ttyUserPrefix :: (Maybe RemoteHostId, Maybe User) -> Maybe RemoteHostId -> User -> [StyledString] -> [StyledString] ttyUserPrefix _ _ _ [] = [] ttyUserPrefix (currentRH, user_) outputRH User {userId, localDisplayName = u} ss @@ -417,7 +429,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g CEvtContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] CEvtBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] - CEvtGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CEvtGroupLinkConnecting u g m -> ttyUser u $ viewUserJoiningGroup g m CEvtBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] @@ -460,20 +472,24 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> tshow (length conns) <> " connections on server " <> showSMPServer srv] CEvtServiceSubStatus srv event -> [plain $ serviceSubEventStr srv event] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r - CEvtUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g + CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m + CEvtGroupLinkDataUpdated u g groupLink relays relaysChanged + | relaysChanged -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays + | otherwise -> [] + CEvtGroupRelayUpdated {} -> [] CEvtJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CEvtHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CEvtHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] CEvtJoinedGroupMemberConnecting u g host m -> ttyUser u $ viewJoinedGroupMemberConnecting g host m CEvtConnectedToGroupMember u g m _ -> ttyUser u $ viewConnectedToGroupMember g m CEvtMemberAcceptedByOther u g acceptingMember m -> ttyUser u $ viewMemberAcceptedByOther g acceptingMember m - CEvtMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' - CEvtMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked - CEvtDeletedMemberUser u g by wm -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm] <> groupPreserved g - CEvtDeletedMember u g by m wm -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm] - CEvtLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CEvtGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] - CEvtGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m + CEvtMemberRole u g by m r r' signed -> ttyUser u $ viewMemberRoleChanged g by m r r' signed + CEvtMemberBlockedForAll u g by m blocked signed -> ttyUser u $ viewMemberBlockedForAll g by m blocked signed + CEvtDeletedMemberUser u g by wm signed -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm <> sigStatusStr signed] <> groupPreserved g + CEvtDeletedMember u g by m wm signed -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm <> sigStatusStr signed] + CEvtLeftMember u g m signed -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group" <> sigStatusStr signed] + CEvtGroupDeleted u g m signed -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group" <> sigStatusStr signed, "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] + CEvtGroupUpdated u g g' m signed -> ttyUser u $ viewGroupUpdated g g' m signed CEvtAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CEvtNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CEvtNewMemberContactReceivedInv u ct g m -> ttyUser u $ viewNewMemberContactReceivedInv u ct g m @@ -551,6 +567,7 @@ chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention (GroupChat g _scopeInfo, CIGroupRcv m) -> groupNtf user g mention && not (memberBlocked m) + (GroupChat g _scopeInfo, CIChannelRcv) -> groupNtf user g mention _ -> True contactNtf :: User -> Contact -> Bool -> Bool @@ -642,7 +659,7 @@ viewChatItems ttyUser unmuted u chatItems ts tz | otherwise = ttyUser u [sShow (length chatItems) <> " new messages created"] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention}, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention, msgSigned}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI where viewCI = case chat of @@ -673,16 +690,18 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa _ -> showSndItem to where to = ttyToGroup g scopeInfo - CIGroupRcv m -> case content of - CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc - CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta - CIRcvGroupInvitation {} -> showRcvItemProhibited from - CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False - CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False - _ -> showRcvItem from - where - from = ttyFromGroupAttention g scopeInfo m userMention + CIGroupRcv m -> rcvGroupItem (Just m) + CIChannelRcv -> rcvGroupItem Nothing where + rcvGroupItem m_ = case content of + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc + CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvGroupInvitation {} | isJust m_ -> showRcvItemProhibited from + CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False + CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False + _ -> showRcvItem from + where + from = ttyFromGroupAttention g scopeInfo m_ userMention context = maybe (maybe [] forwardedFrom itemForwarded) @@ -723,8 +742,8 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa ("", Just _, []) -> [] ("", Just CIFile {fileName}, _) -> view dir context (MCText $ T.pack fileName) ts tz meta _ -> view dir context mc ts tz meta - showSndItem to = showItem $ sentWithTime_ ts tz [to <> plainContent content] meta - showRcvItem from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content] False + showSndItem to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> sigStatusStr msgSigned] meta + showRcvItem from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content <> sigStatusStr msgSigned] False showSndItemProhibited to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> " " <> prohibited] meta showRcvItemProhibited from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content <> " " <> prohibited] False showItem ss = if doShow then ss else [] @@ -813,19 +832,22 @@ viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, itemEd (directQuote chatDir) quotedItem GroupChat g scopeInfo -> case chatDir of - CIGroupRcv m -> case content of - CIRcvMsgContent mc - | itemLive == Just True && not liveItems -> [] - | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta - _ -> [] - where - from = if itemEdited then ttyFromGroupEdited g scopeInfo m else ttyFromGroup g scopeInfo m + CIGroupRcv m -> updGroupItem (Just m) + CIChannelRcv -> updGroupItem Nothing CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ viewSentMessage to context mc ts tz meta _ -> [] where to = if itemEdited then ttyToGroupEdited g scopeInfo else ttyToGroup g scopeInfo where + updGroupItem :: Maybe GroupMember -> [StyledString] + updGroupItem m_ = case content of + CIRcvMsgContent mc + | itemLive == Just True && not liveItems -> [] + | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta + _ -> [] + where + from = if itemEdited then ttyFromGroupEdited g scopeInfo m_ else ttyFromGroup g scopeInfo m_ context = maybe (maybe [] forwardedFrom itemForwarded) @@ -881,7 +903,7 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem prohibited = [styled (colored Red) ("[unexpected message deletion, please report to developers]" :: String)] viewItemReaction :: forall c d. Bool -> ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz = +viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content, meta = CIMeta {showGroupAsSender}}, sentAt, reaction} added ts tz = case (chat, chatDir) of (DirectChat c, CIDirectRcv) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc @@ -889,12 +911,8 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md where from = ttyFromContact c reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">" - (GroupChat g scopeInfo, CIGroupRcv m) -> case ciMsgContent content of - Just mc -> view from $ reactionMsg mc - _ -> [] - where - from = ttyFromGroup g scopeInfo m - reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir + (GroupChat g scopeInfo, CIGroupRcv m) -> groupReaction g scopeInfo (Just m) (sentByMember' g itemDir) + (GroupChat g scopeInfo, CIChannelRcv) -> groupReaction g scopeInfo Nothing (sentByMember' g itemDir) (LocalChat _, CILocalRcv) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc _ -> [] @@ -906,6 +924,13 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md (_, CILocalSnd) -> [sentText] (CInfoInvalidJSON {}, _) -> [] where + groupReaction g scopeInfo m_ sentBy = case ciMsgContent content of + Just mc -> view from $ reactionMsg mc + _ -> [] + where + from = ttyFromGroup g scopeInfo m_ + reactionMsg mc = quoteText mc . ttyQuotedMember $ + if showGroupAsSender then Nothing else sentBy view from msg | showReactions = viewReceivedReaction from msg reactionText ts tz sentAt | otherwise = [] @@ -946,10 +971,11 @@ sentByMember GroupInfo {membership} = \case CIQGroupSnd -> Just membership CIQGroupRcv m -> m -sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> GroupMember +sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> Maybe GroupMember sentByMember' GroupInfo {membership} = \case - CIGroupSnd -> membership - CIGroupRcv m -> m + CIGroupSnd -> Just membership + CIGroupRcv m -> Just m + CIChannelRcv -> Nothing quoteText :: MsgContent -> StyledString -> [StyledString] quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc @@ -1151,6 +1177,27 @@ 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:"] + <> map showRelay relays + <> + [ "group link:", + plain $ maybe cReqStr strEncode shortLink + ] + where + GroupLink {connLinkContact = CCLink cReq shortLink} = groupLink + cReqStr = strEncode $ simplexChatContact cReq + viewGroupCreated :: GroupInfo -> Bool -> [StyledString] viewGroupCreated g testView = case incognitoMembershipProfile g of @@ -1161,12 +1208,21 @@ viewGroupCreated g testView = profile = fromLocalProfile localProfile message = [ "group " <> ttyFullGroup g <> " is created, your incognito profile for this group is " <> incognitoProfile' profile, - "to add members use " <> highlight ("/create link #" <> viewGroupName g) + instruction ] + instruction + | useRelays' g = relaysInstruction + | otherwise = "to add members use " <> highlight ("/create link #" <> viewGroupName g) Nothing -> [ "group " <> ttyFullGroup g <> " is created", - "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + instruction ] + where + instruction + | useRelays' g = relaysInstruction + | otherwise = "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + where + relaysInstruction = "wait for selected relay(s) to join, then you can invite members via group link" viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation g c = @@ -1178,12 +1234,22 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] -viewUserJoinedGroup :: GroupInfo -> [StyledString] -viewUserJoinedGroup g@GroupInfo {membership} = - case incognitoMembershipProfile g of - Just mp -> [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile mp) <> pendingApproval_] - Nothing -> [ttyGroup' g <> ": you joined the group" <> pendingApproval_] +viewUserJoiningGroup :: GroupInfo -> GroupMember -> [StyledString] +viewUserJoiningGroup g m + | isRelay m = [ttyGroup' g <> ": joining the group (connecting to relay " <> ttyMember m <> ")..."] + | otherwise = [ttyGroup' g <> ": joining the group..."] + +viewUserJoinedGroup :: GroupInfo -> GroupMember -> [StyledString] +viewUserJoinedGroup g@GroupInfo {membership} m + | isRelay membership = [ttyGroup' g <> ": you joined the group as relay"] + | otherwise = + case incognitoMembershipProfile g of + Just mp -> [ttyGroup' g <> ": you joined the group" <> connectedToRelay_ <> " incognito as " <> incognitoProfile' (fromLocalProfile mp) <> pendingApproval_] + Nothing -> [ttyGroup' g <> ": you joined the group" <> connectedToRelay_ <> pendingApproval_] where + connectedToRelay_ + | isRelay m = " (connected to relay " <> ttyMember m <> ")" + | otherwise = "" pendingApproval_ = case memberStatus membership of GSMemPendingApproval -> ", pending approval" GSMemPendingReview -> ", connecting to group moderators for admission to group" @@ -1222,7 +1288,8 @@ viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupM [ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ") <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") ] - _ -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group"] + | otherwise -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString] viewConnectedToGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, memberStatus} = case memberStatus of @@ -1248,29 +1315,29 @@ connectedMember m = case memberCategory m of GCPostMember -> "new member " <> ttyMember m -- without fullName as as it was shown in joinedGroupMemberConnecting _ -> "member " <> ttyMember m -- these case is not used -viewMemberRoleChanged :: GroupInfo -> GroupMember -> GroupMember -> GroupMemberRole -> GroupMemberRole -> [StyledString] -viewMemberRoleChanged g@GroupInfo {membership} by m r r' +viewMemberRoleChanged :: GroupInfo -> GroupMember -> GroupMember -> GroupMemberRole -> GroupMemberRole -> Maybe MsgSigStatus -> [StyledString] +viewMemberRoleChanged g@GroupInfo {membership} by m r r' signed | r == r' = [ttyGroup' g <> ": member role did not change"] | groupMemberId' membership == memId = view "your role" | groupMemberId' by == memId = view "the role" | otherwise = view $ "the role of " <> ttyMember m where memId = groupMemberId' m - view s = [ttyGroup' g <> ": " <> ttyMember by <> " changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] + view s = [ttyGroup' g <> ": " <> ttyMember by <> " changed " <> s <> " from " <> showRole r <> " to " <> showRole r' <> sigStatusStr signed] -viewMemberRoleUserChanged :: GroupInfo -> [GroupMember] -> GroupMemberRole -> [StyledString] -viewMemberRoleUserChanged g members r = case members of - [m] -> [ttyGroup' g <> ": you changed the role of " <> ttyMember m <> " to " <> showRole r] - mems' -> [ttyGroup' g <> ": you changed the role of " <> sShow (length mems') <> " members to " <> showRole r] +viewMemberRoleUserChanged :: GroupInfo -> [GroupMember] -> GroupMemberRole -> Bool -> [StyledString] +viewMemberRoleUserChanged g members r signed = case members of + [m] -> [ttyGroup' g <> ": you changed the role of " <> ttyMember m <> " to " <> showRole r <> signedStr signed] + mems' -> [ttyGroup' g <> ": you changed the role of " <> sShow (length mems') <> " members to " <> showRole r <> signedStr signed] -viewMemberBlockedForAll :: GroupInfo -> GroupMember -> GroupMember -> Bool -> [StyledString] -viewMemberBlockedForAll g by m blocked = - [ttyGroup' g <> ": " <> ttyMember by <> " " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] +viewMemberBlockedForAll :: GroupInfo -> GroupMember -> GroupMember -> Bool -> Maybe MsgSigStatus -> [StyledString] +viewMemberBlockedForAll g by m blocked signed = + [ttyGroup' g <> ": " <> ttyMember by <> " " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m <> sigStatusStr signed] -viewMembersBlockedForAllUser :: GroupInfo -> [GroupMember] -> Bool -> [StyledString] -viewMembersBlockedForAllUser g members blocked = case members of - [m] -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] - mems' -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> sShow (length mems') <> " members"] +viewMembersBlockedForAllUser :: GroupInfo -> [GroupMember] -> Bool -> Bool -> [StyledString] +viewMembersBlockedForAllUser g members blocked signed = case members of + [m] -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m <> signedStr signed] + mems' -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> sShow (length mems') <> " members" <> signedStr signed] showRole :: GroupMemberRole -> StyledString showRole = plain . textEncode @@ -1489,11 +1556,12 @@ serviceSubEventStr srv = \case srvStr = " on server " <> showSMPServer srv viewUserServers :: UserOperatorServers -> [StyledString] -viewUserServers (UserOperatorServers _ [] []) = [] -viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = +viewUserServers (UserOperatorServers _ [] [] []) = [] +viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = [plain $ maybe "Your servers" shortViewOperator operator] <> viewServers SPSMP smpServers <> viewServers SPXFTP xftpServers + <> viewChatRelays chatRelays where viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString] viewServers _ [] = [] @@ -1516,6 +1584,19 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = | otherwise = "disabled (servers known)" where rs = operatorRoles p op + viewChatRelays :: [UserChatRelay] -> [StyledString] + viewChatRelays [] = [] + viewChatRelays cRelays + | maybe True (\ServerOperator {enabled} -> enabled) operator = + [" Chat relays"] <> map (plain . (" " <>) . viewChatRelay) cRelays + | otherwise = [] + where + viewChatRelay UserChatRelay {relayProfile = RelayProfile {displayName, fullName, shortDescr}, address, preset, tested, enabled} = displayName <> optionalFullName displayName fullName shortDescr <> relayAddress <> relayInfo + where + relayAddress = ": " <> safeDecodeUtf8 (strEncode address) + relayInfo = if null relayInfo_ then "" else parens $ T.intercalate ", " relayInfo_ + relayInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] + testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested serversUserHelp :: [StyledString] serversUserHelp = @@ -1545,6 +1626,14 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case where pName = protocolName p +viewRelayTestResult :: Maybe RelayProfile -> Maybe RelayTestFailure -> [StyledString] +viewRelayTestResult relayProfile_ = \case + Just RelayTestFailure {rtfStep, rtfError} -> + ["relay test failed at " <> plain (show rtfStep) <> ", error: " <> plain (show rtfError)] + Nothing -> case relayProfile_ of + Just RelayProfile {displayName, fullName, shortDescr} -> ["relay test passed, profile: " <> ttyFullName displayName fullName shortDescr] + Nothing -> ["relay test passed"] + viewServerOperators :: [ServerOperator] -> Maybe UsageConditionsAction -> [StyledString] viewServerOperators ops ca = map (plain . viewOperator) ops <> maybe [] viewConditionsAction ca @@ -1649,12 +1738,16 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta <> viewCustomData customData viewGroupInfo :: GroupInfo -> [StyledString] -viewGroupInfo GroupInfo {groupId, uiThemes, customData, groupSummary = s} = +viewGroupInfo gInfo@GroupInfo {groupId, uiThemes, customData, groupSummary = GroupSummary {currentMembers, publicMemberCount}} = [ "group ID: " <> sShow groupId, - "current members: " <> sShow (currentMembers s) + memberCountLine ] <> viewUITheme uiThemes <> viewCustomData customData + where + memberCountLine + | useRelays' gInfo, Just count <- publicMemberCount = "subscribers: " <> sShow count + | otherwise = "current members: " <> sShow currentMembers viewUITheme :: Maybe UIThemeEntityOverrides -> [StyledString] viewUITheme = maybe [] (\uiThemes -> ["UI themes: " <> viewJSON uiThemes]) @@ -1829,17 +1922,17 @@ countactUserPrefText cup = case cup of CUPUser p -> "default (" <> preferenceText p <> ")" CUPContact p -> preferenceText p -viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString] +viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString] viewGroupUpdated GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma}} g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} - m = do + m signed = do let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated if null update then [] else memberUpdated <> update where - memberUpdated = maybe [] (\m' -> [ttyMember m' <> " updated group " <> ttyGroup n <> ":"]) m + memberUpdated = maybe [] (\m' -> [ttyMember m' <> " updated group " <> ttyGroup n <> ":" <> sigStatusStr signed]) m groupProfileUpdated = ["changed to " <> ttyFullGroup g' | n /= n'] <> ["full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to: " <> plain fullName' | n == n' && fullName /= fullName'] @@ -1988,7 +2081,10 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("contact address: " <>) CPGroupLink glp -> case glp of - GLPOk groupSLinkData -> [grpLink "ok to connect"] <> [viewJSON groupSLinkData | testView] + GLPOk groupSLinkInfo_ groupSLinkData -> + let direct = maybe True (\(GroupShortLinkInfo {direct = d}) -> d) groupSLinkInfo_ + in [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"] + <> [viewJSON groupSLinkData | testView] GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] GLPConnectingConfirmReconnect -> [grpLink "connecting, allowed to reconnect"] GLPConnectingProhibit Nothing -> [grpLink "connecting"] @@ -2230,6 +2326,7 @@ cryptoFileArgsStr testView cfArgs@(CFArgs key nonce) fileFrom :: ChatInfo c -> CIDirection c d -> StyledString fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct fileFrom _ (CIGroupRcv m) = " from " <> ttyMember m +fileFrom (GroupChat g _) CIChannelRcv = " from " <> ttyGroup' g fileFrom _ _ = "" receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] @@ -2432,6 +2529,7 @@ viewChatError isCmd logLevel testView = \case CENoSndFileUser aFileId -> ["error: snd file user not found, file id: " <> sShow aFileId | logLevel <= CLLError] CENoRcvFileUser aFileId -> ["error: rcv file user not found, file id: " <> sShow aFileId | logLevel <= CLLError] CEUserExists name -> ["user with the name " <> ttyContact name <> " already exists"] + CEChatRelayExists -> ["chat realy user already exists"] CEUserUnknown -> ["user does not exist or incorrect password"] CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId] CECantDeleteActiveUser _ -> ["cannot delete active user"] @@ -2518,6 +2616,7 @@ viewChatError isCmd logLevel testView = \case CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] CEConnectionUserChangeProhibited -> ["incognito mode change prohibited for user"] CEPeerChatVRangeIncompatible -> ["peer chat protocol version range incompatible"] + CERelayTestError e -> ["relay test error: " <> plain e] CEInternalError e -> ["internal chat error: " <> plain e] CEException e -> ["exception: " <> plain e] -- e -> ["chat error: " <> sShow e] @@ -2656,7 +2755,7 @@ ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ viewName c <> ">" ttyQuotedMember :: Maybe GroupMember -> StyledString ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (viewName c) -ttyQuotedMember _ = "> " <> ttyFrom "?" +ttyQuotedMember Nothing = ">" ttyFromContact :: Contact -> StyledString ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") @@ -2692,26 +2791,29 @@ ttyFullGroup :: GroupInfo -> StyledString ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName, shortDescr}} = ttyGroup g <> optFullName g fullName shortDescr -ttyFromGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString -ttyFromGroup g scopeInfo m = ttyFromGroupAttention g scopeInfo m False +ttyFromGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> StyledString +ttyFromGroup g scopeInfo m_ = ttyFromGroupAttention g scopeInfo m_ False -ttyFromGroupAttention :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> StyledString -ttyFromGroupAttention g scopeInfo m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g scopeInfo m attention) +ttyFromGroupAttention :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Bool -> StyledString +ttyFromGroupAttention g scopeInfo m_ attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g scopeInfo m_ attention) -ttyFromGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString -ttyFromGroupEdited g scopeInfo m = membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> "[edited] ") +ttyFromGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> StyledString +ttyFromGroupEdited g scopeInfo m_ = membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m_ <> "[edited] ") -ttyFromGroupDeleted :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe Text -> StyledString -ttyFromGroupDeleted g scopeInfo m deletedText_ = - membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) +ttyFromGroupDeleted :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Maybe Text -> StyledString +ttyFromGroupDeleted g scopeInfo m_ deletedText_ = + membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m_ <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) -fromGroup_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Text -fromGroup_ g scopeInfo m = fromGroupAttention_ g scopeInfo m False +fromGroup_ :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Text +fromGroup_ g scopeInfo m_ = fromGroupAttention_ g scopeInfo m_ False -fromGroupAttention_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> Text -fromGroupAttention_ g scopeInfo m attention = +fromGroupAttention_ :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Bool -> Text +fromGroupAttention_ g scopeInfo m_ attention = let attn = if attention then "!" else "" - in "#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo <> viewMemberName m <> attn <> "> " + in "#" <> viewGroupName g + <> maybe "" (" " <>) (groupScopeInfoStr scopeInfo) + <> maybe "" ((" " <>) . viewMemberName) m_ + <> attn <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow @@ -2720,17 +2822,17 @@ ttyTo :: Text -> StyledString ttyTo = styled $ colored Cyan ttyToGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString -ttyToGroup g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo) +ttyToGroup g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> maybe "" (" " <>) (groupScopeInfoStr scopeInfo) <> " ") ttyToGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString -ttyToGroupEdited g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> groupScopeInfoStr scopeInfo <> " [edited] ") +ttyToGroupEdited g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> maybe "" (" " <>) (groupScopeInfoStr scopeInfo) <> " [edited] ") -groupScopeInfoStr :: Maybe GroupChatScopeInfo -> Text +groupScopeInfoStr :: Maybe GroupChatScopeInfo -> Maybe Text groupScopeInfoStr = \case - Nothing -> "" - Just (GCSIMemberSupport {groupMember_}) -> case groupMember_ of - Nothing -> "(support) " - Just m -> "(support: " <> viewMemberName m <> ") " + Nothing -> Nothing + Just (GCSIMemberSupport {groupMember_}) -> Just $ case groupMember_ of + Nothing -> "(support)" + Just m -> "(support: " <> viewMemberName m <> ")" ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 5ecbbb7415..cab69fe10e 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -39,6 +39,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) @@ -150,12 +151,16 @@ testCoreOpts = logFile = Nothing, tbqSize = 16, deviceName = Nothing, + chatRelay = False, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Nothing, maintenance = False } +relayTestOpts :: ChatOpts +relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} + #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}} @@ -206,6 +211,7 @@ testCfg = shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16, + channelSubscriberRole = GRMember, -- starting role is GRMember to test members sending messages confirmMigrations = MCYesUp } @@ -276,11 +282,11 @@ nextVersion :: Version v -> Version v nextVersion (Version v) = Version (v + 1) createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Bool -> Profile -> IO TestCC -createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix clientService profile = do +createTestChat ps cfg opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {chatRelay}} dbPrefix clientService profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore ts <- getCurrentTime - Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecordAt db' (AgentUserId 1) clientService profile True ts + Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecordAt db' (AgentUserId 1) chatRelay clientService profile True ts startTestChat_ ps db cfg opts user startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC @@ -310,7 +316,7 @@ startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {coreOptions = Core ct <- newChatTerminal t opts Right cc <- newChatController db (Just user) cfg opts False void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") 0 `runReaderT` cc - chatAsync <- async $ runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts + chatAsync <- async $ runSimplexChat cfg opts user cc $ \_u cc' -> runChatTerminal ct cc' opts unless maintenance $ atomically $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 20fccf6c64..ab532edaf2 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,6 +1,7 @@ module ChatTests where import ChatTests.ChatList +import ChatTests.ChatRelays import ChatTests.DBUtils import ChatTests.Direct import ChatTests.Files @@ -15,6 +16,7 @@ chatTests = do describe "direct tests" chatDirectTests describe "forward tests" chatForwardTests describe "group tests" chatGroupTests + describe "chat relay tests" chatRelayTests describe "local chats tests" chatLocalChatsTests describe "file tests" chatFileTests describe "profile tests" chatProfileTests diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs new file mode 100644 index 0000000000..721d71d0e0 --- /dev/null +++ b/tests/ChatTests/ChatRelays.hs @@ -0,0 +1,182 @@ +module ChatTests.ChatRelays where + +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import Control.Concurrent (threadDelay) +import Test.Hspec hiding (it) + +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 + it "test chat relay" testChatRelayTest + it "relay profile updated in address" testRelayProfileUpdateInAddress + +testGetSetChatRelays :: HasCallStack => TestParams -> IO () +testGetSetChatRelays ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + alice ##> ("/relays name=bob_relay " <> bobSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" bob_relay: " <> bobSLink) + + alice ##> ("/relays name=cath_relay " <> cathSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" cath_relay: " <> cathSLink) + + alice ##> ("/relays name=bob_relay " <> bobSLink <> " name=cath_relay " <> cathSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice + <### [ 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) + +testChatRelayTest :: HasCallStack => TestParams -> IO () +testChatRelayTest ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + -- Setup: bob (relay) creates address + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + -- Setup: cath (normal user) creates address + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Scenario 1: Happy path - test relay address succeeds + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob (Bob)" + + -- Scenario 2: Non-relay address - cath is not a relay user, + -- her address has ContactShortLinkData, not RelayAddressLinkData + alice ##> ("/relay test " <> cathSLink) + alice <##. "relay test failed at RTSDecodeLink, error: " + + -- Scenario 3: Deleted address - bob deletes his address + bob ##> "/da" + bob <## "Your chat address is deleted - accepted contacts will remain connected." + bob <## "To create a new chat address use /ad" + alice ##> ("/relay test " <> bobSLink) + alice <##. "relay test failed at RTSGetLink, error: " + +testRelayProfileUpdateInAddress :: HasCallStack => TestParams -> IO () +testRelayProfileUpdateInAddress ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob (Bob)" + + bob ##> "/p bob2 Bob relay" + bob <## "user profile is changed to bob2 (Bob relay) (your 0 contacts are notified)" + + threadDelay 100000 + + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob2 (Bob relay)" + +-- 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") + ] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 2490e97af6..740e757ed8 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -389,7 +389,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map itemId [1 .. 2] bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") - bob #$> ("/_get chat @2 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + bob #$> ("/_get chat @2 initial=2", chat, [(0, "Audio/video calls: enabled"), (0, "1"), (0, "2"), (0, "3"), (0, "4")]) -- Read all items bob #$> ("/_read chat @2", id, "ok") @@ -2376,7 +2376,7 @@ testDisableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 2000000 + threadDelay 2500000 -- second user messages are deleted alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 7baa12fdd4..880c1373e9 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -50,7 +50,7 @@ chatFileTests = do it "delete uploaded file in group" testXFTPDeleteUploadedFileGroup it "with relative paths: send and receive file" testXFTPWithRelativePaths xit' "continue receiving file after restart" testXFTPContinueRcv - xit' "receive file marked to receive on chat start" testXFTPMarkToReceive + it "receive file marked to receive on chat start" testXFTPMarkToReceive it "error receiving file" testXFTPRcvError it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat it "should accept file automatically with CLI option" testAutoAcceptFile @@ -913,10 +913,10 @@ testXFTPMarkToReceive = do threadDelay 100000 bob ##> "/_start" - bob <## "chat started" bob - <### [ "subscribed 1 connections on server localhost", + <### [ "chat started", + "subscribed 1 connections on server localhost", "started receiving file 1 (test.pdf) from alice", "saving file 1 from alice to test.pdf" ] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9e671b23a5..3a75a00a20 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,7 +18,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, maybeToList) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.List (intercalate, isInfixOf) @@ -37,7 +37,6 @@ import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.DB (Binary (..)) -import Simplex.Messaging.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -231,19 +230,55 @@ chatGroupTests = do it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves - -- TODO [channels fwd] enable tests (requires communicating useRelays to members) - -- TODO [channels fwd] add tests for channels - -- TODO - tests with multiple relays (all relays should deliver messages, members should deduplicate) + -- TODO [relays] add tests for channels -- TODO - tests with delivery loop over members restored after restart -- TODO - delivery in support scopes inside channels - xdescribe "channels" $ do + -- TODO - connect plans for relay groups + -- TODO - cancellation on failure to create relay group (for owner) + -- TODO - async retry connecting to relay (for members) + -- TODO - test relay privileges + describe "channels" $ do describe "relay delivery" $ do - it "should deliver messages to members" testChannelsRelayDeliver - describe "should deliver messages in a loop over members" $ do - it "number of recipients is multiple of bucket size (3/1)" (testChannelsRelayDeliverLoop 1) - it "number of recipients is NOT multiple of bucket size (3/2)" (testChannelsRelayDeliverLoop 2) - it "number of recipients is equal to bucket size (3/3)" (testChannelsRelayDeliverLoop 3) - it "sender should deduplicate their own messages" testChannelsSenderDeduplicateOwn + describe "single relay" $ do + it "should deliver messages to members" testChannels1RelayDeliver + describe "should deliver messages in a loop over members" $ do + it "number of recipients is multiple of bucket size (3/1)" (testChannels1RelayDeliverLoop 1) + it "number of recipients is NOT multiple of bucket size (3/2)" (testChannels1RelayDeliverLoop 2) + it "number of recipients is equal to bucket size (3/3)" (testChannels1RelayDeliverLoop 3) + it "sender should deduplicate their own messages" testChannelsSenderDeduplicateOwn + describe "multiple relays" $ do + it "2 relays: should deliver messages to members" testChannels2RelaysDeliver + it "should share same incognito profile with all relays" testChannels2RelaysIncognito + describe "channel operations" $ do + it "should update channel profile (signed)" testChannelUpdateProfileSigned + it "should update channel preferences (signed)" testChannelUpdatePrefsSigned + it "should change member role (signed)" testChannelChangeRoleSigned + it "should block member for all (signed)" testChannelBlockMemberSigned + it "should remove member (signed)" testChannelRemoveMemberSigned + it "should delete channel (signed)" testChannelDeleteGroupSigned + it "should delete channel and clean up relay connections" testChannelDeleteGroupCleanup + it "owner should leave channel (signed)" testChannelOwnerLeave + it "subscriber should leave channel (signed)" testChannelSubscriberLeave + it "owner should update profile in channel (signed)" testChannelOwnerProfileUpdate + it "subscriber should update profile in channel (signed)" testChannelSubscriberProfileUpdate + describe "channel message operations" $ do + it "should update channel message" testChannelMessageUpdate + it "should delete channel message" testChannelMessageDelete + it "should send and receive channel message file" testChannelMessageFile + it "should cancel channel message file" testChannelMessageFileCancel + it "should quote channel message" testChannelMessageQuote + it "should not leak owner identity in channel reaction" testChannelOwnerReaction + it "should not leak owner identity in channel quote" testChannelOwnerQuote + it "should update channel message sent as member" testChannelOwnerUpdateAsMember + it "should delete channel message sent as member" testChannelOwnerDeleteAsMember + it "should send and receive file sent as member" testChannelOwnerFileTransferAsMember + it "should cancel file sent as member" testChannelOwnerFileCancelAsMember + it "should attribute reactions to member" testChannelReactionAttribution + it "should recreate deleted item with correct sendAsGroup from update" testChannelUpdateFallbackSendAsGroup + it "should respect sendAsGroup parameter in forward API" testForwardAPIUsesParameter + it "should compute sendAsGroup in CLI forward" testForwardCLISendAsGroup + it "should update member message in channel" testChannelMemberMessageUpdate + it "should delete member message in channel" testChannelMemberMessageDelete testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -421,7 +456,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") - bob #$> ("/_get chat #1 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + bob #$> ("/_get chat #1 initial=2", chat, [(0, "connected"), (0, "1"), (0, "2"), (0, "3"), (0, "4")]) -- Read all items bob #$> ("/_read chat #1", id, "ok") @@ -2497,12 +2532,12 @@ testPlanGroupLinkLeaveRejoin = threadDelay 100000 bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" _sLinkData <- getTermLine bob let gLinkSchema2 = linkAnotherSchema gLink bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" _sLinkData <- getTermLine bob bob ##> ("/c " <> gLink) @@ -3533,7 +3568,7 @@ testPlanGroupLinkKnown = gLink <- getGroupLink alice "team" GRMember True bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" _sLinkData <- getTermLine bob bob ##> ("/c " <> gLink) @@ -8358,107 +8393,216 @@ testScopedSupportMemberLeaves = testOpts { markRead = False } -testChannelsRelayDeliver :: HasCallStack => TestParams -> IO () -testChannelsRelayDeliver = - testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do - createChannel5 alice bob cath dan eve GRObserver - alice #> "#team hi" - bob <# "#team alice> hi" - [cath, dan, eve] *<# "#team alice> hi [>>]" +testChannels1RelayDeliver :: HasCallStack => TestParams -> IO () +testChannels1RelayDeliver ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve - cath ##> "+1 #team hi" - cath <## "added 👍" - bob <# "#team cath> > alice hi" - bob <## " + 👍" - alice <# "#team cath> > alice hi" - alice <## " + 👍" - dan <# "#team cath> > alice hi" - dan <## " + 👍" - eve <# "#team cath> > alice hi" - eve <## " + 👍" + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > hi" + bob <## " + 👍" + -- alice knows cath via XGrpMemNew announcement from relay + alice <# "#team cath> > hi" + alice <## " + 👍" + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> > hi" + dan <## " + 👍" + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> > hi" + eve <## " + 👍" + + -- owner's public member count is maintained automatically + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 4" + -- subscriber refreshes count via short link + threadDelay 100000 -- wait for async short link data update + cath ##> "/_get group link data #1" + cath <## "group ID: 1" + cath <## "subscribers: 4" + +createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () +createChannel1Relay gName owner relay cath dan eve = do + (shortLink, fullLink) <- prepareChannel1Relay gName owner relay + forM_ [cath, dan, eve] $ \member -> + memberJoinChannel gName [relay] [owner] shortLink fullLink member + +prepareChannel1Relay :: String -> TestCC -> TestCC -> IO (String, String) +prepareChannel1Relay gName owner relay = do + rName <- userName relay + + relay ##> "/ad" + (relaySLink, _cLink) <- getContactLinks relay True + + owner ##> ("/relays name=" <> rName <> " " <> relaySLink) + owner <## "ok" + + 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" --- TODO [channels fwd] correctly setup channel with relay forwarding --- TODO - alice to create group as channel --- TODO - add bob as relay --- TODO - alice to manage group link, but members to connect to relay (bob) -createChannel5 :: TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> GroupMemberRole -> IO () -createChannel5 alice bob cath dan eve mRole = do - createGroup2 "team" alice bob - bob ##> ("/create link #team " <> T.unpack (textEncode mRole)) - gLink <- getGroupLink bob "team" mRole True - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - bob <## "cath (Catherine): accepting request to join group #team..." concurrentlyN_ - [ bob <## "#team: cath joined the group", - do - cath <## "#team: joining the group..." - cath <## "#team: you joined the group" - cath <## "#team: member alice (Alice) is connected", - do - alice <## "#team: bob added cath (Catherine) to the group (connecting...)" - alice <## "#team: new member cath is connected" - ] - dan ##> ("/c " <> gLink) - dan <## "connection request sent!" - bob <## "dan (Daniel): accepting request to join group #team..." - concurrentlyN_ - [ bob <## "#team: dan joined the group", - do - dan <## "#team: joining the group..." - dan <## "#team: you joined the group" - dan <## "#team: member alice (Alice) is connected" - dan <## "#team: member cath (Catherine) is connected", - do - alice <## "#team: bob added dan (Daniel) to the group (connecting...)" - alice <## "#team: new member dan is connected", - do - cath <## "#team: bob added dan (Daniel) to the group (connecting...)" - cath <## "#team: new member dan is connected" - ] - eve ##> ("/c " <> gLink) - eve <## "connection request sent!" - bob <## "eve (Eve): accepting request to join group #team..." - concurrentlyN_ - [ bob <## "#team: eve joined the group", - eve - <### [ "#team: joining the group...", - "#team: you joined the group", - "#team: member alice (Alice) is connected", - "#team: member cath (Catherine) is connected", - "#team: member dan (Daniel) is connected" - ], - do - alice <## "#team: bob added eve (Eve) to the group (connecting...)" - alice <## "#team: new member eve is connected", - do - cath <## "#team: bob added eve (Eve) to the group (connecting...)" - cath <## "#team: new member eve is connected", - do - dan <## "#team: bob added eve (Eve) to the group (connecting...)" - dan <## "#team: new member eve is connected" + [ 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") ] -testChannelsRelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () -testChannelsRelayDeliverLoop deliveryBucketSize = - testChatCfg5 cfg aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do - createChannel5 alice bob cath dan eve GRObserver + owner ##> ("/show link #" <> gName) + getGroupLinks owner gName GRMember False - alice #> "#team hi" - bob <# "#team alice> hi" - [cath, dan, eve] *<# "#team alice> hi [>>]" +createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () +createChannel2Relays gName owner relay1 relay2 dan eve frank = do + (shortLink, fullLink) <- prepareChannel2Relays gName owner relay1 relay2 + forM_ [dan, eve, frank] $ \member -> + memberJoinChannel gName [relay1, relay2] [owner] shortLink fullLink member - cath ##> "+1 #team hi" - cath <## "added 👍" - bob <# "#team cath> > alice hi" - bob <## " + 👍" - alice <# "#team cath> > alice hi" - alice <## " + 👍" - dan <# "#team cath> > alice hi" - dan <## " + 👍" - eve <# "#team cath> > alice hi" - eve <## " + 👍" +prepareChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> IO (String, String) +prepareChannel2Relays gName owner relay1 relay2 = do + r1Name <- userName relay1 + r2Name <- userName relay2 + + relay1 ##> "/ad" + (r1SLink, _cLink) <- getContactLinks relay1 True + relay2 ##> "/ad" + (r2SLink, _cLink) <- getContactLinks relay2 True + + owner ##> ("/relays name=" <> r1Name <> " " <> r1SLink <> " name=" <> r2Name <> " " <> r2SLink) + owner <## "ok" + + owner ##> ("/public group relays=1,2 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + + concurrentlyN_ + [ do + -- one relay connects + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner + <### [ EndsWith ": active", + EndsWith ": accepted" + ] + owner <## "group link:" + void $ getTermLine owner -- consume group link line + -- second relay connects + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + owner <## "group link:" + void $ getTermLine owner, -- consume group link line + relay1 <## ("#" <> gName <> ": you joined the group as relay"), + relay2 <## ("#" <> gName <> ": you joined the group as relay") + ] + + owner ##> ("/show link #" <> gName) + getGroupLinks owner gName GRMember False + +memberJoinChannel :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO () +memberJoinChannel gName relays owners shortLink fullLink member = do + mName <- userName member + mFullName <- showName member + relayNames <- mapM userName relays + + member ##> ("/_connect plan 1 " <> shortLink) + member <## "group link: ok to connect via relays" + groupSLinkData <- getTermLine member + + member ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " direct=off " <> groupSLinkData) + member <## ("#" <> gName <> ": group is prepared") + + member ##> "/_connect group #1" + member <## ("#" <> gName <> ": connection started") + concurrentlyN_ $ + [ member + <### concat + [ [ ConsoleString ("#" <> gName <> ": joining the group (connecting to relay " <> rName <> ")..."), + ConsoleString ("#" <> gName <> ": you joined the group (connected to relay " <> rName <> ")") + ] + | rName <- relayNames + ] + ] + <> [ do + relay <## (mFullName <> ": accepting request to join group #team...") + relay <## ("#" <> gName <> ": " <> mName <> " joined the group") + | relay <- relays + ] + <> [ owner <### [EndsWith ("added " <> mFullName <> " to the group")] + | owner <- owners + ] + +memberJoinChannelIncognito :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO String +memberJoinChannelIncognito gName relays owners shortLink fullLink member = do + relayNames <- mapM userName relays + + member ##> ("/_connect plan 1 " <> shortLink) + member <## "group link: ok to connect via relays" + groupSLinkData <- getTermLine member + + member ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " direct=off " <> groupSLinkData) + member <## ("#" <> gName <> ": group is prepared") + + member ##> "/_connect group #1 incognito=on" + memIncognito <- getTermLine member + member <## ("#" <> gName <> ": connection started incognito") + concurrentlyN_ $ + [ member + <### concat + [ [ ConsoleString ("#" <> gName <> ": joining the group (connecting to relay " <> rName <> ")..."), + ConsoleString ("#" <> gName <> ": you joined the group (connected to relay " <> rName <> ") incognito as " <> memIncognito) + ] + | rName <- relayNames + ] + ] + <> [ do + relay <## (memIncognito <> ": accepting request to join group #team...") + relay <## ("#" <> gName <> ": " <> memIncognito <> " joined the group") + | relay <- relays + ] + <> [ owner <### [EndsWith ("added " <> memIncognito <> " to the group")] + | owner <- owners + ] + pure memIncognito + +testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () +testChannels1RelayDeliverLoop deliveryBucketSize ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > hi" + bob <## " + 👍" + alice <# "#team cath> > hi" + alice <## " + 👍" + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> > hi" + dan <## " + 👍" + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> > hi" + eve <## " + 👍" where cfg = testCfg {deliveryBucketSize} @@ -8468,8 +8612,8 @@ testChannelsSenderDeduplicateOwn ps = do withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> do - withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> - createChannel5 alice bob cath dan eve GRMember + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + createChannel1Relay "team" alice bob cath dan eve -- chat relay bob is offline alice #> "#team 1" @@ -8479,12 +8623,12 @@ testChannelsSenderDeduplicateOwn ps = do cath #> "#team 5" dan #> "#team 6" - withTestChatCfg ps cfg "bob" $ \bob -> do - bob <## "subscribed 6 connections server localhost" + withTestChatCfgOpts ps cfg relayTestOpts "bob" $ \bob -> do + bob <## "subscribed 6 connections on server localhost" bob - <### [ WithTime "#team alice> 1", - WithTime "#team alice> 2", - WithTime "#team alice> 3", + <### [ WithTime "#team> 1", + WithTime "#team> 2", + WithTime "#team> 3", WithTime "#team cath> 4", WithTime "#team cath> 5", WithTime "#team dan> 6" @@ -8495,22 +8639,26 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ WithTime "#team alice> 1 [>>]", - WithTime "#team alice> 2 [>>]", - WithTime "#team alice> 3 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + WithTime "#team> 1 [>>]", + WithTime "#team> 2 [>>]", + WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ WithTime "#team alice> 1 [>>]", - WithTime "#team alice> 2 [>>]", - WithTime "#team alice> 3 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + WithTime "#team> 1 [>>]", + WithTime "#team> 2 [>>]", + WithTime "#team> 3 [>>]", WithTime "#team cath> 4 [>>]", WithTime "#team cath> 5 [>>]" ] eve - <### [ WithTime "#team alice> 1 [>>]", - WithTime "#team alice> 2 [>>]", - WithTime "#team alice> 3 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + WithTime "#team> 1 [>>]", + WithTime "#team> 2 [>>]", + WithTime "#team> 3 [>>]", WithTime "#team cath> 4 [>>]", WithTime "#team cath> 5 [>>]", WithTime "#team dan> 6 [>>]" @@ -8518,6 +8666,1217 @@ testChannelsSenderDeduplicateOwn ps = do where cfg = testCfg {deliveryWorkerDelay = 250000} +testChannels2RelaysDeliver :: HasCallStack => TestParams -> IO () +testChannels2RelaysDeliver ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + withNewTestChat ps "frank" frankProfile $ \frank -> do + createChannel2Relays "team" alice bob cath dan eve frank + + alice #> "#team hi" + [bob, cath] *<# "#team> hi" + [dan, eve, frank] *<# "#team> hi [>>]" + + dan ##> "+1 #team hi" + dan <## "added 👍" + bob <# "#team dan> > hi" + bob <## " + 👍" + cath <# "#team dan> > hi" + cath <## " + 👍" + alice <# "#team dan> > hi" + alice <## " + 👍" + eve .<## " forwarded a message from an unknown member, creating unknown member record dan" + eve <# "#team dan> > hi" + eve <## " + 👍" + frank .<## " forwarded a message from an unknown member, creating unknown member record dan" + frank <# "#team dan> > hi" + frank <## " + 👍" + + -- remove below if default role is changed to observer + dan #> "#team hey" + [bob, cath] *<# "#team dan> hey" + [alice, eve, frank] *<# "#team dan> hey [>>]" + +testChannels2RelaysIncognito :: HasCallStack => TestParams -> IO () +testChannels2RelaysIncognito ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath + danIncognito <- memberJoinChannelIncognito "team" [bob, cath] [alice] shortLink fullLink dan + forM_ [eve, frank] $ \member -> + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink member + + alice #> "#team hi" + [bob, cath] *<# "#team> hi" + dan ?<# "#team> hi [>>]" + [eve, frank] *<# "#team> hi [>>]" + + dan ##> "+1 #team hi" + dan <## "added 👍" + bob <# ("#team " <> danIncognito <> "> > hi") + bob <## " + 👍" + cath <# ("#team " <> danIncognito <> "> > hi") + cath <## " + 👍" + alice <# ("#team " <> danIncognito <> "> > hi") + alice <## " + 👍" + eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + eve <# ("#team " <> danIncognito <> "> > hi") + eve <## " + 👍" + frank .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + frank <# ("#team " <> danIncognito <> "> > hi") + frank <## " + 👍" + + -- remove below if default role is changed to observer + dan ?#> "#team hey" + [bob, cath] *<# ("#team " <> danIncognito <> "> hey") + [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") + +testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO () +testChannelUpdateProfileSigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + alice ##> "/set welcome #team welcome to team" + alice <## "welcome message changed to:" + alice <## "welcome to team" + concurrentlyN_ + [ do + bob <## "alice updated group #team: (signed)" + bob <## "welcome message changed to:" + bob <## "welcome to team", + do + cath <## "alice updated group #team: (signed)" + cath <## "welcome message changed to:" + cath <## "welcome to team", + do + dan <## "alice updated group #team: (signed)" + dan <## "welcome message changed to:" + dan <## "welcome to team", + do + eve <## "alice updated group #team: (signed)" + eve <## "welcome message changed to:" + eve <## "welcome to team" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "group profile updated (signed)")]) + +testChannelUpdatePrefsSigned :: HasCallStack => TestParams -> IO () +testChannelUpdatePrefsSigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + alice ##> "/set delete #team on" + alice <## "updated group preferences:" + alice <## "Full deletion: on" + concurrentlyN_ + [ do + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Full deletion: on", + do + cath <## "alice updated group #team: (signed)" + cath <## "updated group preferences:" + cath <## "Full deletion: on", + do + dan <## "alice updated group #team: (signed)" + dan <## "updated group preferences:" + dan <## "Full deletion: on", + do + eve <## "alice updated group #team: (signed)" + eve <## "updated group preferences:" + eve <## "Full deletion: on" + ] + +testChannelChangeRoleSigned :: HasCallStack => TestParams -> IO () +testChannelChangeRoleSigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- other members discover cath + cath #> "#team hello from cath" + bob <# "#team cath> hello from cath" + concurrentlyN_ + [ alice <# "#team cath> hello from cath [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello from cath [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello from cath [>>]" + ] + + -- change member role (XGrpMemRole) - signed (other members can verify) + threadDelay 1000000 + alice ##> "/mr #team cath admin" + alice <## "#team: you changed the role of cath to admin (signed)" + bob <## "#team: alice changed the role of cath from member to admin (signed)" + concurrentlyN_ + [ cath <## "#team: alice changed your role from member to admin (signed)", + dan <## "#team: alice changed the role of cath from member to admin (signed)", + eve <## "#team: alice changed the role of cath from member to admin (signed)" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of cath to admin (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) + + -- change role of silent member (other members don't know about member) + threadDelay 1000000 + alice ##> "/mr #team dan admin" + alice <## "#team: you changed the role of dan to admin (signed)" + bob <## "#team: alice changed the role of dan from member to admin (signed)" + concurrentlyN_ + [ dan <## "#team: alice changed your role from member to admin (signed)", + cath <## "error: x.grp.mem.role with unknown member ID", + eve <## "error: x.grp.mem.role with unknown member ID" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) -- now new chat item + dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) -- now new chat item + +testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO () +testChannelBlockMemberSigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- other members discover cath + threadDelay 1000000 + cath #> "#team hello from cath" + bob <# "#team cath> hello from cath" + concurrentlyN_ + [ alice <# "#team cath> hello from cath [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello from cath [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello from cath [>>]" + ] + + -- block member (XGrpMemRestrict) - signed (other members can verify) + threadDelay 1000000 + alice ##> "/block for all #team cath" + alice <## "#team: you blocked cath (signed)" + bob <## "#team: alice blocked cath (signed)" + concurrentlyN_ + [ dan <## "#team: alice blocked cath (signed)", + eve <## "#team: alice blocked cath (signed)" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "blocked cath (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(1, "hello from cath")]) -- was blocked - no "blocked" chat item + dan #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")]) + + -- TODO [relays] member: in channels - don't create unknown member record and chat item? (just ignore) + -- block silent member (other members create unknown member record and can verify) + threadDelay 1000000 + alice ##> "/block for all #team dan" + alice <## "#team: you blocked dan (signed)" + bob <## "#team: alice blocked dan (signed)" + concurrentlyN_ + [ do + cath <##. "#team: alice blocked an unknown member, creating unknown member record" + cath .<##. ("#team: alice blocked", "(signed)"), + do + eve <##. "#team: alice blocked an unknown member, creating unknown member record" + eve .<##. ("#team: alice blocked", "(signed)") + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "blocked dan (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "blocked dan (signed)")]) + cath ##> "/_get chat #1 count=1" + [(0, r1)] <- chat <$> getTermLine cath + r1 `shouldStartWith` "blocked" + r1 `shouldEndWith` "(signed)" + dan #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")]) -- was blocked - no new chat item + eve ##> "/_get chat #1 count=1" + [(0, r2)] <- chat <$> getTermLine eve + r2 `shouldStartWith` "blocked" + r2 `shouldEndWith` "(signed)" + +testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO () +testChannelRemoveMemberSigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- other members discover eve + eve #> "#team hello from eve" + bob <# "#team eve> hello from eve" + concurrentlyN_ + [ alice <# "#team eve> hello from eve [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + dan <# "#team eve> hello from eve [>>]", + do + cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + cath <# "#team eve> hello from eve [>>]" + ] + + -- before removal — owner count is maintained synchronously + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 4" + threadDelay 100000 -- wait for async short link data update + cath ##> "/_get group link data #1" + cath <## "group ID: 1" + cath <## "subscribers: 4" + + -- remove member (XGrpMemDel) - signed (other members can verify) + threadDelay 1000000 + alice ##> "/rm #team eve" + alice <## "#team: you removed eve from the group (signed)" + bob <## "#team: alice removed eve from the group (signed)" + concurrentlyN_ + [ cath <## "#team: alice removed eve from the group (signed)", + dan <## "#team: alice removed eve from the group (signed)", + do + eve <## "#team: alice removed you from the group (signed)" + eve <## "use /d #team to delete the group" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "removed eve (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) + + -- after first removal + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 3" + threadDelay 100000 -- wait for async short link data update + cath ##> "/_get group link data #1" + cath <## "group ID: 1" + cath <## "subscribers: 3" + + -- remove silent member (other members don't know about member) + threadDelay 1000000 + alice ##> "/rm #team dan" + alice <## "#team: you removed dan from the group (signed)" + bob <## "#team: alice removed dan from the group (signed)" + concurrentlyN_ + [ cath <## "error: x.grp.mem.del with unknown member ID", + do + dan <## "#team: alice removed you from the group (signed)" + dan <## "use /d #team to delete the group" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "removed dan (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "removed dan (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")]) -- no new chat item + dan #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) -- no new chat item + + -- after second removal + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 2" + threadDelay 100000 -- wait for async short link data update + cath ##> "/_get group link data #1" + cath <## "group ID: 1" + cath <## "subscribers: 2" + +testChannelDeleteGroupSigned :: HasCallStack => TestParams -> IO () +testChannelDeleteGroupSigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + alice ##> "/d #team" + alice <## "#team: you deleted the group (signed)" + concurrentlyN_ + [ do + bob <## "#team: alice deleted the group (signed)" + bob <## "use /d #team to delete the local copy of the group", + do + cath <## "#team: alice deleted the group (signed)" + cath <## "use /d #team to delete the local copy of the group", + do + dan <## "#team: alice deleted the group (signed)" + dan <## "use /d #team to delete the local copy of the group", + do + eve <## "#team: alice deleted the group (signed)" + eve <## "use /d #team to delete the local copy of the group" + ] + +testChannelDeleteGroupCleanup :: HasCallStack => TestParams -> IO () +testChannelDeleteGroupCleanup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + + -- verify message delivery works + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + + -- owner deletes channel + alice ##> "/d #team" + concurrentlyN_ + [ alice <## "#team: you deleted the group (signed)", + do + bob <## "#team: alice deleted the group (signed)" + bob <## "use /d #team to delete the local copy of the group", + do + cath <## "#team: alice deleted the group (signed)" + cath <## "use /d #team to delete the local copy of the group" + ] + + -- restart relay, verify no extra subscriptions and no errors + withTestChatOpts ps relayTestOpts "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" + bob ##> "/groups" + bob <## "#team (group deleted, delete local copy: /d #team)" + +testChannelOwnerLeave :: HasCallStack => TestParams -> IO () +testChannelOwnerLeave ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner leaves channel (XGrpLeave is signed) + threadDelay 1000000 + alice ##> "/leave #team" + alice <## "#team: you left the group" + alice <## "use /d #team to delete the group" + bob <## "#team: alice left the group (signed)" + concurrentlyN_ + [ cath <## "#team: alice left the group (signed)", + dan <## "#team: alice left the group (signed)", + eve <## "#team: alice left the group (signed)" + ] + alice #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + +testChannelSubscriberLeave :: HasCallStack => TestParams -> IO () +testChannelSubscriberLeave ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- other members discover cath + threadDelay 1000000 + cath #> "#team hello from cath" + bob <# "#team cath> hello from cath" + concurrentlyN_ + [ alice <# "#team cath> hello from cath [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello from cath [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello from cath [>>]" + ] + + -- before any leave — owner count is maintained synchronously + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 4" + threadDelay 100000 -- wait for async short link data update + eve ##> "/_get group link data #1" + eve <## "group ID: 1" + eve <## "subscribers: 4" + + -- known member leaves (XGrpLeave signed) - owner and relay see items + threadDelay 1000000 + cath ##> "/leave #team" + cath <## "#team: you left the group" + cath <## "use /d #team to delete the group" + bob <## "#team: cath left the group (signed)" + alice <## "#team: cath left the group (signed)" + -- other subscribers: cath is known, but items are muted (muteEventInChannel) + -- member status is still updated to left in DB + alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) -- no leave item + eve #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) -- no leave item + + -- after first leave + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 3" + threadDelay 100000 -- wait for async short link data update + eve ##> "/_get group link data #1" + eve <## "group ID: 1" + eve <## "subscribers: 3" + + -- verify cath's member status is "left" on all clients + checkMemberStatus alice "cath" (Just "left") + checkMemberStatus bob "cath" (Just "left") + checkMemberStatus cath "cath" (Just "left") + checkMemberStatus dan "cath" (Just "left") + checkMemberStatus eve "cath" (Just "left") + + -- silent subscriber leaves (unknown to other subscribers) + threadDelay 1000000 + dan ##> "/leave #team" + dan <## "#team: you left the group" + dan <## "use /d #team to delete the group" + bob <## "#team: dan left the group (signed)" + alice <## "#team: dan left the group (signed)" + -- eve doesn't know dan - no unknown member record created (skipped for XGrpLeave) + alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) -- no new item + + -- after second leave + alice ##> "/_info #1" + alice <## "group ID: 1" + alice <## "subscribers: 2" + threadDelay 100000 -- wait for async short link data update + eve ##> "/_get group link data #1" + eve <## "group ID: 1" + eve <## "subscribers: 2" + + -- verify dan's member status is "left" on nodes that know dan + checkMemberStatus alice "dan" (Just "left") + checkMemberStatus bob "dan" (Just "left") + checkMemberStatus dan "dan" (Just "left") + -- eve doesn't know dan - no member record (XGrpLeave skips unknown member creation) + checkMemberStatus eve "dan" Nothing + checkMemberStatus cath "dan" Nothing + where + checkMemberStatus :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () + checkMemberStatus cc name expected = do + statuses <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_status FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] + map (\(Only s) -> s) statuses `shouldBe` maybeToList expected + +testChannelOwnerProfileUpdate :: HasCallStack => TestParams -> IO () +testChannelOwnerProfileUpdate ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner updates profile (XInfo is signed) + -- profile update to group is sent lazily with next message + threadDelay 1000000 + alice ##> "/_profile 1 {\"displayName\": \"alisa\", \"fullName\": \"\"}" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + + -- sending as channel does NOT trigger profile update + threadDelay 1000000 + alice #> "#team hello from channel" + bob <# "#team> hello from channel" + concurrentlyN_ + [ cath <# "#team> hello from channel [>>]", + dan <# "#team> hello from channel [>>]", + eve <# "#team> hello from channel [>>]" + ] + -- no profile update items on any participant + alice #$> ("/_get chat #1 count=1", chat, [(1, "hello from channel")]) + bob #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")]) + dan #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")]) + eve #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")]) + -- verify profiles are updated correctly + alice `hasContactProfiles` ["alisa", "bob", "cath", "dan", "eve"] + bob `hasContactProfiles` ["alice", "bob", "cath", "dan", "eve"] + cath `hasContactProfiles` ["alice", "bob", "cath"] + dan `hasContactProfiles` ["alice", "bob", "dan"] + eve `hasContactProfiles` ["alice", "bob", "eve"] + + -- sending as member (as_group=off) triggers profile update + threadDelay 1000000 + alice ##> "/_send #1(as_group=off) text hello from alisa" + alice <# "#team hello from alisa" + bob <# "#team alisa> hello from alisa" + concurrentlyN_ + [ cath <# "#team alisa> hello from alisa [>>]", + dan <# "#team alisa> hello from alisa [>>]", + eve <# "#team alisa> hello from alisa [>>]" + ] + -- profile update items on all receivers (signed), not on alice who sent it + alice #$> ("/_get chat #1 count=2", chat, [(1, "hello from channel"), (1, "hello from alisa")]) + bob #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")]) + dan #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")]) + eve #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")]) + -- verify profiles are updated correctly + forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alisa", "bob", "cath", "dan", "eve"] + cath `hasContactProfiles` ["alisa", "bob", "cath"] + dan `hasContactProfiles` ["alisa", "bob", "dan"] + eve `hasContactProfiles` ["alisa", "bob", "eve"] + +testChannelSubscriberProfileUpdate :: HasCallStack => TestParams -> IO () +testChannelSubscriberProfileUpdate ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- other members discover cath + threadDelay 1000000 + cath #> "#team hello from cath" + bob <# "#team cath> hello from cath" + concurrentlyN_ + [ alice <# "#team cath> hello from cath [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello from cath [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello from cath [>>]" + ] + + -- known subscriber updates profile (XInfo signed) + threadDelay 1000000 + cath ##> "/_profile 1 {\"displayName\": \"kate\", \"fullName\": \"\"}" + cath <## "user profile is changed to kate (your 0 contacts are notified)" + cath #> "#team hello from kate" + bob <# "#team kate> hello from kate" + concurrentlyN_ + [ alice <# "#team kate> hello from kate [>>]", + dan <# "#team kate> hello from kate [>>]", + eve <# "#team kate> hello from kate [>>]" + ] + -- profile update items on alice and bob (owner/relay, signed) + alice #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from kate")]) + bob #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from kate")]) + -- no profile update items on dan and eve (subscriber-to-subscriber muted) + dan #$> ("/_get chat #1 count=2", chat, [(0, "hello from cath"), (0, "hello from kate")]) + eve #$> ("/_get chat #1 count=2", chat, [(0, "hello from cath"), (0, "hello from kate")]) + -- cath doesn't see her own profile update + cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from cath"), (1, "hello from kate")]) + -- verify profiles are updated correctly + forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dan", "eve"] + cath `hasContactProfiles` ["alice", "bob", "kate"] + dan `hasContactProfiles` ["alice", "bob", "kate", "dan"] + eve `hasContactProfiles` ["alice", "bob", "kate", "eve"] + + -- previously silent subscriber updates profile + threadDelay 1000000 + dan ##> "/_profile 1 {\"displayName\": \"dave\", \"fullName\": \"\"}" + dan <## "user profile is changed to dave (your 0 contacts are notified)" + dan #> "#team hello from dave" + bob <# "#team dave> hello from dave" + concurrentlyN_ + [ alice <# "#team dave> hello from dave [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + eve <# "#team dave> hello from dave [>>]", + do + cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + cath <# "#team dave> hello from dave [>>]" + ] + -- profile update items on alice and bob (moderator+/relay, 2nd profile update signed) + alice #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from dave")]) + bob #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from dave")]) + -- no profile update items on cath and eve (subscriber-to-subscriber muted) + cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from kate"), (0, "hello from dave")]) + eve #$> ("/_get chat #1 count=2", chat, [(0, "hello from kate"), (0, "hello from dave")]) + -- dan doesn't see his own profile update + dan #$> ("/_get chat #1 count=2", chat, [(0, "hello from kate"), (1, "hello from dave")]) + -- verify profiles are updated correctly + forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"] + cath `hasContactProfiles` ["alice", "bob", "kate", "dave"] + dan `hasContactProfiles` ["alice", "bob", "kate", "dave"] + eve `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"] + +testChannelMessageUpdate :: HasCallStack => TestParams -> IO () +testChannelMessageUpdate ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello" + bob <# "#team> hello" + [cath, dan, eve] *<# "#team> hello [>>]" + + -- owner updates channel message + msgId <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId <> " text hello updated") + alice <# "#team [edited] hello updated" + bob <# "#team> [edited] hello updated" + [cath, dan, eve] *<# "#team> [edited] hello updated" -- TODO show as forwarded + +testChannelMessageDelete :: HasCallStack => TestParams -> IO () +testChannelMessageDelete ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello" + bob <# "#team> hello" + [cath, dan, eve] *<# "#team> hello [>>]" + + -- owner deletes channel message (broadcast) + msgId <- lastItemId alice + alice #$> ("/_delete item #1 " <> msgId <> " broadcast", id, "message marked deleted") + bob <# "#team> [marked deleted] hello" + [cath, dan, eve] *<# "#team> [marked deleted] hello" -- TODO show as forwarded + +testChannelMessageFile :: HasCallStack => TestParams -> IO () +testChannelMessageFile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as channel message + alice #> "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- all members receive the file concurrently + src <- B.readFile "./tests/fixtures/test.jpg" + concurrentlyN_ + [ receiveFile bob "bob" src, + receiveFile cath "cath" src, + receiveFile dan "dan" src, + receiveFile eve "eve" src + ] + where + receiveFile cc name src = do + let path = "./tests/tmp/test_" <> name <> ".jpg" + cc ##> ("/fr 1 " <> path) + cc + <### [ ConsoleString ("saving file 1 from #team to " <> path), + "started receiving file 1 (test.jpg) from #team" + ] + cc <## "completed receiving file 1 (test.jpg) from #team" + B.readFile path >>= (`shouldBe` src) + +testChannelMessageFileCancel :: HasCallStack => TestParams -> IO () +testChannelMessageFileCancel ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as channel message + alice #> "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- owner cancels file + alice ##> "/fc 1" + alice <## "cancelled sending file 1 (test.jpg) to bob" + bob <## "team cancelled sending file 1 (test.jpg)" + concurrentlyN_ + [ cath <## "team cancelled sending file 1 (test.jpg)", + dan <## "team cancelled sending file 1 (test.jpg)", + eve <## "team cancelled sending file 1 (test.jpg)" + ] + +testChannelMessageQuote :: HasCallStack => TestParams -> IO () +testChannelMessageQuote ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello from channel" + bob <# "#team> hello from channel" + [cath, dan, eve] *<# "#team> hello from channel [>>]" + + -- member quotes channel message + cath `send` "> #team (hello from) replying to channel" + cath <# "#team > hello from channel" + cath <## " replying to channel" + bob <# "#team cath> > hello from channel" + bob <## " replying to channel" + concurrentlyN_ + [ do + alice <# "#team cath> > hello from channel [>>]" + alice <## " replying to channel [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> > hello from channel [>>]" + dan <## " replying to channel [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> > hello from channel [>>]" + eve <## " replying to channel [>>]" + ] + +testChannelOwnerReaction :: HasCallStack => TestParams -> IO () +testChannelOwnerReaction ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello" + bob <# "#team> hello" + [cath, dan, eve] *<# "#team> hello [>>]" + + -- owner reacts to own channel message - reaction is forwarded as member + alice ##> "+1 #team hello" + alice <## "added 👍" + bob <# "#team alice> > hello" + bob <## " + 👍" + concurrentlyN_ + [ do cath <# "#team alice> > hello" + cath <## " + 👍", + do dan <# "#team alice> > hello" + dan <## " + 👍", + do eve <# "#team alice> > hello" + eve <## " + 👍" + ] + +testChannelOwnerQuote :: HasCallStack => TestParams -> IO () +testChannelOwnerQuote ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello from channel" + bob <# "#team> hello from channel" + [cath, dan, eve] *<# "#team> hello from channel [>>]" + + -- owner quotes own channel message (sender sees own name locally, not a protocol leak) + alice `send` "> #team (hello from) my reply" + alice <# "#team > alice hello from channel" + alice <## " my reply" + bob <# "#team> > hello from channel" + bob <## " my reply" + concurrentlyN_ + [ do cath <# "#team> > hello from channel [>>]" + cath <## " my reply [>>]", + do dan <# "#team> > hello from channel [>>]" + dan <## " my reply [>>]", + do eve <# "#team> > hello from channel [>>]" + eve <## " my reply [>>]" + ] + +testChannelOwnerUpdateAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerUpdateAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends message as member (not as channel) + alice ##> "/_send #1(as_group=off) text hello" + alice <# "#team hello" + bob <# "#team alice> hello" + [cath, dan, eve] *<# "#team alice> hello [>>]" + + -- owner updates message + msgId <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId <> " text hello updated") + alice <# "#team [edited] hello updated" + bob <# "#team alice> [edited] hello updated" + [cath, dan, eve] *<# "#team alice> [edited] hello updated" + +testChannelOwnerDeleteAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerDeleteAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends message as member (not as channel) + alice ##> "/_send #1(as_group=off) text hello" + alice <# "#team hello" + bob <# "#team alice> hello" + [cath, dan, eve] *<# "#team alice> hello [>>]" + + -- owner deletes message (broadcast) + msgId <- lastItemId alice + alice #$> ("/_delete item #1 " <> msgId <> " broadcast", id, "message marked deleted") + bob <# "#team alice> [marked deleted] hello" + [cath, dan, eve] *<# "#team alice> [marked deleted] hello" + +testChannelOwnerFileTransferAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerFileTransferAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as member (not as channel) + alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- all members receive the file + src <- B.readFile "./tests/fixtures/test.jpg" + concurrentlyN_ + [ receiveFile bob "bob" src, + receiveFile cath "cath" src, + receiveFile dan "dan" src, + receiveFile eve "eve" src + ] + where + receiveFile cc name src = do + let path = "./tests/tmp/test_" <> name <> ".jpg" + cc ##> ("/fr 1 " <> path) + cc + <### [ ConsoleString ("saving file 1 from alice to " <> path), + "started receiving file 1 (test.jpg) from alice" + ] + cc <## "completed receiving file 1 (test.jpg) from alice" + B.readFile path >>= (`shouldBe` src) + +testChannelOwnerFileCancelAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerFileCancelAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as member (not as channel) + alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- owner cancels file + alice ##> "/fc 1" + alice <## "cancelled sending file 1 (test.jpg) to bob" + bob <## "alice cancelled sending file 1 (test.jpg)" + concurrentlyN_ + [ cath <## "alice cancelled sending file 1 (test.jpg)", + dan <## "alice cancelled sending file 1 (test.jpg)", + eve <## "alice cancelled sending file 1 (test.jpg)" + ] + +testChannelReactionAttribution :: HasCallStack => TestParams -> IO () +testChannelReactionAttribution ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends message as member + alice ##> "/_send #1(as_group=off) text hello" + alice <# "#team hello" + bob <# "#team alice> hello" + [cath, dan, eve] *<# "#team alice> hello [>>]" + + -- owner reacts to own member message - reaction is forwarded as member + alice ##> "+1 #team hello" + alice <## "added 👍" + bob <# "#team alice> > alice hello" + bob <## " + 👍" + concurrentlyN_ + [ do cath <# "#team alice> > alice hello" + cath <## " + 👍", + do dan <# "#team alice> > alice hello" + dan <## " + 👍", + do eve <# "#team alice> > alice hello" + eve <## " + 👍" + ] + +testChannelUpdateFallbackSendAsGroup :: HasCallStack => TestParams -> IO () +testChannelUpdateFallbackSendAsGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message (sendAsGroup=True) + alice #> "#team channel msg" + bob <# "#team> channel msg" + [cath, dan, eve] *<# "#team> channel msg [>>]" + + -- bob locally deletes the item + bobMsgId <- lastItemId bob + bob #$> ("/_delete item #1 " <> bobMsgId <> " internal", id, "message deleted") + + -- owner updates message (XMsgUpdate includes asGroup=True) + aliceMsgId <- lastItemId alice + alice ##> ("/_update item #1 " <> aliceMsgId <> " text channel msg updated") + alice <# "#team [edited] channel msg updated" + -- bob's item was locally deleted, fallback recreates it with [edited] marker + bob <# "#team> [edited] channel msg updated" + [cath, dan, eve] *<# "#team> [edited] channel msg updated" + + -- now test sendAsGroup=False case + -- owner sends message as member + alice ##> "/_send #1(as_group=off) text member msg" + alice <# "#team member msg" + bob <# "#team alice> member msg" + [cath, dan, eve] *<# "#team alice> member msg [>>]" + + -- bob locally deletes the item + bobMsgId2 <- lastItemId bob + bob #$> ("/_delete item #1 " <> bobMsgId2 <> " internal", id, "message deleted") + + -- owner updates message (XMsgUpdate includes asGroup=False) + aliceMsgId2 <- lastItemId alice + alice ##> ("/_update item #1 " <> aliceMsgId2 <> " text member msg updated") + alice <# "#team [edited] member msg updated" + -- bob's internally deleted item is re-created as from member (sendAsGroup=False) + bob <# "#team alice> [edited] member msg updated" + -- forwarded members see correct member attribution + [cath, dan, eve] *<# "#team alice> [edited] member msg updated" + +testForwardAPIUsesParameter :: HasCallStack => TestParams -> IO () +testForwardAPIUsesParameter ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + createChannel1Relay "team" alice bob cath dan eve + connectUsers alice frank + + -- frank sends alice a message + frank #> "@alice hi there" + alice <# "frank> hi there" + + -- forward to channel with sendAsGroup=True (as channel) + alice ##> "/last_item_id @frank" + msgId <- getTermLine alice + alice ##> ("/_forward #1 as_group=on @2 " <> msgId) + alice <# "#team <- @frank" + alice <## " hi there" + bob <# "#team> -> forwarded" + bob <## " hi there" + concurrentlyN_ + [ do cath <# "#team> -> forwarded [>>]" + cath <## " hi there [>>]", + do dan <# "#team> -> forwarded [>>]" + dan <## " hi there [>>]", + do eve <# "#team> -> forwarded [>>]" + eve <## " hi there [>>]" + ] + + -- forward to channel with sendAsGroup=False (as member) + alice ##> ("/_forward #1 as_group=off @2 " <> msgId) + alice <# "#team <- @frank" + alice <## " hi there" + bob <# "#team alice> -> forwarded" + bob <## " hi there" + concurrentlyN_ + [ do cath <# "#team alice> -> forwarded [>>]" + cath <## " hi there [>>]", + do dan <# "#team alice> -> forwarded [>>]" + dan <## " hi there [>>]", + do eve <# "#team alice> -> forwarded [>>]" + eve <## " hi there [>>]" + ] + +testForwardCLISendAsGroup :: HasCallStack => TestParams -> IO () +testForwardCLISendAsGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + createChannel1Relay "team" alice bob cath dan eve + connectUsers alice frank + + -- frank sends alice a message + frank #> "@alice hi" + alice <# "frank> hi" + + -- CLI forward to channel computes sendAsGroup=True (owner in channel) + alice `send` "#team <- @frank hi" + alice <# "#team <- @frank" + alice <## " hi" + bob <# "#team> -> forwarded" + bob <## " hi" + concurrentlyN_ + [ do cath <# "#team> -> forwarded [>>]" + cath <## " hi [>>]", + do dan <# "#team> -> forwarded [>>]" + dan <## " hi [>>]", + do eve <# "#team> -> forwarded [>>]" + eve <## " hi [>>]" + ] + +testChannelMemberMessageUpdate :: HasCallStack => TestParams -> IO () +testChannelMemberMessageUpdate ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- member sends a message + cath #> "#team hello" + bob <# "#team cath> hello" + concurrentlyN_ + [ alice <# "#team cath> hello [>>]", + do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello [>>]", + do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello [>>]" + ] + + -- member updates their message + cathMsgId <- lastItemId cath + cath ##> ("/_update item #1 " <> cathMsgId <> " text hello updated") + cath <# "#team [edited] hello updated" + bob <# "#team cath> [edited] hello updated" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello updated", + dan <# "#team cath> [edited] hello updated", + eve <# "#team cath> [edited] hello updated" + ] + +testChannelMemberMessageDelete :: HasCallStack => TestParams -> IO () +testChannelMemberMessageDelete ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- member sends a message + cath #> "#team hello" + bob <# "#team cath> hello" + concurrentlyN_ + [ alice <# "#team cath> hello [>>]", + do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello [>>]", + do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello [>>]" + ] + + -- member deletes their message + cathMsgId <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] hello" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] hello", + dan <# "#team cath> [marked deleted] hello", + eve <# "#team cath> [marked deleted] hello" + ] + testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () testGroupLinkContentFilter = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 17e93505b2..58fc48062c 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2908,7 +2908,7 @@ testShortLinkJoinGroup = name <- userName cc sName <- showName cc cc ##> ("/_connect plan 1 " <> link) - cc <## "group link: ok to connect" + cc <## "group link: ok to connect directly" _sLinkData <- getTermLine cc cc ##> ("/c " <> link) cc <## "connection request sent!" @@ -3380,7 +3380,7 @@ testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile test alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3414,7 +3414,7 @@ testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile test alice <## "#team: bob left the group" cath <## "#team: bob left the group" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" void $ getTermLine bob testShortLinkPrepareGroupReject :: HasCallStack => TestParams -> IO () @@ -3425,7 +3425,7 @@ testShortLinkPrepareGroupReject = testChatCfg3 cfg aliceProfile bobProfile cathP alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3458,7 +3458,7 @@ testGroupShortLinkWelcome = testChat2 aliceProfile bobProfile test alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3491,7 +3491,7 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3706,7 +3706,7 @@ testShortLinkConnectPreparedGroupIncognito = testChat3 aliceProfile bobProfile c alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3750,7 +3750,7 @@ testShortLinkChangePreparedGroupUser = testChat3 aliceProfile bobProfile cathPro showActiveUser bob "bob (Bob)" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3806,7 +3806,7 @@ testShortLinkChangePreparedGroupUserDuplicate = testChat3 aliceProfile bobProfil showActiveUser bob "robert" bob ##> ("/_connect plan 2 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData1 <- getTermLine bob bob ##> ("/_prepare group 2 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData1) bob <## "#team: group is prepared" @@ -3815,7 +3815,7 @@ testShortLinkChangePreparedGroupUserDuplicate = testChat3 aliceProfile bobProfil showActiveUser bob "bob (Bob)" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData2 <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData2) bob <## "#team: group is prepared" @@ -4078,7 +4078,7 @@ testShortLinkGroupChangeProfile = testChat3 aliceProfile bobProfile cathProfile cath <## "changed to #club" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#club: group is prepared" @@ -4114,9 +4114,10 @@ testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cath cath <## "changed to #club" alice <## "cath updated group #team:" alice <## "changed to #club" + threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#club: group is prepared" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9aec38b56f..087cd3b900 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -75,6 +75,9 @@ danProfile = mkProfile "dan" "Daniel" Nothing eveProfile :: Profile eveProfile = mkProfile "eve" "Eve" Nothing +frankProfile :: Profile +frankProfile = mkProfile "frank" "Frank" Nothing + businessProfile :: Profile businessProfile = mkProfile "biz" "Biz Inc" Nothing @@ -375,6 +378,16 @@ cc .<## line = do unless suffix $ print ("expected to end with: " <> line, ", got: " <> l) suffix `shouldBe` True +(.<##.) :: HasCallStack => TestCC -> (String, String) -> Expectation +cc .<##. (linePrefix, lineSuffix) = do + l <- getTermLine' (Just $ "prefix: " <> linePrefix <> "; suffix: " <> lineSuffix) cc + let prefix = linePrefix `isPrefixOf` l + unless prefix $ print ("expected to start from: " <> linePrefix, ", got: " <> l) + prefix `shouldBe` True + let suffix = lineSuffix `isSuffixOf` l + unless suffix $ print ("expected to end with: " <> lineSuffix, ", got: " <> l) + suffix `shouldBe` True + (<#.) :: HasCallStack => TestCC -> String -> Expectation cc <#. line = do l <- dropTime <$> getTermLine' (Just $ "prefix: " <> line) cc diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index f4068d3d14..37fab0e4f0 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"clientService\":false}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"clientService\":false}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}" chatStartedSwift :: LB.ByteString chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" @@ -35,7 +35,7 @@ connectionsDiffTagged :: LB.ByteString connectionsDiffTagged = "{\"result\":{\"type\":\"connectionsDiff\",\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}" userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 1945a2a7dc..05322a0834 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -9,6 +9,7 @@ module MessageBatching (batchingTests) where import Crypto.Number.Serialize (os2ip) import Data.ByteString (ByteString) import qualified Data.ByteString as B +import Data.ByteString.Internal (c2w) import Data.Either (partitionEithers) import Data.Int (Int64) import Data.String (IsString (..)) @@ -19,20 +20,21 @@ import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) import Simplex.Chat.Protocol (maxEncodedMsgLength) import Simplex.Chat.Types (SharedMsgId (..)) +import Simplex.Messaging.Encoding (Large (..), smpEncodeList) import Test.Hspec batchingTests :: Spec batchingTests = describe "message batching tests" $ do testBatchingCorrectness + testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch instance IsString SndMessage where - fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s'} + fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing} where s' = encodeUtf8 $ T.pack s msgId = fromInteger $ os2ip s' -deriving instance Eq SndMessage instance IsString ChatError where fromString s = ChatError $ CEInternalError ("large message " <> show msgId) @@ -41,50 +43,77 @@ instance IsString ChatError where msgId = fromInteger (os2ip s') :: Int64 testBatchingCorrectness :: Spec -testBatchingCorrectness = describe "correctness tests" $ do - runBatcherTest 8 ["a"] [] ["a"] - runBatcherTest 8 ["a", "b"] [] ["[a,b]"] - runBatcherTest 8 ["a", "b", "c"] [] ["[a,b,c]"] - runBatcherTest 8 ["a", "bb", "c"] [] ["[a,bb,c]"] - runBatcherTest 8 ["a", "b", "c", "d"] [] ["a", "[b,c,d]"] - runBatcherTest 8 ["a", "bb", "c", "d"] [] ["a", "[bb,c,d]"] - runBatcherTest 8 ["a", "bb", "c", "de"] [] ["[a,bb]", "[c,de]"] - runBatcherTest 8 ["a", "b", "c", "d", "e"] [] ["[a,b]", "[c,d,e]"] - runBatcherTest 8 ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] [] ["a", "[b,c,d]", "[e,f,g]", "[h,i,j]"] - runBatcherTest 8 ["aaaaa"] [] ["aaaaa"] - runBatcherTest 8 ["8aaaaaaa"] [] ["8aaaaaaa"] - runBatcherTest 8 ["aaaa", "bbbb"] [] ["aaaa", "bbbb"] - runBatcherTest 8 ["aa", "bbb", "cc", "dd"] [] ["[aa,bbb]", "[cc,dd]"] - runBatcherTest 8 ["aa", "bbb", "cc", "dd", "eee", "fff", "gg", "hh"] [] ["aa", "[bbb,cc]", "[dd,eee]", "fff", "[gg,hh]"] - runBatcherTest 8 ["9aaaaaaaa"] ["9aaaaaaaa"] [] - runBatcherTest 8 ["aaaaa", "bbb", "cc"] [] ["aaaaa", "[bbb,cc]"] - runBatcherTest 8 ["8aaaaaaa", "bbb", "cc"] [] ["8aaaaaaa", "[bbb,cc]"] - runBatcherTest 8 ["9aaaaaaaa", "bbb", "cc"] ["9aaaaaaaa"] ["[bbb,cc]"] - runBatcherTest 8 ["9aaaaaaaa", "bbb", "cc", "dd"] ["9aaaaaaaa"] ["bbb", "[cc,dd]"] - runBatcherTest 8 ["9aaaaaaaa", "bbb", "cc", "dd", "e"] ["9aaaaaaaa"] ["[bbb,cc]", "[dd,e]"] - runBatcherTest 8 ["bbb", "cc", "aaaaa"] [] ["[bbb,cc]", "aaaaa"] - runBatcherTest 8 ["bbb", "cc", "8aaaaaaa"] [] ["[bbb,cc]", "8aaaaaaa"] - runBatcherTest 8 ["bbb", "cc", "9aaaaaaaa"] ["9aaaaaaaa"] ["[bbb,cc]"] - runBatcherTest 8 ["bbb", "cc", "dd", "9aaaaaaaa"] ["9aaaaaaaa"] ["bbb", "[cc,dd]"] - runBatcherTest 8 ["bbb", "cc", "dd", "e", "9aaaaaaaa"] ["9aaaaaaaa"] ["[bbb,cc]", "[dd,e]"] - runBatcherTest 8 ["bbb", "cc", "aaaaa", "dd"] [] ["[bbb,cc]", "aaaaa", "dd"] - runBatcherTest 8 ["bbb", "cc", "aaaaa", "dd", "e"] [] ["[bbb,cc]", "aaaaa", "[dd,e]"] - runBatcherTest 8 ["bbb", "cc", "8aaaaaaa", "dd"] [] ["[bbb,cc]", "8aaaaaaa", "dd"] - runBatcherTest 8 ["bbb", "cc", "8aaaaaaa", "dd", "e"] [] ["[bbb,cc]", "8aaaaaaa", "[dd,e]"] - runBatcherTest 8 ["bbb", "cc", "9aaaaaaaa"] ["9aaaaaaaa"] ["[bbb,cc]"] - runBatcherTest 8 ["bbb", "cc", "9aaaaaaaa", "dd"] ["9aaaaaaaa"] ["[bbb,cc]", "dd"] - runBatcherTest 8 ["bbb", "cc", "9aaaaaaaa", "dd", "e"] ["9aaaaaaaa"] ["[bbb,cc]", "[dd,e]"] - runBatcherTest 8 ["9aaaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] [] - runBatcherTest 8 ["8aaaaaaa", "9aaaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["8aaaaaaa"] - runBatcherTest 8 ["9aaaaaaaa", "8aaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["8aaaaaaa"] - runBatcherTest 8 ["9aaaaaaaa", "10aaaaaaaa", "8aaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["8aaaaaaa"] - runBatcherTest 8 ["bb", "cc", "dd", "9aaaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] - runBatcherTest 8 ["bb", "cc", "9aaaaaaaa", "dd", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["[bb,cc]", "dd"] - runBatcherTest 8 ["bb", "9aaaaaaaa", "cc", "dd", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] - runBatcherTest 8 ["bb", "9aaaaaaaa", "cc", "10aaaaaaaa", "dd"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "cc", "dd"] - runBatcherTest 8 ["9aaaaaaaa", "bb", "cc", "dd", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] - runBatcherTest 8 ["9aaaaaaaa", "bb", "10aaaaaaaa", "cc", "dd"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] - runBatcherTest 8 ["9aaaaaaaa", "10aaaaaaaa", "bb", "cc", "dd"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] +testBatchingCorrectness = describe "JSON batching correctness tests" $ do + runBatcherTest BMJson 8 ["a"] [] ["a"] + runBatcherTest BMJson 8 ["a", "b"] [] ["[a,b]"] + runBatcherTest BMJson 8 ["a", "b", "c"] [] ["[a,b,c]"] + runBatcherTest BMJson 8 ["a", "bb", "c"] [] ["[a,bb,c]"] + runBatcherTest BMJson 8 ["a", "b", "c", "d"] [] ["a", "[b,c,d]"] + runBatcherTest BMJson 8 ["a", "bb", "c", "d"] [] ["a", "[bb,c,d]"] + runBatcherTest BMJson 8 ["a", "bb", "c", "de"] [] ["[a,bb]", "[c,de]"] + runBatcherTest BMJson 8 ["a", "b", "c", "d", "e"] [] ["[a,b]", "[c,d,e]"] + runBatcherTest BMJson 8 ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] [] ["a", "[b,c,d]", "[e,f,g]", "[h,i,j]"] + runBatcherTest BMJson 8 ["aaaaa"] [] ["aaaaa"] + runBatcherTest BMJson 8 ["8aaaaaaa"] [] ["8aaaaaaa"] + runBatcherTest BMJson 8 ["aaaa", "bbbb"] [] ["aaaa", "bbbb"] + runBatcherTest BMJson 8 ["aa", "bbb", "cc", "dd"] [] ["[aa,bbb]", "[cc,dd]"] + runBatcherTest BMJson 8 ["aa", "bbb", "cc", "dd", "eee", "fff", "gg", "hh"] [] ["aa", "[bbb,cc]", "[dd,eee]", "fff", "[gg,hh]"] + runBatcherTest BMJson 8 ["9aaaaaaaa"] ["9aaaaaaaa"] [] + runBatcherTest BMJson 8 ["aaaaa", "bbb", "cc"] [] ["aaaaa", "[bbb,cc]"] + runBatcherTest BMJson 8 ["8aaaaaaa", "bbb", "cc"] [] ["8aaaaaaa", "[bbb,cc]"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "bbb", "cc"] ["9aaaaaaaa"] ["[bbb,cc]"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "bbb", "cc", "dd"] ["9aaaaaaaa"] ["bbb", "[cc,dd]"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "bbb", "cc", "dd", "e"] ["9aaaaaaaa"] ["[bbb,cc]", "[dd,e]"] + runBatcherTest BMJson 8 ["bbb", "cc", "aaaaa"] [] ["[bbb,cc]", "aaaaa"] + runBatcherTest BMJson 8 ["bbb", "cc", "8aaaaaaa"] [] ["[bbb,cc]", "8aaaaaaa"] + runBatcherTest BMJson 8 ["bbb", "cc", "9aaaaaaaa"] ["9aaaaaaaa"] ["[bbb,cc]"] + runBatcherTest BMJson 8 ["bbb", "cc", "dd", "9aaaaaaaa"] ["9aaaaaaaa"] ["bbb", "[cc,dd]"] + runBatcherTest BMJson 8 ["bbb", "cc", "dd", "e", "9aaaaaaaa"] ["9aaaaaaaa"] ["[bbb,cc]", "[dd,e]"] + runBatcherTest BMJson 8 ["bbb", "cc", "aaaaa", "dd"] [] ["[bbb,cc]", "aaaaa", "dd"] + runBatcherTest BMJson 8 ["bbb", "cc", "aaaaa", "dd", "e"] [] ["[bbb,cc]", "aaaaa", "[dd,e]"] + runBatcherTest BMJson 8 ["bbb", "cc", "8aaaaaaa", "dd"] [] ["[bbb,cc]", "8aaaaaaa", "dd"] + runBatcherTest BMJson 8 ["bbb", "cc", "8aaaaaaa", "dd", "e"] [] ["[bbb,cc]", "8aaaaaaa", "[dd,e]"] + runBatcherTest BMJson 8 ["bbb", "cc", "9aaaaaaaa"] ["9aaaaaaaa"] ["[bbb,cc]"] + runBatcherTest BMJson 8 ["bbb", "cc", "9aaaaaaaa", "dd"] ["9aaaaaaaa"] ["[bbb,cc]", "dd"] + runBatcherTest BMJson 8 ["bbb", "cc", "9aaaaaaaa", "dd", "e"] ["9aaaaaaaa"] ["[bbb,cc]", "[dd,e]"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] [] + runBatcherTest BMJson 8 ["8aaaaaaa", "9aaaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["8aaaaaaa"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "8aaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["8aaaaaaa"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "10aaaaaaaa", "8aaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["8aaaaaaa"] + runBatcherTest BMJson 8 ["bb", "cc", "dd", "9aaaaaaaa", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] + runBatcherTest BMJson 8 ["bb", "cc", "9aaaaaaaa", "dd", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["[bb,cc]", "dd"] + runBatcherTest BMJson 8 ["bb", "9aaaaaaaa", "cc", "dd", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] + runBatcherTest BMJson 8 ["bb", "9aaaaaaaa", "cc", "10aaaaaaaa", "dd"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "cc", "dd"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "bb", "cc", "dd", "10aaaaaaaa"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "bb", "10aaaaaaaa", "cc", "dd"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] + runBatcherTest BMJson 8 ["9aaaaaaaa", "10aaaaaaaa", "bb", "cc", "dd"] ["9aaaaaaaa", "10aaaaaaaa"] ["bb", "[cc,dd]"] + +-- Binary batch format: 'B' ( )* +-- Single element returned as-is (no B prefix) +-- Overhead per batch: 2 bytes (B + count) + 2 bytes per element (length prefix) +testBinaryBatchingCorrectness :: Spec +testBinaryBatchingCorrectness = describe "Binary batching correctness tests" $ do + -- Single element: returned as-is + runBatcherTest BMBinary 10 ["a"] [] ["a"] + runBatcherTest BMBinary 10 ["aaaa"] [] ["aaaa"] + -- Two elements: binary batch format (2 + 2*2 + content = 6 + content) + runBatcherTest BMBinary 10 ["a", "b"] [] [binaryBatch ["a", "b"]] -- 6 + 2 = 8 + runBatcherTest BMBinary 12 ["aa", "bb"] [] [binaryBatch ["aa", "bb"]] -- 6 + 4 = 10 + -- Three elements (2 + 3*2 + content = 8 + content) + runBatcherTest BMBinary 12 ["a", "b", "c"] [] [binaryBatch ["a", "b", "c"]] -- 8 + 3 = 11 + -- Large element: error (9 bytes > limit 8) + runBatcherTest BMBinary 8 ["9aaaaaaaa"] ["9aaaaaaaa"] [] + -- Mix of sizes: batch of 2 3-byte elements = 6 + 6 = 12 + runBatcherTest BMBinary 12 ["aaa", "bbb", "ccc"] [] ["aaa", binaryBatch ["bbb", "ccc"]] + -- 4 elements of 2 bytes: batch of 4 = 2 + 8 + 8 = 18, batch of 3 = 2 + 6 + 6 = 14 + runBatcherTest BMBinary 16 ["aa", "bb", "cc", "dd"] [] ["aa", binaryBatch ["bb", "cc", "dd"]] + -- Each element separate when can't batch due to size differences + runBatcherTest BMBinary 10 ["aa", "9aaaaaaaa", "bb"] [] ["aa", "9aaaaaaaa", "bb"] + runBatcherTest BMBinary 14 ["aa", "9aaaaaaaa", "bb", "cc"] [] ["aa", "9aaaaaaaa", binaryBatch ["bb", "cc"]] + +-- Helper to construct expected binary batch output +binaryBatch :: [ByteString] -> ByteString +binaryBatch msgs = c2w '=' `B.cons` smpEncodeList (map Large msgs) testImageFitsSingleBatch :: IO () testImageFitsSingleBatch = do @@ -97,23 +126,23 @@ testImageFitsSingleBatch = do let xMsgNewStr = B.replicate xMsgNewRoundedSize 1 descrStr = B.replicate descrRoundedSize 2 - msg s = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = s} + msg s = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = s, signedMsg_ = Nothing} batched = "[" <> xMsgNewStr <> "," <> descrStr <> "]" - runBatcherTest' maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] + runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] -runBatcherTest :: Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec -runBatcherTest maxLen msgs expectedErrors expectedBatches = +runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec +runBatcherTest mode maxLen msgs expectedErrors expectedBatches = it ( (show (map (\SndMessage {msgBody} -> msgBody) msgs) <> ", limit " <> show maxLen <> ": should return ") <> (show (length expectedErrors) <> " large, ") <> (show (length expectedBatches) <> " batches") ) - (runBatcherTest' maxLen msgs expectedErrors expectedBatches) + (runBatcherTest' mode maxLen msgs expectedErrors expectedBatches) -runBatcherTest' :: Int -> [SndMessage] -> [ChatError] -> [ByteString] -> IO () -runBatcherTest' maxLen msgs expectedErrors expectedBatches = do - let (errors, batches) = partitionEithers $ batchMessages maxLen (map Right msgs) +runBatcherTest' :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> IO () +runBatcherTest' mode maxLen msgs expectedErrors expectedBatches = do + let (errors, batches) = partitionEithers $ batchMessages mode maxLen (map Right msgs) batchedStrs = map (\(MsgBatch batchBody _) -> batchBody) batches testErrors errors `shouldBe` testErrors expectedErrors batchedStrs `shouldBe` expectedBatches diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e9be3839c9..c75bc37166 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -149,7 +149,7 @@ testChatApi ps = do Right ChatDatabase {chatStore, agentStore} <- createChatDatabase (ChatDbOpts dbPrefix "myKey" DB.TQOff True) (MigrationConfig MCYesUp Nothing) insertUser agentStore ts <- getCurrentTime - Right _ <- withTransaction chatStore $ \db -> runExceptT $ createUserRecordAt db (AgentUserId 1) False aliceProfile {preferences = Nothing} True ts + Right _ <- withTransaction chatStore $ \db -> runExceptT $ createUserRecordAt db (AgentUserId 1) False False aliceProfile {preferences = Nothing} True ts Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" "yesUp" diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 656f0ae0e2..5e659dd82c 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -20,10 +20,12 @@ import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Operators import Simplex.Chat.Operators.Presets +import Simplex.Chat.Protocol (RelayProfile (..), mkRelayProfile) import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.Entity +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol import Test.Hspec @@ -34,18 +36,31 @@ operatorTests = describe "managing server operators" $ do validateServersTest :: Spec validateServersTest = describe "validate user servers" $ do - it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` [] + it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` ([], []) it "should fail without servers" $ do - validateUserServers [invalidNoServers] [] `shouldBe` [USENoServers aSMP Nothing] - validateUserServers [invalidDisabled] [] `shouldBe` [USENoServers aSMP Nothing] - validateUserServers [invalidDisabledOp] [] `shouldBe` [USENoServers aSMP Nothing, USENoServers aXFTP Nothing] + validateUserServers [invalidNoServers] [] `shouldBe` ([USENoServers aSMP Nothing], []) + validateUserServers [invalidDisabled] [] `shouldBe` ([USENoServers aSMP Nothing], []) + validateUserServers [invalidDisabledOp] [] `shouldBe` ([USENoServers aSMP Nothing, USENoServers aXFTP Nothing], [USWNoChatRelays Nothing]) it "should fail without servers with storage role" $ do - validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing] + validateUserServers [invalidNoStorage] [] `shouldBe` ([USEStorageMissing aSMP Nothing], []) it "should fail with duplicate host" $ do - validateUserServers [invalidDuplicate] [] `shouldBe` - [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", - USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" - ] + validateUserServers [invalidDuplicateSrv] [] + `shouldBe` ( [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", + USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" + ], + [] + ) + it "should warn without chat relays" $ + validateUserServers [invalidNoChatRelays] [] `shouldBe` ([], [USWNoChatRelays Nothing]) + it "should allow duplicate chat relay name" $ + validateUserServers [duplicateChatRelayName] [] `shouldBe` ([], []) + it "should fail with duplicate chat relay address" $ do + validateUserServers [invalidDuplicateChatRelayAddress] [] + `shouldBe` ( [ USEDuplicateChatRelayAddress "SimpleX Chat Relay 2" duplicateAddr, + USEDuplicateChatRelayAddress "chat_relay_4" duplicateAddr + ], + [] + ) where aSMP = AProtocolType SPSMP aXFTP = AProtocolType SPXFTP @@ -59,51 +74,67 @@ updatedServersTest = describe "validate user servers" $ do all addedPreset ops' `shouldBe` True let ops'' :: [(Maybe PresetOperator, Maybe ServerOperator)] = saveOps ops' -- mock getUpdateServerOperators - uss <- groupByOperator' (ops'', [], []) -- no stored servers + uss <- groupByOperator' (ops'', [], [], []) -- no stored servers or relays length uss `shouldBe` 3 [op1, op2, op3] <- pure $ map updatedUserServers uss [p1, p2] <- pure operators -- presets sameServers p1 op1 + sameRelays p1 op1 sameServers p2 op2 + sameRelays p2 op2 null (servers' SPSMP op3) `shouldBe` True null (servers' SPXFTP op3) `shouldBe` True - it "adding preset operators and assiging servers to operator for existing users" $ do + null (chatRelays' op3) `shouldBe` True + it "adding preset operators and assigning servers to operator for existing users" $ do let ops' = updatedServerOperators operators [] ops'' = saveOps ops' uss <- groupByOperator' ( ops'', saveSrvs $ take 3 simplexChatSMPServers <> [newUserServer "smp://abcd@smp.example.im"], - saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers + saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers, + saveRelays $ take 2 simplexChatRelays <> [newChatRelay (mkRelayProfile "custom_relay" Nothing) ["example.im"] customRelayAddr] ) [op1, op2, op3] <- pure $ map updatedUserServers uss [p1, p2] <- pure operators -- presets sameServers p1 op1 + sameRelays p1 op1 sameServers p2 op2 + sameRelays p2 op2 map srvHost' (servers' SPSMP op3) `shouldBe` [["smp.example.im"]] null (servers' SPXFTP op3) `shouldBe` True + map relayName' (chatRelays' op3) `shouldBe` ["custom_relay"] where addedPreset = \case (Just PresetOperator {operator = Just op}, Just (ASO SDBNew op')) -> operatorTag op == operatorTag op' _ -> False - saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1..] - saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1..] + saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1 ..] + saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1 ..] + saveRelays = zipWith (\i relay -> relay {chatRelayId = DBEntityId i}) [1 ..] sameServers preset op = do map srvHost (pServers SPSMP preset) `shouldBe` map srvHost' (servers' SPSMP op) map srvHost (pServers SPXFTP preset) `shouldBe` map srvHost' (servers' SPXFTP op) + sameRelays PresetOperator {chatRelays = presetRelays} op = + map chatRelayAddress presetRelays `shouldBe` map relayAddr' (chatRelays' op) srvHost' (AUS _ s) = srvHost s + relayAddr' (AUCR _ r) = chatRelayAddress r + relayName' (AUCR _ UserChatRelay {relayProfile = RelayProfile {displayName}}) = displayName PresetServers {operators} = presetServers defaultChatConfig + customRelayAddr = either error id $ strDecode "https://relay.example.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y" deriving instance Eq User deriving instance Eq UserServersError +deriving instance Eq UserServersWarning + valid :: UpdatedUserOperatorServers valid = UpdatedUserOperatorServers { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1}, smpServers = map (AUS SDBNew) simplexChatSMPServers, - xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers + xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers, + chatRelays = map (AUCR SDBNew) simplexChatRelays } invalidNoServers :: UpdatedUserOperatorServers @@ -127,8 +158,26 @@ invalidNoStorage = { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, smpRoles = allRoles {storage = False}} } -invalidDuplicate :: UpdatedUserOperatorServers -invalidDuplicate = +invalidDuplicateSrv :: UpdatedUserOperatorServers +invalidDuplicateSrv = (valid :: UpdatedUserOperatorServers) { smpServers = map (AUS SDBNew) $ simplexChatSMPServers <> [presetServer True "smp://abcd@smp8.simplex.im"] } + +invalidNoChatRelays :: UpdatedUserOperatorServers +invalidNoChatRelays = (valid :: UpdatedUserOperatorServers) {chatRelays = []} + +duplicateChatRelayName :: UpdatedUserOperatorServers +duplicateChatRelayName = + (valid :: UpdatedUserOperatorServers) + { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True (mkRelayProfile "chat_relay_1" Nothing) ["simplex.im"] (either error id $ strDecode "https://smp444.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y")] + } + +invalidDuplicateChatRelayAddress :: UpdatedUserOperatorServers +invalidDuplicateChatRelayAddress = + (valid :: UpdatedUserOperatorServers) + { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True (mkRelayProfile "chat_relay_4" Nothing) ["simplex.im"] duplicateAddr] + } + +duplicateAddr :: ShortLinkContact +duplicateAddr = either error id $ strDecode "https://smp6.simplex.im/r#_qlQfogHGDJ8MAF2wKmkglRBM-xHR142gDJstKiGRQQ" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d607d1f208..1b708a2ffa 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -66,7 +66,7 @@ quotedMsg = s ==## msg = do case parseChatMessages s of [acMsg] -> case acMsg of - Right (ACMsg _ msg') -> case checkEncoding msg' of + Right (APMsg _ (ParsedMsg _ _ msg')) -> case checkEncoding msg' of Right msg'' -> msg'' `shouldBe` msg Left e -> expectationFailure $ "checkEncoding error: " <> show e Left e -> expectationFailure $ "parse error: " <> show e @@ -107,7 +107,7 @@ testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences} testGroupProfile :: GroupProfile -testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} +testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do @@ -116,10 +116,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing Nothing)) it "x.msg.new simple text - live message" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing Nothing)) it "x.msg.new simple link" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) @@ -146,22 +146,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing Nothing Nothing))) it "x.msg.new quote - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True) Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True) Nothing Nothing))) it "x.msg.new forward" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing Nothing)) it "x.msg.new forward - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing Nothing)) it "x.msg.new simple text with file" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing Nothing @@ -247,19 +247,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) + #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} @@ -268,10 +268,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile diff --git a/website/.eleventy.js b/website/.eleventy.js index 1a45d33418..1a98609f1a 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -53,7 +53,7 @@ const globalConfig = { } const translationsDirectoryPath = './langs' -const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "why", "file", ""] +const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "why", ""] let supportedLangs = [] fs.readdir(translationsDirectoryPath, (err, files) => { if (err) { diff --git a/website/langs/ar.json b/website/langs/ar.json index 708e2d306d..41825d0683 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -200,7 +200,7 @@ "simplex-private-section-header": "ما الذي يجعل SimpleX خصوصيًّا", "privacy-matters-section-subheader": "الحفاظ على خصوصية بياناتك الوصفية — مع مَن تتحدث — يحميك من:", "simplex-network-1-overlay-linktext": "مشاكل شبكات P2P", - "simplex-network-section-desc": "يوفر Simplex Chat أفضل خصوصية من خلال الجمع بين مزايا P2P والشبكات الاتحادية.", + "simplex-network-section-desc": "يوفر SimpleX Chat أفضل خصوصية من خلال الجمع بين مزايا P2P والشبكات الاتحادية.", "simplex-network-1-desc": "يتم إرسال جميع الرسائل عبر الخوادم، مما يوفر خصوصية أفضل للبيانات الوصفية وتسليمًا موثوقًا للرسائل غير المتزامنة، مع تجنب الكثير", "simplex-network-2-header": "على عكس الشبكات الاتحادية", "simplex-network-3-desc": "توفر الخوادم قوائم انتظار أحادية الاتجاه لتوصيل المستخدمين، لكن ليس لديهم رؤية للرسم البياني لاتصال الشبكة — إلا للمستخدمين فقط.", diff --git a/website/langs/cs.json b/website/langs/cs.json index fd3cde171e..19f4aeedb6 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -1,7 +1,7 @@ { "simplex-private-card-10-point-2": "Umožňuje doručovat zprávy bez identifikátoru uživatelských profilů, což poskytuje lepší soukromí metadat než alternativy.", "simplex-unique-4-overlay-1-title": "Plně decentralizované — uživatelé vlastní síť SimpleX", - "hero-overlay-card-1-p-6": "Přečtěte si více v SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Více v SimpleX whitepaper.", "hero-overlay-card-1-p-2": "K doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními sítěmi, dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení — neexistují žádné dlouhodobé identifikátory.", "hero-overlay-card-1-p-3": "Definujete, které servery se mají používat k přijímání zpráv, vašich kontaktů — servery, které používáte k odesílání zpráv. Každá konverzace bude pravděpodobně používat dva různé servery.", "hero-overlay-card-2-p-3": "I v těch nejsoukromějších aplikacích, které používají služby Tor v3, pokud mluvíte se dvěma různými kontakty prostřednictvím stejného profilu, může být prokázáno, že jsou spojeni se stejnou osobou.", @@ -102,7 +102,7 @@ "simplex-unique-overlay-card-4-p-1": "Můžete použít SimpleX se svými vlastními servery a přesto komunikovat s lidmi, kteří používají přednastavené servery v aplikacích.", "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", "simplex-unique-card-1-p-1": "SimpleX chrání soukromí vašeho profilu, kontaktů a metadat a skrývá je před servery SimpleX sítě a jakýmikoli pozorovateli.", - "simplex-unique-card-1-p-2": "Na rozdíl od jakékoli jiné existující síti pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům — ani náhodná čísla.", + "simplex-unique-card-1-p-2": "Na rozdíl od jakékoli jiné existující síti pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům — ani náhodná čísla.", "simplex-unique-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", "simplex-unique-card-3-p-2": "End-to-end šifrované zprávy jsou dočasně uchovávány na přenosových serverech SimpleX, dokud nejsou přijaty, poté jsou trvale odstraněny.", "join": "Připojit", @@ -140,10 +140,10 @@ "privacy-matters-section-header": "Proč na soukromí záleží", "privacy-matters-section-subheader": "Zachování soukromí vašich metadat — s kým mluvíte — vás chrání před:", "privacy-matters-section-label": "Ujistěte se, že váš messenger nemá přístup k vašim datům!", - "simplex-private-section-header": "Co dělá SimpleX soukromým", + "simplex-private-section-header": "Co dělá SimpleX soukromým", "tap-to-close": "Klepnutím zavřete", "simplex-network-section-header": "SimpleX Síť", - "simplex-network-section-desc": "Simplex Chat poskytuje nejlepší soukromí tím, že kombinuje výhody P2P a federovaných sítí.", + "simplex-network-section-desc": "SimpleX Chat poskytuje nejlepší soukromí tím, že kombinuje výhody P2P a federovaných sítí.", "simplex-network-1-header": "Na rozdíl od P2P sítí", "simplex-network-1-overlay-linktext": "problémům P2P sítí", "simplex-network-2-header": "Na rozdíl od federovaných sítí", @@ -181,7 +181,7 @@ "privacy-matters-overlay-card-2-p-2": "Chcete-li být objektivní a činit nezávislá rozhodnutí, musíte mít svůj informační prostor pod kontrolou. Je to možné pouze v případě, že používáte soukromou komunikační síť, která nemá přístup k vašemu sociálnímu grafu.", "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", - "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných sítí pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá na telefonní čísla, adresy založené na doméně (jako e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů — Operátoři SimpleX serverů neví, kolik lidí používá jejich servery.", + "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných sítí pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá na telefonní čísla, adresy založené na doméně (jako e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů — Operátoři SimpleX serverů neví, kolik lidí používá jejich servery.", "invitation-hero-header": "Byl vám zaslán odkaz pro připojení na SimpleX Chat", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", "contact-hero-p-1": "Veřejné klíče a adresa fronty zpráv v tomto odkazu NEJSOU při zobrazení této stránky odesílány přes síť — jsou obsaženy ve fragmentu kontrolního součtu adresy URL odkazu.", @@ -235,7 +235,7 @@ "hero-overlay-3-title": "Hodnocení zabezpečení", "hero-overlay-3-textlink": "Hodnocení zabezpečení", "hero-overlay-card-3-p-1": "Trail of Bits je přední bezpečnostní a technologické poradenství, jejichž klienti zahrnují velké technologické firmy, vládní agentury a významné blockchainové projekty.", - "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", + "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", "comparison-section-list-point-4a": "SimpleX relé nemůže ohrozit šifrování e2e. Ověřte bezpečnostní kód, který zmírňuje mimo pásmový útok na kanál", "docs-dropdown-8": "SimpleX Directory", @@ -279,12 +279,12 @@ "index-messaging-p2": "Pro vaše soukromí servery nevidí vaše zprávy ani to, s kým si píšete.", "index-messaging-cta": "Zjistit více o zprávách v SimpleX", "index-nextweb-h2": "Váš internet
budoucnosti", - "index-nextweb-p1": "SimpleX je postaven na myšlence, že vaše data, kontakty a skupiny patří vám.", - "index-nextweb-p2": "Otevřená decentralizovaná síť vám umožňuje spojovat se s ostatními a komunikovat svobodně a bezpečně.", + "index-nextweb-p1": "SimpleX je postaven na myšlence, že vy musíte vlastnit váš profil, kontakty a komunity.", + "index-nextweb-p2": "Nikým nevlastněná decentralizovaná síť, vám umožní spojit se s lidmi a sdílet nápady, být svobodný a bezpečný ve své síti.", "index-token-h2": "Stabilní komunity", "index-token-p1": "Své oblíbené skupiny budete moci podpořit pomocí připravovaných Skupinových Voucherů.", "index-token-p2": "Vouchery budou sloužit k úhradě provozu serverů, aby skupiny zůstaly svobodné a nezávislé.", - "index-token-cta": "Zjistěte více a získejte bezplatný přístup do testování.", + "index-token-cta": "Zjistěte více a získejte bezplatnou vstupenku pro rané testování.", "index-roadmap-h2": "Plán SimpleX ke svobodnému internetu", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Škálování pro velké komunity", @@ -314,5 +314,59 @@ "messengers-comparison-section-list-point-5": "Dvoufaktorovou výměnu klíčů lze volitelně aktivovat pomocí ověření bezpečnostním kódem.", "messengers-comparison-section-list-point-6": "Postkvantová dohoda o klíči je omezená — chrání pouze některé kroky ratchet mechanismu.", "navbar-old-site": "Starý web", + "why-p1": "Narodili jste se bez účtu.", + "why-p2": "Nikdo nesledoval vaše konverzace. Nikdo nenakreslil mapu, kde jste byli. Ochrana osobních údajů nikdy nebyla funkce — byl to způsob života.", + "why-tagline": "Buďte volní ve své síti.", + "why-footer-link": "Proč ji stavíme", + "docs-dropdown-15": "Ověřitelné a opakovatelné sestavení", + "why-p3": "Pak jsme se přesunuli na internet a každá platforma chtěla o vás něco vědět — vaše jméno, vaše číslo, vaše přátele. Smířili jsme se s tím, že cenou za komunikaci s ostatními je dát někomu vědět, s kým mluvíme. Každá generace, lidská i technická, to tak měla — telefon, e-mail, komunikátory, sociální sítě. Zdálo se, že je to jediný možný způsob.", + "why-p4": "Existuje i jiný způsob. Síť bez telefonních čísel. Bez uživatelských jmen. Bez účtů. Bez jakékoli uživatelské identity. Síť, která spojuje lidi a přenáší šifrované zprávy, aniž by bylo známo, kdo je připojen.", + "why-p5": "Není lepší zámek na dveřích někoho jiného. Není milejší nájemce, který respektuje vaše soukromí, ale přesto vede evidenci všech návštěvníků. Vy nejste host. Jste doma. Ani král do něj nemůže vstoupit — jste suverén.", + "why-p6": "Vaše konverzace patří vám, jako tomu bylo vždy před internetem. Síť není místo, které navštěvujete. Je to místo, které vytváříte a vlastníte. A nikdo vám ho nemůže vzít, ať už je soukromé, nebo veřejné.", + "why-p7": "Nejstarší lidská svoboda — mluvit s druhým člověkem, aniž by byl sledován — postavena na infrastruktuře, která ji nemůže zradit.", + "why-p8": "Protože jsme zničili sílu vědět, kdo jste. Aby vám vaši moc nikdo nemohl vzít.", + "file": "Soubor", + "file-noscript": "Pro přenos souborů je zapotřebí JavaScript.", + "file-e2e-note": "Koncové šifrování — server nikdy neuvidí váš soubor.", + "file-learn-more": "Další informace o protokolu XFTP", + "file-desc": "Odeslání souborů bezpečně s šifrováním end-to-end — žádné účty, žádné sledování.", + "file-cta-heading": "Získejte SimpleX Chat — nejbezpečnější & soukromí komunikátor", + "file-cta-subheading": "Přenos souborů, který jste právě použili, používá stejný protokol pro přenos dat jako SimpleX Chat. Aplikace má end-to-end šifrované zprávy, hlasové a video volání, skupiny a odesílání souborů. Žádný účet. Žádný telefon. Žádný e-mail. Žádné ID uživatelského profilu.", + "file-title": "SimpleX Přenos Souborů", + "file-drop-text": "Přetáhněte soubor sem", + "file-drop-hint": "nebo", + "file-choose": "Vyberte soubor", + "file-max-size": "Max 100 MB - SimpleX Chat apka podporuje soubory až do 1 GB", + "file-encrypting": "Šifruji…", + "file-uploading": "Nahrávám…", + "file-cancel": "Zrušit", + "file-uploaded": "Nahrané soubory", + "file-copy": "Kopírovat", + "file-copied": "Zkopírováno!", + "file-share": "Sdílet", + "file-expiry": "Soubory jsou obvykle k dispozici 48 hodin.", + "file-sec-1": "Váš soubor byl zašifrován v prohlížeči - datové routery nikdy neuvidí obsah souboru, jméno nebo velikost.", + "file-sec-2": "Šifrovací klíč je obsažen v hash části odkazu – nikdy se neodesílá na žádný server.", + "file-sec-3": "Pro větší bezpečnost, použijte SimpleX Chat apku.", + "file-retry": "Znovu", + "file-downloading": "Stahuji…", + "file-decrypting": "Dešifruji…", + "file-download-complete": "Stažení dokončeno", + "file-download-btn": "Stahování", + "file-too-large": "Soubor příliš velký (%size%). Maximum je 100 MB. SimpleX app podporuje soubory až do 1 GB.", + "file-empty": "Soubor je prázdný.", + "file-invalid-link": "Neplatný nebo poškozený odkaz.", + "file-init-error": "Chyba inicializace: %error%", + "file-available": "Dostupný soubor (~%size%)", + "file-dl-sec-1": "Tento soubor je šifrován - datové směrovače nikdy neuvidí obsah souboru, jméno nebo velikost.", + "file-workers-required": "Vyžadováni Web Workers — aktualizujte prohlížeč", + "file-protocol-title": "XFTP protokol: nejbezpečnější přenos souborů", + "file-proto-h-1": "Není nutný žádný účet", + "file-proto-p-1": "Každá část souborů používá nový náhodný klíč. Datové směrovače nemají \"uživatele\" nebo \"soubory\" - přenášejí šifrované části souborů stejných velikostí.", + "file-proto-h-2": "Trojitě zašifrováno ve vašem prohlížeči", + "file-proto-h-4": "Nezávislé směrovače dat", + "file-proto-spec": "Přečtěte si specifikaci XFTP protokolu →", + "file-proto-p-2": "Šifrovací klíč souboru je obsažen pouze v části hash adresy URL – váš prohlížeč jej nikdy neodesílá na server. Existují 3 úrovně šifrování: přenos přes protokol TLS, šifrování pro každého příjemce (jedinečný dočasný klíč pro každý přenos) a šifrování souborů typu end-to-end.", + "file-proto-p-4": "Když je soubor rozdělen na části, je odeslán přes síťové směrovače provozované nezávislými stranami. Žádný operátor nemůže vidět aktuální velikost nebo jméno souboru. I kdyby byl směrovač ohrožen, může vidět pouze šifrované části s pevně stanovenou velikosti. Části souboru jsou v mezipaměti síťových směrovačů uchovávány přibližně 48 hodin.", "send-file": "Odeslat soubor" } diff --git a/website/langs/de.json b/website/langs/de.json index a1ec3447b3..17fb9b184a 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -200,7 +200,7 @@ "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn Anwender die in der SimpleX Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", "contact-hero-header": "Sie haben eine Adresse zur Verbindung mit SimpleX Chat erhalten", "invitation-hero-header": "Sie haben einen Einmal-Link zur Verbindung mit SimpleX Chat erhalten", - "privacy-matters-overlay-card-3-p-3": "Normale Menschen werden für das, was sie online teilen, sogar unter Nutzung ihrer „anonymen“ Konten, selbst in demokratischen Ländern verhaftet.", + "privacy-matters-overlay-card-3-p-3": "Selbst in demokratischen Ländern werden normale Menschen, auch unter Nutzung ihrer „anonymen“ Benutzerkennungen, für das, was sie online teilen, verhaftet.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat speichert alle Benutzerdaten ausschließlich auf den Endgeräten in einem portablen und verschlüsselten Datenbankformat, welches exportiert und auf jedes unterstützte Gerät übertragen werden kann.", "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", @@ -275,15 +275,15 @@ "index-publications-optout-title": "Podcast-Interview von OptOut", "worlds-most-secure-messaging": "Das sicherste Messaging-System der Welt", "index-messaging-p1": "SimpleX-Messaging verfügt über modernste Ende-zu-Ende-Verschlüsselung.", - "index-messaging-p2": "Zu Ihrer Sicherheit und zum Schutz Ihrer Privatsphäre können Server Ihre Nachrichten weder sehen, noch mit wem Sie kommunizieren.", + "index-messaging-p2": "Zu Ihrer Sicherheit und zum Schutz Ihrer Privatsphäre können Server weder Ihre Nachrichten sehen, noch mit wem Sie kommunizieren.", "index-messaging-cta": "Lernen Sie mehr über SimpleX-Messaging", "index-nextweb-h2": "Sie besitzen die
Zukunft des Webs", - "index-nextweb-p1": "SimpleX basiert auf der Überzeugung, dass Sie Eigentümer Ihrer Identität, Ihrer Kontakte und Ihrer Communitys bleiben müssen.", - "index-nextweb-p2": "Ein offenes und dezentrales Netzwerk ermöglicht es Ihnen, mit Menschen in Kontakt zu treten und Ideen auszutauschen: Seien Sie frei und sicher.", + "index-nextweb-p1": "SimpleX wurde aus der Überzeugung heraus entwickelt, dass Sie Eigentümer Ihrer Profile, Kontakte und Communitys bleiben müssen.", + "index-nextweb-p2": "Ein dezentrales Netzwerk, welches Niemanden gehört, ermöglicht es Ihnen, mit Menschen in Kontakt zu treten, Ideen auszutauschen und dabei frei und sicher in Ihrem Netzwerk zu sein.", "index-token-h2": "Communitys, die Bestand haben", "index-token-p1": "Sie werden Ihre Lieblingsgruppen in Zukunft mit Community-Gutscheinen unterstützen können.", "index-token-p2": "Server werden mit Gutscheinen bezahlt, damit Ihre Communitys kostenlos und unabhängig bleiben können.", - "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT ab
, um es frühzeitig auszuprobieren.", + "index-token-cta": "Erfahren Sie mehr und sichern Sie sich einen kostenlosen Zugangspass, um es frühzeitig auszuprobieren.", "index-roadmap-h2": "SimpleX - Der Weg zum freien Internet", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Skalierung auf große Communitys", @@ -314,5 +314,59 @@ "messengers-comparison-section-list-point-6": "Die Schlüsselvereinbarung per Post-Quanten-Security ist nicht durchgängig — Sie schützt lediglich ausgewählte Schritte innerhalb des Ratchet-Prozesses.", "navbar-token": "Token", "navbar-old-site": "Alte Webseite", + "docs-dropdown-15": "Builds überprüfen und reproduzieren", + "why-p1": "Sie wurden ohne eine Benutzerkennung geboren.", + "why-p2": "Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature — sie war selbstverständlich.", + "why-p3": "Dann sind wir online gegangen, und jede Plattform wollte Etwas von Ihnen — Ihren Namen, Ihre Nummer, Ihre Freunde. Wir akzeptierten, dass es der Preis mit Anderen zu kommunizieren ist, Jemandem preiszugeben, mit wem und wie wir miteinander kommunizieren. Jede Generation, Menschen und Technologien, kannten es nur so — Telefon, E-Mail, Messenger, soziale Medien. Es schien der einzig mögliche Weg zu sein.", + "why-p4": "Es gibt einen anderen Weg. Ein Netzwerk ohne Telefonnummern, ohne Benutzernamen, ohne Benutzerkennungen und ohne jegliche Benutzeridentität. Ein Netzwerk, welches Menschen verbindet und verschlüsselte Nachrichten überträgt, ohne zu wissen, wer mit wem verbunden ist.", + "why-p5": "Nicht ein besseres Schloss an der Tür eines Anderen. Kein freundlicher Vermieter, der Ihre Privatsphäre respektiert, aber dennoch jeden Besucher registriert. Sie sind kein Gast. Sie sind zu Hause. Kein Vermieter, kein Fremder kann es betreten — Sie sind souverän.", + "why-p6": "Ihre Kommunikation gehört Ihnen, so wie es immer war, bevor es das Internet gab. Das Netzwerk ist kein Ort, den Sie besuchen. Es ist ein Ort, den Sie erschaffen und besitzen und Niemand kann es Ihnen nehmen, egal ob Sie es privat oder öffentlich machen.", + "why-p7": "Die älteste Freiheit des Menschen — mit einem anderen Menschen sprechen zu können, ohne beobachtet zu werden — gestützt auf einer Infrastruktur, die Sie nicht verraten kann.", + "why-p8": "Weil wir die Macht zerstört haben, zu wissen, wer Sie sind. Damit Ihnen Ihre Macht niemals genommen werden kann.", + "why-tagline": "Genießen Sie die Freiheit in Ihrem Netzwerk.", + "why-footer-link": "Warum wir es erschaffen haben", + "file": "Datei", + "file-desc": "Versenden Sie Dateien via Ende-zu-Ende-Verschlüsselung — ohne Benutzerkennungen, ohne Tracking.", + "file-noscript": "Für den Datei-Transfer wird JavaScript benötigt.", + "file-e2e-note": "Ende-zu-Ende-verschlüsselt — der Server bekommt Ihre Datei nie zu sehen.", + "file-learn-more": "Erfahren Sie mehr über das XFTP-Protokoll", + "file-cta-heading": "Laden Sie sich die SimpleX Chat App herunter — die sicherste & private Messenger-App", + "file-cta-subheading": "Die Dateiübertragung, die Sie gerade verwendet haben, nutzt dasselbe Datenweiterleitungsprotokoll wie SimpleX Chat. Die App bietet Ende‑zu‑Ende‑verschlüsselte Nachrichten, Sprach‑ und Videoanrufe, Gruppen sowie das Senden von Dateien. Keine Benutzerkennung. Kein Telefon. Keine E‑Mail. Keine Benutzerprofil‑IDs.", + "file-title": "SimpleX Dateiübertragung", + "file-drop-text": "Datei per Drag & Drop hinzufügen", + "file-drop-hint": "oder", + "file-choose": "Datei auswählen", + "file-max-size": "Max. 100 MB — die SimpleX Chat App unterstützt Dateien bis zu 1 GB", + "file-encrypting": "Wird verschlüsselt…", + "file-uploading": "Wird hochgeladen…", + "file-cancel": "Abbrechen", + "file-uploaded": "Datei wurde hochgeladen", + "file-copy": "Kopieren", + "file-copied": "Wurde kopiert!", + "file-share": "Teilen", + "file-expiry": "Dateien sind in der Regel 48 Stunden lang verfügbar.", + "file-sec-1": "Ihre Datei wurde im Browser verschlüsselt — Datenrouter sehen weder Inhalt, Namen noch Größe der Datei.", + "file-sec-2": "Der für die Verschlüsselung genutzte Schlüssel befindet sich im Hash‑Fragment des Links und wird niemals an einen Server übertragen.", + "file-sec-3": "Für noch mehr Sicherheit verwenden Sie die SimpleX Chat App.", + "file-retry": "Wiederholen", + "file-downloading": "Wird heruntergeladen…", + "file-decrypting": "Wird entschlüsselt…", + "file-download-complete": "Herunterladen abgeschlossen", + "file-download-btn": "Herunterladen", + "file-too-large": "Datei ist zu groß (%size%). Das Maximum ist 100MB. Die SimpleX Chat App unterstützt Dateigrößen bis zu 1GB.", + "file-empty": "Datei ist leer.", + "file-invalid-link": "Ungültiger oder beschädigter Link.", + "file-init-error": "Initialisierung fehlgeschlagen: %error%", + "file-available": "Datei verfügbar (~%size%)", + "file-dl-sec-1": "Diese Datei ist verschlüsselt — Datenrouter sehen weder Inhalt, Namen noch Größe der Datei.", + "file-workers-required": "Web Workers werden benötigt — aktualisieren Sie Ihren Browser", + "file-protocol-title": "XFTP-Protokoll: Der sicherste Dateitransfer", + "file-proto-h-1": "Es wird keine Benutzerkennung benötigt", + "file-proto-p-1": "Jedes Dateifragment verwendet einen neuen zufälligen Schlüssel. Datenrouter kennen keine \"Benutzer\" oder \"Dateien\" — sie übertragen nur verschlüsselte Dateifragmente fester Größe.", + "file-proto-h-2": "Dreifach direkt in Ihrem Browser verschlüsselt", + "file-proto-p-2": "Der für die Dateiverschlüsselung genutzte Schlüssel befindet sich ausschließlich im Hash‑Fragment der URL — Ihr Browser sendet ihn niemals an einen Server. Es gibt drei Verschlüsselungsebenen: TLS‑Transport, empfängerbezogene Verschlüsselung (ein eindeutiger, flüchtiger Schlüssel pro Transfer) und Ende‑zu‑Ende‑Verschlüsselung der Datei.", + "file-proto-h-4": "Unabhängig voneinander arbeitende Datenrouter", + "file-proto-p-4": "Wenn die Datei in Fragmente aufgeteilt wurde, wird sie über Netzwerkrouter übertragen, die von unabhängigen Parteien betrieben werden. Kein Betreiber kann die tatsächliche Dateigröße oder den Dateinamen sehen. Selbst wenn ein Router kompromittiert wird, sieht er nur verschlüsselte Fragmente fester Größe. Die Fragmente werden von den Netzwerkroutern für etwa 48 Stunden zwischengespeichert.", + "file-proto-spec": "Lesen Sie die XFTP‑Protokollspezifikation durch →", "send-file": "Datei senden" } diff --git a/website/langs/en.json b/website/langs/en.json index b509e90342..aea1d057d4 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -185,7 +185,7 @@ "simplex-private-section-header": "What makes SimpleX private", "tap-to-close": "Tap to close", "simplex-network-section-header": "SimpleX Network", - "simplex-network-section-desc": "Simplex Chat provides the best privacy by combining the advantages of P2P and federated networks.", + "simplex-network-section-desc": "SimpleX Chat provides the best privacy by combining the advantages of P2P and federated networks.", "simplex-network-1-header": "Unlike P2P networks", "simplex-network-1-desc": "All messages are sent via the servers, both providing better metadata privacy and reliable asynchronous message delivery, while avoiding many", "simplex-network-1-overlay-linktext": "problems of P2P networks", diff --git a/website/langs/es.json b/website/langs/es.json index ac6993c085..5c708ef681 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -83,7 +83,7 @@ "simplex-unique-4-title": "La red SimpleX te pertenece", "hero-overlay-card-1-p-1": "Muchos usuarios se preguntan: si SimpleX no tiene identificadores de usuario, ¿cómo puede saber dónde entregar los mensajes?", "hero-overlay-card-1-p-4": "Este diseño evita que se filtren metadatos de usuario a nivel de aplicación. Para mejorar aún más la privacidad y proteger tu dirección IP, puedes conectarte a los servidores de mensajería a través de la red Tor.", - "hero-overlay-card-1-p-6": "Encontrarás más información en elSimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Encontrarás más información en el SimpleX whitepaper.", "hero-overlay-card-2-p-1": "Cuando los usuarios asumen identidades persistentes, aunque sólo se trate de un número aleatorio como un identificador de sesión, existe el riesgo de que el proveedor de red o un atacante puedan observar cómo se conectan y la cantidad de mensajes que envían.", "hero-overlay-card-2-p-2": "A continuación podrían correlacionar esta información con las redes sociales públicas existentes y averiguar las identidades reales de los usuarios.", "hero-overlay-card-2-p-3": "Incluso con las aplicaciones más privadas que usan los servicios de Tor v3, si hablas con dos contactos a través de un mismo perfil se puede probar que estos están conectados con la misma persona.", @@ -199,7 +199,7 @@ "scan-the-qr-code-with-the-simplex-chat-app-description": "Las claves públicas y la dirección de la cola de mensajes de este enlace NO se envían a través de la red cuando ves esta página.
Están contenidos en el fragmento hash del URL del enlace.", "privacy-matters-section-header": "Por qué es importante la privacidad", "simplex-private-section-header": "Qué hace que SimpleX sea privado", - "simplex-network-section-desc": "Simplex Chat proporciona la mejor privacidad al combinar las ventajas de P2P y las redes federadas.", + "simplex-network-section-desc": "SimpleX Chat proporciona la mejor privacidad al combinar las ventajas de P2P y las redes federadas.", "simplex-network-1-header": "Distinta de las redes P2P", "simplex-network-2-header": "Distinta de las redes federadas", "simplex-network-2-desc": "Los servidores de SimpleX NO almacenan perfiles de usuario, contactos, o mensajes entregados. NO contactan entre sí y NO existe un directorio de servidores.", @@ -235,7 +235,7 @@ "stable-and-beta-versions-built-by-developers": "Versiones estables y beta compilados por los desarrolladores", "f-droid-page-simplex-chat-repo-section-text": "Para añadirlo al cliente F-Droid, escanea el código QR o usa esta URL:", "docs-dropdown-8": "Directorio SimpleX", - "simplex-chat-repo": "Repositorio Simplex Chat", + "simplex-chat-repo": "Repositorio SimpleX Chat", "simplex-chat-via-f-droid": "SimpleX Chat en F-Droid", "f-droid-org-repo": "Repositorio F-Droid.org", "stable-versions-built-by-f-droid-org": "Versión estable compilada por F-Droid.org", @@ -278,12 +278,12 @@ "index-messaging-p2": "Para tu seguridad y privacidad los servidores no pueden ver tus mensajes ni con quién te comunicas.", "index-messaging-cta": "Descubre más sobre la mensajería SimpleX", "index-nextweb-h2": "La Web
Del Futuro
Te Pertenece", - "index-nextweb-p1": "SimpleX se funda en la creencia de que debes ser el propietario de tu identidad, contactos y comunidades.", - "index-nextweb-p2": "Una red abierta y descentralizada que te permite conectarte con personas y compartir ideas: libre y segura.", + "index-nextweb-p1": "SimpleX se origina en la creencia de que debes ser el propietario de tus perfiles, contactos y comunidades.", + "index-nextweb-p2": "Una red descentralizada que no pertenece a nadie, que te permite conectar con personas y compartir ideas de manera libre y segura en tu red.", "index-token-h2": "Comunidades Duraderas", "index-token-p1": "Podrás apoyar a tus grupos favoritos con los futuros Vales Comunitarios.", "index-token-p2": "Los vales costearán los servidores para que tus comunidades sigan siendo libres e independientes.", - "index-token-cta": "Descubre más y obtén tu NFT gratuito por participar en las pruebas.", + "index-token-cta": "Descubre más y obtén acceso gratuito para participar en las pruebas iniciales.", "index-roadmap-h2": "Ruta SimpleX hacía el Internet Libre", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Escalar a Comunidades Grandes", @@ -314,5 +314,59 @@ "messengers-comparison-section-list-point-6": "El acuerdo de claves postcuántico es \"parcial\", solo protege determinados pasos del ratchet.", "navbar-token": "Token", "navbar-old-site": "Web antigua", + "why-p1": "Naciste sin una cuenta.", + "why-p2": "Nadie monitorizaba tus conversaciones. Nadie registraba tus ubicaciones. La privacidad nunca fue un lujo, era la manera de vivir.", + "why-p3": "Después pasamos a internet y cada plataforma pedía una parte de tí: tu nombre, tu número, tus amistades. Aceptamos que el precio de hablar con los demás es informar a alguien de quién es interlocutor. Cada generación, personas y tecnología, ha funcionado así: teléfono, email, mensajería, redes sociales. Parecía el único camino.", + "why-p4": "Existe otro camino. Una red sin números de teléfono. Sin nombres de usuario. Sin cuentas. Sin identificadores de ningún tipo. Una red que conecta las personas y entrega mensajes cifrados sin saber quien está conectado.", + "why-p5": "No un candado mejorado en la puerta de otro. No un terrateniente que respeta tu privacidad pero sigue guardando un registro de tus visitantes. Tu no eres el invitado. Estás en tu casa y ningún rey podrá entrar. Tu eres el soberano.", + "why-p6": "Tus conversaciones te pertenecen, tal como ha sido siempre antes de la llegada de internet. Tu red no es un lugar que visitas. Es un lugar que has creado, te pertenece y nadie te la podrá quitar, ya sea pública o privada.", + "why-p7": "La libertad más antigua del ser humano, la de hablar con otra persona sin ser observado, materializada sobre una infraestructura que no puede traicionarla.", + "why-p8": "Porque hemos destruido el poder de saber quien eres. De manera que tu poder nunca se pueda arrebatar.", + "why-tagline": "Se libre en tu red.", + "why-footer-link": "Por qué la estamos creando", + "docs-dropdown-15": "Verificar y reproducir compilaciones", + "file": "Archivo", + "file-desc": "Envía archivos cifrados de extremo a extremo de forma segura - sin cuentas, sin ser monitorizado.", + "file-noscript": "Se requiere JavaScript para transferir archivos.", + "file-e2e-note": "Cifrado de extremo a extremo, el servidor nunca ve tus archivos.", + "file-learn-more": "Descubre más sobre el protocolo XFTP", + "file-cta-heading": "Descarga SimpleX Chat — el mensajero privado & más seguro", + "file-title": "Transferencia de archivos SimpleX", + "file-drop-text": "Arrastra y suelta el archivo aquí", + "file-drop-hint": "o", + "file-choose": "Seleccionar archivo", + "file-max-size": "Max 100 MB - La app SimpleX Chat soporta archivos hasta 1GB", + "file-encrypting": "Cifrando…", + "file-uploading": "Subiendo…", + "file-cancel": "Cancelar", + "file-uploaded": "Archivo subido", + "file-copy": "Copiar", + "file-copied": "¡Copiado!", + "file-share": "Compartir", + "file-expiry": "Normalmente los archivos están disponibles durante 48 horas.", + "file-sec-1": "Tu archivo se ha cifrado en el navegador, los routers de datos nunca ven el contenido, el nombre o el tamaño.", + "file-sec-2": "La clave de cifrado está en el fragmento hash del enlace, nunca se envía a ningún servidor.", + "file-sec-3": "Para mejor seguridad, usa la app SimpleX Chat.", + "file-retry": "Reintentar", + "file-downloading": "Descargando…", + "file-decrypting": "Descifrando…", + "file-download-complete": "Descarga completada", + "file-download-btn": "Descargar", + "file-too-large": "Archivo demasiado grande (%size%). Máximo 100MB. La app SimpleX soporta archivos de hasta 1GB.", + "file-empty": "El archivo está vacío.", + "file-invalid-link": "Enlace corrupto o no válido.", + "file-init-error": "Fallo al inicializar: %error%", + "file-available": "Archivo disponible (~%size%)", + "file-dl-sec-1": "El archivo está cifrado, los routers de datos no ven el contenido, el nombre o el tamaño.", + "file-workers-required": "Web Workers requerido, actualiza el navegador", + "file-protocol-title": "Protocolo XFTP, la transferencia de archivos más segura", + "file-proto-h-1": "No se necesita cuenta", + "file-proto-p-1": "Cada fragmento del archivo usa una clave aleatoria. Los routers de datos no tienen \"usuarios\" o \"archivos\", solo transportan los fragmentos cifrados con un tamaño fijo.", + "file-proto-h-2": "Triple cifrado en tu navegador", + "file-proto-p-2": "La clave de cifrado está presente solo en el fragmento hash de la URL, tu navegador nunca lo envía a un servidor. Hay tres capas de cifrado: transporte TLS, clave por transferencia (clave única y temporal por cada transferencia), y cifrado de extremo a extremo del archivo.", + "file-proto-h-4": "Routers de datos independientes", + "file-proto-p-4": "Cuando un archivo se divide en fragmentos, se envía a través de routers en la red gestionados por terceros independientes. Ningún operador puede ver el nombre o el tamaño real del archivo. Incluso si un enrutador se ve comprometido, solo puede ver fragmentos cifrados de tamaño fijo. Los fragmentos de los archivos se almacenan por los routers de la red durante aproximadamente 48 horas.", + "file-proto-spec": "Sobre las especificaciones del protocolo XFTP →", + "file-cta-subheading": "La transferencia de archivos que acabas de usar emplea el mismo protocolo de enrutamiento de datos que SimpleX Chat. La aplicación ofrece mensajería, llamadas de voz y video, grupos y envío de archivos con cifrado de extremo a extremo. Sin cuentas. Sin teléfono. Sin correo electrónico. Sin identificadores de usuario.", "send-file": "Enviar archivo" } diff --git a/website/langs/fi.json b/website/langs/fi.json index e1e31c633b..50f10331cf 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -213,7 +213,7 @@ "privacy-matters-section-header": "Miksi yksityisyys on tärkeää", "tap-to-close": "Napauta sulkeaksesi", "simplex-network-section-header": "SimpleX Verkko", - "simplex-network-section-desc": "Simplex Chat tarjoaa parhaan yksityisyyden yhdistämällä P2P-verkkojen ja liitettävien verkkojen edut.", + "simplex-network-section-desc": "SimpleX Chat tarjoaa parhaan yksityisyyden yhdistämällä P2P-verkkojen ja liitettävien verkkojen edut.", "simplex-network-1-desc": "Kaikki viestit lähetetään palvelimien kautta, mikä sekä parantaa metatietojen yksityisyyttä että mahdollistaa luotettavan asynkronisen viestien toimituksen, samalla välttäen monia", "simplex-network-2-header": "Toisin kuin liitettävät verkot", "simplex-network-3-desc": "palvelimet tarjoavat yksisuuntaisia jonopalveluja yhdistääkseen käyttäjät, mutta niillä ei ole näkyvyyttä verkon yhteyskarttaan — ainoastaan käyttäjillä on.", diff --git a/website/langs/fr.json b/website/langs/fr.json index 8f87809224..b2551990fd 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -177,7 +177,7 @@ "simplex-private-section-header": "Ce qui rend SimpleX privé", "tap-to-close": "Appuyez pour fermer", "simplex-network-section-header": "Réseau SimpleX", - "simplex-network-section-desc": "Simplex Chat offre la meilleure protection pour votre vie privée en combinant les avantages du P2P et des réseaux fédérés.", + "simplex-network-section-desc": "SimpleX Chat offre la meilleure protection pour votre vie privée en combinant les avantages du P2P et des réseaux fédérés.", "simplex-network-1-header": "Contrairement aux réseaux P2P", "simplex-network-1-desc": "Tous les messages sont envoyés via les serveurs, offrant à la fois une meilleure protection des métadonnées et une livraison asynchrone fiable des messages, tout en évitant de nombreux", "simplex-network-1-overlay-linktext": "problèmes des réseaux P2P", diff --git a/website/langs/he.json b/website/langs/he.json index 0af1afaec3..9d72680c84 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -122,7 +122,7 @@ "simplex-chat-for-the-terminal": "SimpleX Chat עבור מסוף שורת הפקודה", "simplex-network-overlay-card-1-li-3": "P2P אינו פותר את בעיית התקפת MITM, ורוב ההטמעות הקיימות אינן משתמשות בהודעות out-of-band עבור החלפת המפתחות הראשונית. SimpleX משתמש בהודעות out-of-band או, במקרים מסוימים, בחיבורים מאובטחים ומהימנים קיימים מראש לצורך החלפת המפתחות הראשונית.", "the-instructions--source-code": "לקבלת ההוראות כיצד להוריד או לקמפל אותו מקוד המקור.", - "simplex-network-section-desc": "Simplex Chat מספק את הפרטיות הטובה ביותר על ידי שילוב היתרונות של P2P ורשתות מאוחדות.", + "simplex-network-section-desc": "SimpleX Chat מספק את הפרטיות הטובה ביותר על ידי שילוב היתרונות של P2P ורשתות מאוחדות.", "privacy-matters-section-subheader": "שמירה על פרטיות המטא נתונים שלכם — עם מי אתם מדברים — מגנה עליכם מפני:", "if-you-already-installed": "אם כבר התקנתם", "join": "הצטרפו", diff --git a/website/langs/hu.json b/website/langs/hu.json index c5150cabf9..5dda790d96 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -44,10 +44,10 @@ "feature-7-title": "Hordozható, titkosított alkalmazás-adattárolás — profil átköltöztetése egy másik eszközre", "feature-8-title": "Az inkognitómód —
egyedülálló a SimpleX Chatben", "simplex-network-overlay-1-title": "Összehasonlítás más P2P-üzenetküldő protokollokkal", - "simplex-private-1-title": "2 rétegű végpontok közötti titkosítás", + "simplex-private-1-title": "Kétrétegű végpontok közötti titkosítás", "simplex-private-2-title": "További rétege a
kiszolgáló-titkosítás", "simplex-private-4-title": "Hozzáférés a Tor hálózaton keresztül
(nem kötelező)", - "simplex-private-5-title": "Több rétegű
tartalomkitöltés", + "simplex-private-5-title": "Többrétegű
tartalomkitöltés", "simplex-private-6-title": "Sávon kívüli
kulcscsere", "simplex-private-7-title": "Üzenetintegritás
hitelesítés", "simplex-private-8-title": "Üzenetek keverése
a korreláció csökkentése érdekében", @@ -55,7 +55,7 @@ "simplex-private-10-title": "Ideiglenes, névtelen, páronkénti azonosítók", "simplex-private-card-1-point-1": "Dupla racsnis protokoll —
OTR-üzenetküldés, kompromittálás előtti és utáni titkosság-védelemmel.", "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes várólistához, hogy megakadályozza a forgalom korrelációját az üzenetek várólistái között, ha a TLS veszélybe kerül.", - "simplex-private-card-2-point-1": "Kiegészítő kiszolgáló titkosítási réteg a címzettnek történő kézbesítéshez, hogy megakadályozza a fogadott és az elküldött kiszolgálóforgalom közötti korrelációt, ha a TLS veszélybe kerül.", + "simplex-private-card-2-point-1": "Kiegészítő kiszolgálótitkosítási réteg a címzettnek történő kézbesítéshez, hogy megakadályozza a fogadott és az elküldött kiszolgálóforgalom közötti korrelációt, ha a TLS veszélybe kerül.", "simplex-private-card-3-point-1": "A kliens és a kiszolgálók közötti kapcsolatokhoz csak az erős algoritmusokkal rendelkező TLS 1.2/1.3 protokollt használja.", "simplex-private-card-3-point-2": "A kiszolgáló ujjlenyomata és a csatornakötés megakadályozza a MITM- és a visszajátszási támadásokat.", "simplex-private-card-3-point-3": "Az újrakapcsolódás le van tiltva a munkamenet elleni támadások megelőzése érdekében.", @@ -88,7 +88,7 @@ "hero-overlay-card-1-p-1": "Sok felhasználó kérdezte: ha a SimpleXnek nincsenek felhasználói azonosítói, honnan tudja, hogy hová kell eljuttatni az üzeneteket?", "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek várólistába rendezéséhez ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz Tor hálózaton keresztül is kapcsolódhat.", - "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, partnereket és csoportokat; az üzenetek küldése 2 rétegű végpontok közötti titkosítással történik.", + "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, partnereket és csoportokat; az üzenetek küldése kétrétegű végpontok közötti titkosítással történik.", "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", "hero-overlay-card-2-p-1": "Ha a felhasználók állandó azonosítóval rendelkeznek, még akkor is, ha ez csak egy véletlenszerű szám, például egy munkamenet-azonosító, fennáll annak a veszélye, hogy a szolgáltató vagy egy támadó megfigyelheti, azt hogy hogyan kapcsolódnak a felhasználók egymáshoz, és hány üzenetet küldenek egymásnak.", "hero-overlay-card-2-p-2": "Ezt az információt aztán összefüggésbe hozhatják a meglévő nyilvános közösségi hálózatokkal, és meghatározhatnak néhány valódi személyazonosságot.", @@ -168,7 +168,7 @@ "privacy-matters-section-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataihoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", "simplex-network-section-header": "SimpleX hálózat", - "simplex-network-section-desc": "A Simplex Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", + "simplex-network-section-desc": "A SimpleX Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálójegyzék.", @@ -266,7 +266,7 @@ "index-security-review-2024-title": "Biztonsági audit 2024", "index-security-audits-label": "Biztonsági
auditok", "index-publications-heise-title": "A Heise Online kiadványai", - "index-hero-h2": "A Saját Hálózatában", + "index-hero-h2": "Az Ön hálózatában", "index-testflight-title": "Nyilvános betekintés az iOS alkalmazás fejlesztésébe a TestFlighton", "index-f-droid-title": "SimpleX alkalmazás az F-Droidon keresztül", "index-publications-privacy-guides-title": "A Privacy Guides üzenetváltó ajánlásai", @@ -278,8 +278,8 @@ "index-messaging-p2": "A biztonsága és magánszférájának védelme érdekében a kiszolgálók nem látják az üzeneteit, és azt sem, hogy kivel beszélget.", "index-messaging-cta": "Tudjon meg többet a SimpleX üzenetváltó alkalmazásról", "index-nextweb-h2": "Vegye birtokba
A jövő hálózatát", - "index-nextweb-p1": "A SimpleX arra a meggyőződésre épül, hogy a felhasználóknak kell birtokolnia a saját identitásukat, valamint a kapcsolataikat a partnereikkel és a közösségeikkel.", - "index-nextweb-p2": "A nyílt és decentralizált hálózat lehetővé teszi, hogy kapcsolatba lépjen másokkal és ötleteket osszon meg: legyen szabad biztonságban.", + "index-nextweb-p1": "A SimpleX abból a meggyőződésből jött létre, hogy a profilok, a kapcsolatok és a közösségek a felhasználók tulajdonát kell, hogy képezzék.", + "index-nextweb-p2": "Egy decentralizált hálózat, amelyet senki sem birtokol, lehetővé teszi a kapcsolatok létrehozását és az ötleteket megosztását szabadon és biztonságosan a hálózaton.", "index-token-h2": "Időtálló közösségek", "index-token-p1": "A jövőben közösségi utalványokkal támogathatja a kedvenc csoportjait.", "index-token-p2": "Az utalványokkal fizetni tudja a kiszolgálókat, hogy a közösségek szabadok és függetlenek maradhassanak.", @@ -314,5 +314,59 @@ "messengers-comparison-section-list-point-6": "A kvantumbiztos kulcscsere „ritka” — csak a racsnis lépések egy részét védi.", "navbar-token": "Token", "navbar-old-site": "Régi oldal", + "docs-dropdown-15": "Összeállítások ellenőrzése és reprodukálása", + "why-p2": "Senki sem követi nyomon a beszélgetéseit. Senki sem készít térképet az Ön kapcsolati hálójáról. A magánélet nem csak egy funkció, hanem egy életmód.", + "why-p3": "Amikor online vagyunk minden platform egy darabot kér tőlünk – nevet, telefonszámot, baráti kapcsolatokat. Elfogadtuk, hogy a kommunikáció ára az, hogy mások megtudják, hogy kivel beszélünk. Minden generáció, az emberek és a technológia is eddig így működött – telefon, e-mail, üzenetküldő programok, közösségi média. Úgy tűnt, ez az egyetlen lehetséges mód.", + "why-p4": "De van egy másik lehetőség is. Egy hálózat, amelyben nincsenek telefonszámok. Nincsenek felhasználónevek. Nincsenek fiókok. Nincsenek semmiféle felhasználói azonosítók. Egy hálózat, amely összeköti az embereket és titkosított üzeneteket továbbít, anélkül, hogy tudná, ki csatlakozik hozzá.", + "why-p5": "Nem egy jobb zár mások ajtaján. Nem egy kedvesebb házmester, aki tiszteletben tartja az Ön magánéletét, de mégis nyilvántartást vezet minden látogatójáról. Ön itt nem csak egy vendég. Ön itt otthon van. Egyetlen „kíváncsiskodó” sem tekinthet bele a beszélgetéseibe, Ön itt szuverén.", + "why-p6": "A beszélgetései Önhöz tartoznak, ahogy az internet megjelenése előtt is mindig így volt. A hálózat nem egy hely, amelyet meglátogat. Ez egy olyan hely, amelyet Ön hoz létre saját magának. És senki sem veheti el Öntől, függetlenül attól, hogy privát vagy nyilvános.", + "why-p7": "A legrégebbi emberi szabadság – beszélgetni az emberekkel anélkül, hogy mások megfigyelnének – olyan infrastruktúrán alapul, amely nem tudja elárulni.", + "why-p8": "Mert elpusztítottuk azt az erőt, amellyel megtudhatnánk, hogy Ön kicsoda. Hogy az Ön ereje soha ne kerülhessen mások kezébe.", + "why-tagline": "Legyen szabad a saját hálózatában.", + "why-footer-link": "Miért készítjük", + "why-p1": "Ön fiók nélkül született.", + "file": "Fájl", + "file-desc": "Fájlok biztonságos küldése végpontok közötti titkosítással – felhasználói fiókok és nyomon követés nélkül.", + "file-noscript": "A fájlátvitelhez JavaScript szükséges.", + "file-e2e-note": "Végpontok közötti titkosítás – a kiszolgáló soha nem „látja” a fájlt.", + "file-learn-more": "Tudjon meg többet az XFTP-protokollról", + "file-cta-heading": "Szerezze be a SimpleX Chat alkalmazást – a legbiztonságosabb és legbizalmasabb üzenetváltó programot", + "file-cta-subheading": "Az imént használt fájlátvitel ugyanazt az útválasztó protokollt használja, mint a SimpleX Chat alkalmazás. Az alkalmazás végpontok közötti titkosított üzenetküldést, hang- és videohívásokat, csoportos csevegéseket és fájlok küldését teszi lehetővé. Nem szükséges hozzá felhasználói fiók, telefonszám, e-mail-cím és nem használ felhasználói profilazonosítókat sem.", + "file-title": "SimpleX fájlátvitel", + "file-drop-text": "Húzzon ide", + "file-drop-hint": "vagy", + "file-choose": "válasszon ki egy fájlt", + "file-max-size": "Legfeljebb 100 MB – a SimpleX Chat alkalmazás viszont 1 GB méretű fájlokat is támogat", + "file-encrypting": "Titkosítás…", + "file-uploading": "Feltöltés…", + "file-cancel": "Mégse", + "file-uploaded": "Fájl feltöltve", + "file-copy": "Másolás", + "file-copied": "Másolva!", + "file-share": "Megosztás", + "file-expiry": "A fájlok általában 48 óráig érhetők el.", + "file-sec-1": "A fájl a böngészőben lett titkosítva – az útválasztók soha nem „látják” a fájl tartalmát, nevét és méretét.", + "file-sec-2": "A titkosítási kulcs a hivatkozás kivonattöredékében található – soha nem kerül elküldésre semmilyen kiszolgálóra.", + "file-sec-3": "A nagyobb biztonság érdekében használja a SimpleX Chat alkalmazást.", + "file-retry": "Újra", + "file-downloading": "Letöltés…", + "file-decrypting": "Visszafejtés…", + "file-download-complete": "Letöltés kész", + "file-download-btn": "Letöltés", + "file-too-large": "A fájl túl nagy (%size%). A megengedett feltölthető méret 100 MB. Az ettől nagyobb fájlok küldéséhez használja a SimpleX Chat alkalmazást, ami legfeljebb 1 GB méretű fájlokat is támogat.", + "file-empty": "A fájl üres.", + "file-invalid-link": "Érvénytelen vagy sérült hivatkozás.", + "file-init-error": "Nem sikerült előkészíteni: %error%", + "file-available": "A fájl elérhető (~%size%)", + "file-dl-sec-1": "Ez a fájl titkosítva van – az útválasztók soha nem „látják” a fájl tartalmát, nevét és méretét.", + "file-workers-required": "Web Workers szükséges – frissítse a böngészőjét", + "file-protocol-title": "XFTP-protokoll: a legbiztonságosabb fájlátvitel", + "file-proto-h-1": "Nincs szükség felhasználói fiókra", + "file-proto-p-1": "Minden fájldarab új, véletlenszerű kulcsot használ. Az útválasztóknak nincsenek „felhasználóik” vagy „fájljaik” – rögzített méretű titkosított fájldarabokat továbbítanak.", + "file-proto-h-2": "Háromrétegű titkosítás a böngészőben", + "file-proto-p-2": "A fájl titkosítási kulcsa csak a webcím kivonattöredékében található – a böngésző soha nem küldi el azt a kiszolgálónak. A fájlok háromrétegű titkosítással rendelkeznek: TLS-átviteli, címzettenkénti (egyedi, ideiglenes kulcs átvitelenként) és végpontok közötti titkosítással.", + "file-proto-h-4": "Független útválasztók", + "file-proto-p-4": "Amikor a fájl töredékekre oszlik, akkor a független felek által üzemeltetett hálózati útválasztókon keresztül kerül továbbításra. Egyetlen üzemeltető sem láthatja a fájl tényleges méretét és nevét. Még ha egy útválasztó biztonsága meg is sérül, csak a rögzített méretű titkosított töredékeket „láthatja”. A fájltöredékeket a hálózati útválasztók körülbelül 48 órán át tárolják a gyorsítótárban.", + "file-proto-spec": "Olvassa el az XFTP-protokoll leírását →", "send-file": "Fájl küldése" } diff --git a/website/langs/it.json b/website/langs/it.json index 5b292a30a0..c7f6679a74 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -108,7 +108,7 @@ "privacy-matters-section-subheader": "Preservare la privacy dei tuoi metadati — con chi parli — ti protegge da:", "privacy-matters-section-label": "Assicurati che il tuo messenger non possa accedere ai tuoi dati!", "simplex-network-section-header": "Rete di SimpleX", - "simplex-network-section-desc": "Simplex Chat offre la migliore privacy combinando i vantaggi del P2P e delle reti federate.", + "simplex-network-section-desc": "SimpleX Chat offre la migliore privacy combinando i vantaggi del P2P e delle reti federate.", "simplex-network-1-header": "A differenza delle reti P2P", "simplex-network-1-overlay-linktext": "problemi delle reti P2P", "simplex-network-2-header": "A differenza delle reti federate", @@ -258,7 +258,7 @@ "docs-dropdown-14": "SimpleX per il lavoro", "about-and-contact-us": "Informazioni e contatti", "directory": "Directory", - "index-hero-h2": "Nella Tua Rete", + "index-hero-h2": "nella tua rete", "index-hero-p1": "Messaggistica privata e sicura.
La prima rete in cui possiedi
i tuoi contatti e i tuoi gruppi.", "index-hero-download-desktop-btn-title": "Scarica l'app desktop di SimpleX", "index-testflight-title": "Anteprima pubblica per iOS su TestFlight", @@ -278,12 +278,12 @@ "index-messaging-p2": "Per la tua sicurezza e privacy, i server non possono vedere i messaggi e con chi parli.", "index-messaging-cta": "Scopri di più sui messaggi di SimpleX", "index-nextweb-h2": "Il nuovo web
è tuo", - "index-nextweb-p1": "SimpleX è fondato sulla convinzione che devi possedere la tua identità, i tuoi contatti e le tue comunità.", - "index-nextweb-p2": "La rete aperta e decentralizzata consente di connetterti con persone e condividere idee: sii libero e al sicuro.", + "index-nextweb-p1": "SimpleX è stato creato sulla convinzione che devi possedere i tuoi profili, contatti e comunità.", + "index-nextweb-p2": "Una rete decentralizzata che nessuno possiede consente di connetterti con persone e condividere idee, di restare libero e sicuro nella tua rete.", "index-token-h2": "Comunità fatte per restare", "index-token-p1": "Sosterrai i tuoi gruppi preferiti con futuri buoni comunitari.", "index-token-p2": "I buoni pagheranno i server, per consentire alle tue comunità di rimanere libere e indipendenti.", - "index-token-cta": "Scopri di più e ricevi un NFT gratuito per provarlo in anticipo.", + "index-token-cta": "Scopri di più e ricevi un pass di accesso gratuito per provarlo in anticipo.", "index-roadmap-h2": "Tabella di marcia per un internet libero", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Scalabilità per comunità numerose", @@ -314,5 +314,59 @@ "messengers-comparison-section-list-point-6": "L'accordo sulle chiavi post-quantistico è “scarno” — protegge solo alcuni dei passaggi del ratchet.", "navbar-token": "Token", "navbar-old-site": "Sito vecchio", + "docs-dropdown-15": "Verifica e riproduci le build", + "why-p2": "Nessuno monitorava le tue conversazioni. Nessuno disegnava una mappa delle tue posizioni. La privacy non era mai una caratteristica, era uno stile di vita.", + "why-p3": "Poi ci siamo trasferiti online e ogni piattaforma ha chiesto un pezzo di noi: il nome, il numero, gli amici. Abbiamo accettato che il prezzo da pagare per comunicare con gli altri fosse quello di far sapere a qualcuno con chi parliamo. Ogni generazione, sia le persone che la tecnologia, ha funzionato così: telefono, email, messenger, social media. Sembrava l'unica via possibile.", + "why-p1": "Sei nato senza un account.", + "why-p4": "C'è un altro modo. Una rete senza numeri di telefono. Senza nomi utente. Senza account. Senza identificatori utente di alcun tipo. Una rete che connette le persone e trasferisce messaggi crittografati senza sapere chi è connesso.", + "why-p5": "Non una serratura migliore sulla porta di qualcun altro. Non un padrone di casa più gentile che rispetta la tua privacy, ma che continua a tenere traccia di tutti i visitatori. Non sei un ospite. Sei a casa tua. Nessun re può entrarvi: sei tu il sovrano.", + "why-p6": "Le tue conversazioni appartengono a te, come è sempre stato prima dell'avvento di Internet. La rete non è un luogo che visiti. È un luogo che crei e possiedi. E nessuno può portartelo via, sia che tu lo renda privato o pubblico.", + "why-p7": "La più antica libertà umana, parlare con un'altra persona senza essere osservati, si basa su un'infrastruttura che non può tradirla.", + "why-p8": "Perché abbiamo distrutto il potere di sapere chi sei. In modo che il tuo potere non possa mai essere sottratto.", + "why-tagline": "Vivi libero nella tua rete.", + "why-footer-link": "Perché lo stiamo costruendo", + "file": "File", + "file-desc": "Invia file in modo sicuro con crittografia end-to-end: nessun account, nessun monitoraggio.", + "file-noscript": "JavaScript è necessario per il trasferimento di file.", + "file-e2e-note": "Crittografato end-to-end: il server non vede mai il file.", + "file-learn-more": "Maggiori informazioni sul protocollo XFTP", + "file-cta-heading": "Scarica SimpleX Chat — il messenger più sicuro e privato", + "file-cta-subheading": "Il trasferimento di file appena avviato usa lo stesso protocollo di instradamento dati di SimpleX Chat. L'app offre messaggi crittografati end-to-end, chiamate vocali e video, gruppi e invio di file. Nessun account. Nessun telefono. Nessuna email. Nessun ID utente.", + "file-title": "Trasferimento file di SimpleX", + "file-drop-text": "Trascina un file qui", + "file-drop-hint": "o", + "file-choose": "Scegli file", + "file-max-size": "Max 100 MB - L'app SimpleX Chat supporta file fino a 1 GB", + "file-encrypting": "Crittografia…", + "file-uploading": "Caricamento…", + "file-cancel": "Annulla", + "file-uploaded": "File caricato", + "file-copy": "Copia", + "file-copied": "Copiato!", + "file-share": "Condividi", + "file-expiry": "I file sono generalmente disponibili per 48 ore.", + "file-sec-1": "Il file è stato crittografato nel browser: gli instradatori di dati non vedono mai il contenuto, il nome o la dimensione del file.", + "file-sec-2": "La chiave di crittografia è nel frammento hash del link, non viene mai inviata ad alcun server.", + "file-sec-3": "Per una migliore sicurezza, usa l'app SimpleX Chat.", + "file-retry": "Riprova", + "file-downloading": "Scaricamento…", + "file-decrypting": "Decifrazione…", + "file-download-complete": "Scaricamento completato", + "file-download-btn": "Scarica", + "file-too-large": "File troppo grande (%size%). Il massimo è 100 MB. L'app SimpleX supporta file fino a 1 GB.", + "file-empty": "Il file è vuoto.", + "file-invalid-link": "Link non valido o danneggiato.", + "file-init-error": "Inizializzazione fallita: %error%", + "file-available": "File disponibile (~%size%)", + "file-dl-sec-1": "Questo file è crittografato: gli instradatori di dati non vedono mai il contenuto del file, il nome o la dimensione.", + "file-workers-required": "Web worker necessari, aggiorna il browser", + "file-protocol-title": "Protocollo XFTP: il trasferimento di file più sicuro", + "file-proto-h-1": "Nessun account richiesto", + "file-proto-p-1": "Ogni frammento di file usa una nuova chiave casuale. Gli instradatori di dati non hanno \"utenti\" o \"file\": trasferiscono frammenti di file crittografati di dimensioni fisse.", + "file-proto-h-2": "Crittografia tripla nel tuo browser", + "file-proto-p-2": "La chiave di crittografia dei file è presente solo nel frammento dell'hash dell'URL: il browser non la invia mai a un server. Ci sono 3 strati di crittografia: trasporto TLS, crittografia per-destinatario (chiave effimera unica per trasferimento) e crittografia file end-to-end.", + "file-proto-h-4": "Instradatori indipendenti di dati", + "file-proto-p-4": "Quando il file è diviso in frammenti, viene inviato tramite instradatori di rete operati da parti indipendenti. Nessun operatore può vedere la vera dimensione o il nome del file. Anche se un instradatore venisse compromesso, potrà vedere solo frammenti cifrati di dimensione fissa. I frammenti di file restano in cache dagli instradatori di rete per circa 48 ore.", + "file-proto-spec": "Leggi le specifiche del protocollo XFTP →", "send-file": "Invia file" } diff --git a/website/langs/ja.json b/website/langs/ja.json index d3ce009eff..2337ed472d 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -95,7 +95,7 @@ "simplex-chat-for-the-terminal": "ターミナル用 SimpleX チャット", "simplex-network-overlay-card-1-li-3": "P2P は MITM 攻撃 問題を解決せず、既存の実装のほとんどは最初の鍵交換に帯域外メッセージを使用していません 。 SimpleX は、最初のキー交換に帯域外メッセージを使用するか、場合によっては既存の安全で信頼できる接続を使用します。", "the-instructions--source-code": "ソースコードからダウンロードまたはコンパイルする方法を説明します。", - "simplex-network-section-desc": "Simplex Chat は、P2P とフェデレーション ネットワークの利点を組み合わせて最高のプライバシーを提供します。", + "simplex-network-section-desc": "SimpleX Chat は、P2P とフェデレーション ネットワークの利点を組み合わせて最高のプライバシーを提供します。", "privacy-matters-section-subheader": "メタデータのプライバシーを保護する — 話す相手 — 以下のことからあなたを守ります:", "if-you-already-installed": "すでにインストールしている場合", "join": "参加", diff --git a/website/langs/nl.json b/website/langs/nl.json index c0acba7cbd..613e07ff5c 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -146,7 +146,7 @@ "privacy-matters-section-subheader": "Behoud van de privacy van uw metadata — met wie u praat — beschermt u tegen:", "simplex-private-section-header": "Wat maakt SimpleX privé", "simplex-network-section-header": "SimpleX Netwerk", - "simplex-network-section-desc": "Simplex Chat biedt de beste privacy door de voordelen van P2P en gefedereerde netwerken te combineren.", + "simplex-network-section-desc": "SimpleX Chat biedt de beste privacy door de voordelen van P2P en gefedereerde netwerken te combineren.", "simplex-network-1-header": "In tegenstelling tot P2P netwerken", "simplex-network-1-desc": "Alle berichten worden verzonden via servers, die zorgen voor een betere metadata privacy en een betrouwbare asynchrone bezorging van berichten, terwijl er veel word vermeden", "simplex-network-1-overlay-linktext": "van problemen met P2P netwerken", diff --git a/website/langs/pl.json b/website/langs/pl.json index e14274a408..37582f80e3 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -17,7 +17,7 @@ "donate": "Darowizna", "copyright-label": "© 2020-2025 SimpleX Chat | Projekt Open-Source", "simplex-chat-protocol": "Protokół SimpleX Chat", - "terminal-cli": "Terminal CLI", + "terminal-cli": "Terminal wiersza poleceń", "terms-and-privacy-policy": "Polityka prywatności", "hero-header": "Prywatność zdefiniowana na nowo", "hero-subheader": "Pierwszy komunikator
bez identyfikatorów użytkowników (ID)", @@ -191,7 +191,7 @@ "copy-the-command-below-text": "skopiuj poniższe polecenie i użyj go na czacie:", "privacy-matters-section-label": "Upewnij się, że Twój komunikator nie ma dostępu do Twoich danych!", "simplex-network-1-header": "W przeciwieństwie do sieci P2P", - "simplex-network-section-desc": "Simplex Chat zapewnia najlepszą prywatność dzięki połączeniu zalet sieci P2P i sieci federacyjnych.", + "simplex-network-section-desc": "SimpleX Chat zapewnia najlepszą prywatność dzięki połączeniu zalet sieci P2P i sieci federacyjnych.", "simplex-network-2-header": "W przeciwieństwie do sieci federacyjnych", "simplex-private-section-header": "Co sprawia, że SimpleX jest prywatny", "tap-to-close": "Stuknij, aby zamknąć", @@ -279,12 +279,12 @@ "index-messaging-p2": "Dla Twojego bezpieczeństwa i prywatności, serwery nie mogą zobaczyć wiadomości i tego z kim rozmawiasz.", "index-messaging-cta": "Dowiedz się więcej o komunikatorze SimpleX", "index-nextweb-h2": "Ty Posiadasz
Sieć Kolejnej Generacji", - "index-nextweb-p1": "SimpleX opiera się na przekonaniu, że to Ty musisz posiadać na własność swoją tożsamość, kontakty i społeczności.", - "index-nextweb-p2": "Otwarta i zdecentralizowana sieć pozwala połączyć się z ludźmi i dzielić się pomysłami: bądź wolny i bezpieczny.", + "index-nextweb-p1": "SimpleX powstał w oparciu o przekonanie, że musisz być właścicielem swoich profili, kontaktów i społeczności.", + "index-nextweb-p2": "Zdecentralizowana sieć, której nikt nie jest właścicielem, pozwala łączyć się z ludźmi i dzielić się pomysłami, zapewniając swobodę i bezpieczeństwo w sieci.", "index-token-h2": "Społeczności, Które Trwają", "index-token-p1": "Będziesz mógł wspierać swoje ulubione grupy dzięki przyszłym Voucherom Społeczności.", "index-token-p2": "Vouchery opłacą serwery, aby Twoje społeczności pozostały wolne i niezależne.", - "index-token-cta": "Dowiedz się więcej i uzyskuj swój darmowy NFT
do wczesnych testów.", + "index-token-cta": "Dowiedz się więcej i uzyskaj bezpłatną przepustkę umożliwiającą wczesne testowanie.", "index-roadmap-h2": "Plan Działania SimpleX dla Wolnego Internetu", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Wyskalowany dla Dużych Społeczności", @@ -303,10 +303,10 @@ "how-secure-comparison-title": "Porównanie zabezpieczeń szyfrowania end-to-end w różnych komunikatorach", "how-secure-message-padding": "Wypełnianie wiadomości", "how-secure-repudiation-deniability": "Wyrzeczenie (wiarygodne zaprzeczenie)", - "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-forward-secrecy": "Poufność przekazywania informacji", "how-secure-break-in-recovery": "Bezpieczeństwo po naruszeniu zabezpieczeń", "how-secure-two-factor-key-exchange": "Wymiana kluczy 2-składnikowych", - "how-secure-post-quantum-hybrid-crypto": "Post-quantum hybrid crypto", + "how-secure-post-quantum-hybrid-crypto": "Hybrydowe szyfrowanie postkwantowe", "messengers-comparison-section-list-point-1": "Briar wypełnia wiadomości do zaokrąglonego rozmiaru do maksymalnie 1024 bajtów, Signal - do 160 bajtów", "messengers-comparison-section-list-point-2": "Repudiatacja (wiarygodne zaprzeczenie) nie obejmuje połączenia klient-serwer.", "messengers-comparison-section-list-point-3": "Wydaje się, że użycie podpisów kryptograficznych zagraża repudiatacji (wiarygodnemu zaprzeczeniu), ale należy je wyjaśnić.", @@ -314,5 +314,59 @@ "messengers-comparison-section-list-point-5": "Wymiana kluczy 2-składnikowych jest opcjonalna poprzez weryfikację kodu bezpieczeństwa.", "messengers-comparison-section-list-point-6": "Post-kwantowe, kluczowe porozumienie jest \"rzadkie\" — chroni tylko niektóre kroki systemu Ratchet.", "navbar-old-site": "Stara strona", + "docs-dropdown-15": "Weryfikacja i odtwarzanie kompilacji", + "why-p1": "Urodziłeś się bez konta.", + "why-p2": "Nikt nie śledził twoich rozmów. Nikt nie rysował mapy miejsc, w których byłeś. Prywatność nigdy nie była funkcją — była sposobem na życie.", + "why-p3": "Następnie przenieśliśmy się do sieci, a każda platforma prosiła o podanie danych osobowych — imienia i nazwiska, numeru telefonu, znajomych. Zaakceptowaliśmy fakt, że ceną za możliwość komunikowania się z innymi jest ujawnienie komuś, z kim rozmawiamy. Tak było w przypadku każdego pokolenia, ludzi i technologii — telefonu, poczty elektronicznej, komunikatorów, mediów społecznościowych. Wydawało się to jedyną możliwą opcją.", + "why-p4": "Jest jeszcze inny sposób. Sieć bez numerów telefonów. Bez nazw użytkowników. Bez kont. Bez jakichkolwiek tożsamości użytkowników. Sieć, która łączy ludzi i przesyła zaszyfrowane wiadomości, nie wiedząc, kto jest podłączony.", + "why-p5": "Nie chodzi o lepszy zamek w drzwiach kogoś innego. Nie chodzi o milszego właściciela, który szanuje twoją prywatność, ale nadal prowadzi rejestr wszystkich odwiedzających. Nie jesteś gościem. Jesteś w domu. Żaden król nie może do niego wejść — jesteś suwerenem.", + "why-p6": "Twoje rozmowy należą do Ciebie, tak jak zawsze było przed pojawieniem się Internetu. Sieć nie jest miejscem, które odwiedzasz. Jest miejscem, które tworzysz i które należy do Ciebie. Nikt nie może Ci tego odebrać, niezależnie od tego, czy jest to miejsce prywatne, czy publiczne.", + "why-p7": "Najstarsza ludzka wolność — możliwość rozmowy z inną osobą bez bycia obserwowanym — opiera się na infrastrukturze, która nie może jej zdradzić.", + "why-p8": "Ponieważ zniszczyliśmy moc pozwalającą poznać, kim jesteś. Więc twoja moc nigdy nie będzie Ci odebrana.", + "why-tagline": "Ciesz się swobodą w swojej sieci.", + "why-footer-link": "Dlaczego to budujemy", + "file": "Plik", + "file-desc": "Wysyłaj pliki bezpiecznie dzięki szyfrowaniu typu end-to-end — bez kont, bez śledzenia.", + "file-noscript": "Do przesyłania plików wymagana jest obsługa języka JavaScript.", + "file-e2e-note": "Szyfrowanie typu end-to-end — serwer nigdy nie widzi Twojego pliku.", + "file-learn-more": "Dowiedz się więcej o protokole XFTP", + "file-cta-heading": "Pobierz SimpleX Chat — najbezpieczniejszy i najbardziej prywatny komunikator", + "file-cta-subheading": "Przesyłanie plików, z którego właśnie skorzystałeś, wykorzystuje ten sam protokół routingu danych, co SimpleX Chat. Aplikacja oferuje szyfrowane wiadomości, połączenia głosowe i wideo, grupy oraz wysyłanie plików. Bez konta. Bez telefonu. Bez adresu e-mail. Bez identyfikatorów profilu użytkownika.", + "file-title": "Transfer plików SimpleX", + "file-drop-text": "Przeciągnij i upuść plik tutaj", + "file-drop-hint": "lub", + "file-choose": "Wybierz plik", + "file-max-size": "Maksymalnie 100 MB - aplikacja SimpleX Chat obsługuje pliki o rozmiarze do 1 GB", + "file-encrypting": "Szyfrowanie…", + "file-uploading": "Wysyłanie…", + "file-cancel": "Anuluj", + "file-uploaded": "Plik wysłany", + "file-copy": "Kopiuj", + "file-copied": "Skopiowano!", + "file-share": "Udostępnij", + "file-expiry": "Pliki są zazwyczaj dostępne przez 48 godzin.", + "file-sec-1": "Twój plik został zaszyfrowany w przeglądarce – routery danych nigdy nie widzą treści plików, ich nazw ani rozmiarów.", + "file-sec-2": "Klucz szyfrujący znajduje się w fragmencie skrótu linku – nigdy nie jest wysyłany do żadnego serwera.", + "file-sec-3": "Aby zapewnić większe bezpieczeństwo, użyj aplikacji SimpleX Chat.", + "file-retry": "Ponów", + "file-downloading": "Pobieranie…", + "file-decrypting": "Odszyfrowywanie…", + "file-download-complete": "Pobieranie zakończone", + "file-download-btn": "Pobierz", + "file-too-large": "Plik jest zbyt duży (%size%). Maksymalny rozmiar to 100 MB. Aplikacja SimpleX obsługuje pliki o rozmiarze do 1 GB.", + "file-empty": "Plik jest pusty.", + "file-invalid-link": "Nieprawidłowy lub uszkodzony link.", + "file-init-error": "Nie udało się zainicjować: %error%", + "file-available": "Plik dostępny (~%size%)", + "file-dl-sec-1": "Ten plik jest zaszyfrowany – routery danych nigdy nie widzą zawartości pliku, jego nazwy ani rozmiaru.", + "file-workers-required": "Wymagane Web Workers — zaktualizuj przeglądarkę", + "file-protocol-title": "Protokół XFTP: najbezpieczniejszy transfer plików", + "file-proto-h-1": "Nie jest wymagane konto", + "file-proto-p-1": "Każdy fragment pliku wykorzystuje nowy losowy klucz. Routery danych nie mają „użytkowników” ani „plików” – przesyłają zaszyfrowane fragmenty plików o stałych rozmiarach.", + "file-proto-h-2": "Potrójne szyfrowanie w przeglądarce", + "file-proto-p-2": "Klucz szyfrowania plików znajduje się wyłącznie we fragmencie skrótu adresu URL – przeglądarka nigdy nie wysyła go do serwera. Istnieją trzy warstwy szyfrowania: transport TLS, szyfrowanie po-odbiorcy (unikalny klucz tymczasowy dla każdego transferu) oraz szyfrowanie plików typu end-to-end.", + "file-proto-h-4": "Niezależne routery danych", + "file-proto-p-4": "Gdy plik jest dzielony na fragmenty, jest on wysyłany przez routery sieciowe obsługiwane przez niezależne podmioty. Żaden operator nie może zobaczyć rzeczywistego rozmiaru ani nazwy pliku. Nawet jeśli router zostanie naruszony, może on zobaczyć tylko zaszyfrowane fragmenty o stałym rozmiarze. Fragmenty plików są przechowywane w pamięci podręcznej routerów sieciowych przez około 48 godzin.", + "file-proto-spec": "Zapoznaj się ze specyfikacją protokołu XFTP →", "send-file": "Wyślij plik" } diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 3c1c971f7f..a94670e443 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -178,7 +178,7 @@ "simplex-private-section-header": "O que torna o SimpleX privado", "tap-to-close": "Toque para fechar", "guide-dropdown-6": "Chamadas de áudio e vídeo", - "simplex-network-section-desc": "O Simplex Chat oferece a melhor privacidade ao combinar as vantagens das redes P2P e federadas.", + "simplex-network-section-desc": "O SimpleX Chat oferece a melhor privacidade ao combinar as vantagens das redes P2P e federadas.", "simplex-network-1-header": "Diferente das redes P2P", "docs-dropdown-2": "Acessando arquivos do Android", "comparison-section-list-point-3": "Chave pública ou alguma outra ID globalmente exclusiva", @@ -238,7 +238,7 @@ "stable-and-beta-versions-built-by-developers": "Versões estáveis e beta criadas pelos desenvolvedores", "signing-key-fingerprint": "Assinatura de impressão digital de chave (SHA-256)", "simplex-chat-via-f-droid": "SimpleX Chat pelo F-Droid", - "simplex-chat-repo": "Repositório Simplex Chat", + "simplex-chat-repo": "Repositório SimpleX Chat", "f-droid-org-repo": "Repositório F-Droid.org", "stable-versions-built-by-f-droid-org": "Versões estáveis criadas por F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "Os lançamentos para este repositório são feitos 1 ou 2 dias depois", diff --git a/website/langs/uk.json b/website/langs/uk.json index d36ff6c820..7c719381d7 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -202,7 +202,7 @@ "simplex-private-section-header": "Що робить SimpleX конфіденційним", "tap-to-close": "Торкніться, щоб закрити", "simplex-network-section-header": "SimpleX Network", - "simplex-network-section-desc": "Simplex Chat надає найкращу конфіденційність, поєднуючи переваги P2P та федеративних мереж.", + "simplex-network-section-desc": "SimpleX Chat надає найкращу конфіденційність, поєднуючи переваги P2P та федеративних мереж.", "simplex-network-1-desc": "Всі повідомлення відправляються через сервери, що забезпечує кращу конфіденційність метаданих та надійну асинхронну доставку повідомлень, уникаючи багатьох", "simplex-network-1-header": "На відміну від мереж P2P", "simplex-network-1-overlay-linktext": "проблем P2P-мереж", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 5b1eea5969..e67df60a6d 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -174,7 +174,7 @@ "simplex-network-2-desc": "SimpleX 中继服务器不存储用户配置文件、联系人和传递的消息,不相互连接,并且没有服务器目录。", "tap-to-close": "点击关闭", "simplex-network-section-header": "SimpleX 网络", - "simplex-network-section-desc": "Simplex Chat 通过结合 P2P 和联邦网络的优势使其保密性无与伦比。", + "simplex-network-section-desc": "SimpleX Chat 通过结合 P2P 和联邦网络的优势使其保密性无与伦比。", "no-private": "否 - 私密", "simplex-network-1-desc": "所有消息都通过服务器发送,既能更好地保护元数据隐私和可靠地传递异步消息,同时也能避免许多", "simplex-network-3-header": "SimpleX 网络", @@ -258,5 +258,115 @@ "docs-dropdown-14": "企业版 SimpleX", "directory": "目录", "about-and-contact-us": "关于 & 联系我们", + "docs-dropdown-15": "验证并重现构建过程", + "index-hero-h1": "变得
自由", + "index-hero-h2": "在属于你的网络中", + "index-hero-p1": "私密安全的即时通讯。
首个由您掌控
联系人和群组的网络。", + "index-hero-download-desktop-btn-title": "下载 SimpleX 桌面应用程序", + "index-testflight-title": "SimpleX iOS 测试版已在 TestFlight 上发布", + "index-f-droid-title": "SimpleX 安卓应用(通过 F-Droid)", + "index-security-assessment-title": "安全审计", + "index-security-review-2022-title": "2022年­安全审计", + "index-security-review-2024-title": "2024年安全审计", + "index-security-audits-label": "安全
审计", + "index-publications-privacy-guides-title": "隐私指南通讯推荐", + "index-publications-whonix-title": "Whonix通讯推荐", + "index-publications-heise-title": "Heise Online出版物", + "index-publications-kuketz-title": "Mike Kuketz 的评论", + "index-publications-optout-title": "OptOut播客访谈", + "worlds-most-secure-messaging": "全球最安全的即时通讯", + "index-messaging-p1": "SimpleX 即时通讯采用最先进的端到端加密技术。", + "index-messaging-p2": "为了您的安全和隐私,服务器无法看到您的消息以及您与谁交谈。", + "index-messaging-cta": "了解更多关于 SimpleX 消息传递的知识", + "index-nextweb-h2": "属于你的
下一代互联网", + "index-nextweb-p1": "SimpleX 的创建理念是:您必须拥有自己的个人资料、联系人和社区。", + "index-nextweb-p2": "一个不归个人所有的去中心化网络,让你能够与他人联系并分享想法,在网络中自由安全地生活。", + "index-token-h2": "长久存在的社区", + "index-token-p1": "您将通过未来的社区代金券支持您喜爱的团体。", + "index-token-p2": "代金券将用于支付服务器费用,让您的社区保持自由和独立。", + "index-token-cta": "了解更多信息并获取免费抢先体验券,参与早期测试。", + "index-roadmap-h2": "SimpleX 通往自由互联网的路线图", + "index-roadmap-2025-title": "扩展到大型社区", + "index-roadmap-2025-desc": "逃离中心化平台", + "index-roadmap-2026-title": "可持续社区与服务器", + "index-roadmap-2026-desc": "推出社区代金券", + "index-roadmap-2027-title": "促进社区发展", + "index-roadmap-2027-desc": "用于推广社区的工具", + "index-directory-h2": "加入 SimpleX 社区", + "index-directory-p1": "已有数十万人信赖 SimpleX 即时通讯服务。", + "index-directory-p2": "在 SimpleX 目录中找到您的社区并创建您自己的社区!", + "index-directory-cta": "查看 SimpleX 目录", + "index-directory-users-group-title": "SimpleX 用户群组", + "how-secure-comparison-title": "不同即时通讯软件端到端加密安全性的比较", + "how-secure-message-padding": "消息填充", + "how-secure-repudiation-deniability": "否认(可否认性)", + "how-secure-forward-secrecy": "前向加密", + "how-secure-break-in-recovery": "事后安全", + "how-secure-two-factor-key-exchange": "双因素密钥交换", + "how-secure-post-quantum-hybrid-crypto": "后量子混合加密", + "messengers-comparison-section-list-point-1": "Briar 将消息大小向上取整至 1024 字节,Signal 消息大小向上取整至 160 字节", + "messengers-comparison-section-list-point-2": "可否认性不包括客户端-服务器连接。", + "messengers-comparison-section-list-point-3": "使用加密签名似乎会损害可否认性(否认能力),但这一点需要澄清。", + "messengers-comparison-section-list-point-4": "多设备部署会降低双棘轮攻击后的安全性", + "messengers-comparison-section-list-point-5": "双因素密钥交换可通过安全码验证进行,并非强制要求。", + "messengers-comparison-section-list-point-6": "后量子密钥协商是“稀疏的”——它只保护了部分棘轮步骤。", + "navbar-old-site": "旧网站", + "why-p1": "你生来就没有账户。", + "why-p2": "没有人追踪你的谈话内容。没有人绘制你去过的地方的地图。隐私从来都不是一项功能——而是一种生活方式。", + "why-p3": "然后我们转向线上,每个平台都要求你提供一些信息——你的姓名、电话号码、好友列表。我们接受了这样一个事实:与人交流的代价就是让别人知道我们在和谁交流。每一代人,每一代科技,都遵循着这样的模式——电话、电子邮件、即时通讯、社交媒体。这似乎是唯一可行的方式。", + "why-p4": "还有另一种方法。一个没有电话号码、没有用户名、没有账户、没有任何用户身份的网络。一个连接人们并传输加密信息的网络,而无需知道谁连接了。", + "why-p5": "别人家的门锁再好也比不上这里。房东再好也比不上这里,他既尊重你的隐私,又保留着所有访客的记录。你不是客人,你是家。没有国王能闯入——你是主人。", + "why-p6": "你的对话内容始终属于你,就像互联网出现之前一样。网络不是一个你访问的地方,而是一个你创建并拥有的地方。无论你将其设为私密还是公开,任何人都无法将其夺走。", + "why-p7": "人类最古老的自由——与他人交谈而不被监视——建立在不会背叛它的基础设施之上。", + "why-p8": "因为我们摧毁了知道你是谁的权力,因而您的权利永远不会被夺走。", + "why-tagline": "在你的网络中自由畅行。", + "why-footer-link": "我们为什么要建造它", + "file": "文件", + "file-desc": "使用端到端加密安全发送文件——无需注册账户,不会追踪。", + "file-noscript": "文件传输需要启用 JavaScript。", + "file-e2e-note": "端到端加密——服务器永远不会看到您的文件。", + "file-learn-more": "了解更多关于 XFTP 协议的信息", + "file-cta-heading": "获取 SimpleX Chat——最安全、最私密的即时通讯工具", + "file-cta-subheading": "您刚才使用的文件传输功能与 SimpleX Chat 使用相同的数据路由协议。该应用提供端到端加密的消息传递、语音和视频通话、群组功能以及文件发送功能。无需注册账号,无需电话号码,无需邮箱,也无需用户个人资料 ID。", + "file-title": "SimpleX 文件传输", + "file-drop-text": "将文件拖放到此处", + "file-drop-hint": "或", + "file-choose": "选择文件", + "file-max-size": "最大支持 100 MB - SimpleX Chat 应用 支持最大 1 GB 的文件", + "file-encrypting": "加密中……", + "file-uploading": "正在上传…", + "file-cancel": "取消", + "file-uploaded": "文件已上传", + "file-copy": "复制", + "file-copied": "已复制!", + "file-share": "分享", + "file-expiry": "文件通常可保存 48 小时。", + "file-sec-1": "您的文件已在浏览器中加密 - 数据路由器永远不会看到文件内容、名称或大小。", + "file-sec-2": "加密密钥位于链接的哈希片段中——它永远不会发送到任何服务器。", + "file-sec-3": "为了获得更好的安全性,请使用SimpleX Chat应用程序。", + "file-retry": "重试", + "file-downloading": "正在下载…", + "file-decrypting": "正在解密…", + "file-download-complete": "下载完成", + "file-download-btn": "下载", + "file-too-large": "文件过大(%size%)。最大文件大小为 100 MB。SimpleX 应用 支持最大 1 GB 的文件。", + "file-empty": "文件为空。", + "file-invalid-link": "链接无效或已损坏。", + "file-init-error": "初始化失败:%error%", + "file-available": "文件可用(约 %size%)", + "file-dl-sec-1": "此文件已加密——数据路由器永远看不到文件内容、名称或大小。", + "file-workers-required": "需要 Web Workers — 请更新您的浏览器", + "file-protocol-title": "XFTP协议:最安全的文件传输协议", + "file-proto-h-1": "无需注册账号", + "file-proto-p-1": "每个文件片段都使用一个新的随机密钥。数据路由器没有“用户”或“文件”的概念——它们传输的是固定大小的加密文件片段。", + "file-proto-h-2": "浏览器中采用三重加密", + "file-proto-p-2": "文件加密密钥仅存在于 URL 哈希片段中——您的浏览器绝不会将其发送给服务器。加密过程包含三层:TLS 传输层、每个接收者加密层(每次传输使用唯一的临时密钥)以及文件端到端加密层。", + "file-proto-h-4": "独立数据路由器", + "file-proto-p-4": "文件被分割成多个片段后,会通过独立运营商运营的网络路由器进行传输。任何运营商都无法看到文件的实际大小或名称。即使路由器遭到入侵,也只能看到固定大小的加密片段。网络路由器会将文件片段缓存约48小时。", + "file-proto-spec": "阅读 XFTP 协议规范 →", + "navbar-token": "Token 令牌", + "index-roadmap-2025": "2025", + "index-roadmap-2026": "2026", + "index-roadmap-2027": "2027", "send-file": "发送文件" } diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 79032db8ed..9e1913879f 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -26,7 +26,7 @@ -
+