diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 5031592f02..855f3380cc 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -109,6 +109,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) @@ -306,6 +307,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)" @@ -494,6 +496,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" @@ -673,6 +676,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { 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) @@ -706,6 +710,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" @@ -741,6 +746,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { 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" } @@ -1104,7 +1110,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupUpdated(user: UserRef, toGroup: GroupInfo) - case groupLinkRelaysUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) + case groupLinkDataUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay], relaysChanged: Bool) case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) @@ -1181,7 +1187,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .joinedGroupMember: "joinedGroupMember" case .connectedToGroupMember: "connectedToGroupMember" case .groupUpdated: "groupUpdated" - case .groupLinkRelaysUpdated: "groupLinkRelaysUpdated" + case .groupLinkDataUpdated: "groupLinkDataUpdated" case .newMemberContactReceivedInv: "newMemberContactReceivedInv" case .rcvFileAccepted: "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" @@ -1262,7 +1268,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkRelaysUpdated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") + case let .groupLinkDataUpdated(u, groupInfo, groupLink, groupRelays, relaysChanged): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)\nrelaysChanged: \(relaysChanged)") 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 diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0819d74ec1..1119f28bac 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -890,6 +890,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) } @@ -2578,7 +2584,7 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(toGroup) } } - case let .groupLinkRelaysUpdated(user, groupInfo, _, groupRelays): + case let .groupLinkDataUpdated(user, groupInfo, _, groupRelays, _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) 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/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 87f6b8a787..3301f96792 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -695,6 +695,14 @@ struct ChatView: View { ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays) } } + } else { + Task { + if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } } } updateAvailableContent() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 271e50c8d7..f37eb614b9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -382,7 +382,8 @@ struct ComposeView: View { Divider() } - if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays { + 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 : [] diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index b3317cbc6b..abcadc6c3f 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -16,39 +16,34 @@ struct ChannelMembersView: View { @EnvironmentObject var theme: AppTheme var body: some View { - let allMembers = chatModel.groupMembers + let members = chatModel.groupMembers .filter { m in let s = m.wrapped.memberStatus - return s != .memLeft && s != .memRemoved && m.wrapped.groupMemberId != groupInfo.membership.groupMemberId + return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay } - let owners = allMembers.filter { $0.wrapped.memberRole >= .owner } - // TODO [relays] subscriber/owner counts require backend support for accurate totals - let subscribers = allMembers.filter { $0.wrapped.memberRole < .owner && $0.wrapped.memberRole != .relay } - List { - Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { - if groupInfo.membership.memberRole >= .owner { - memberRow(GMember(groupInfo.membership), user: true) - } - ForEach(owners) { member in - memberRow(member, user: false) + if groupInfo.isOwner { + 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) + } } } - if groupInfo.isOwner { - Section(header: Text("\(subscribers.count) subscribers").foregroundColor(theme.colors.secondary)) { - if subscribers.isEmpty { - Text("No subscribers") - .foregroundColor(theme.colors.secondary) - } else { - ForEach(subscribers) { member in - memberRow(member, user: false) - } + } 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) -> some View { + @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) @@ -60,9 +55,20 @@ struct ChannelMembersView: View { let row = HStack { MemberProfileImage(member, size: 38) .padding(.trailing, 2) - displayName - .lineLimit(1) + 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 diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index d02cf2eb38..134ecffbb3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -297,6 +297,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) } @@ -627,7 +635,7 @@ struct GroupChatInfoView: View { } private func channelMembersButton() -> some View { - let label: LocalizedStringKey = groupInfo.isOwner ? "Owners & subscribers" : "Owners" + let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners" return NavigationLink { ChannelMembersView(chat: chat, groupInfo: groupInfo) .navigationTitle(label) 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/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 95f7fa2e9d..63fb7f5221 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1106,6 +1106,7 @@ private func showPrepareGroupAlert( cleanup: (() -> Void)? ) { let isChannel = !(groupShortLinkInfo?.direct ?? true) + let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" } showOpenChatAlert( profileName: groupShortLinkData.groupProfile.displayName, profileFullName: groupShortLinkData.groupProfile.fullName, @@ -1118,6 +1119,7 @@ private func showPrepareGroupAlert( size: alertProfileImageSize ), theme: theme, + subtitle: isChannel ? subscriberCount : nil, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: isChannel ? NSLocalizedString("Open new channel", comment: "new chat action") @@ -1178,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, @@ -1188,6 +1191,7 @@ private func showOpenKnownGroupAlert( size: alertProfileImageSize ), theme: theme, + subtitle: groupInfo.useRelays ? subscriberCount : nil, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: groupInfo.useRelays diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 468ede7031..cfa66218b3 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2360,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)" } } @@ -2413,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: "" @@ -2506,8 +2508,23 @@ 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 { 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 1c25d7e3d4..17fcf1c4b7 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 @@ -2058,6 +2058,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?, @@ -2136,6 +2137,7 @@ data class GroupInfo ( chatTs = Clock.System.now(), preparedGroup = null, uiThemes = null, + groupSummary = GroupSummary(currentMembers = 0), membersRequireAttention = 0, chatTags = emptyList(), localAlias = "", @@ -2203,9 +2205,21 @@ data class ContactShortLinkData ( val business: Boolean ) +@Serializable +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 groupProfile: GroupProfile, + val publicGroupData: PublicGroupData? = null ) @Serializable 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 df479e77c8..723e7bdc25 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 @@ -1343,6 +1343,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 @@ -2977,7 +2983,7 @@ object ChatController { chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } - is CR.GroupLinkRelaysUpdated -> + is CR.GroupLinkDataUpdated -> if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) @@ -3642,6 +3648,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() @@ -3841,6 +3848,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" @@ -4018,6 +4026,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" @@ -6197,6 +6206,7 @@ sealed class 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() @@ -6290,7 +6300,7 @@ sealed class 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("groupLinkRelaysUpdated") class GroupLinkRelaysUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List, val relaysChanged: Boolean): 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() @@ -6382,6 +6392,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" @@ -6474,7 +6485,7 @@ sealed class CR { is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" is GroupUpdated -> "groupUpdated" - is GroupLinkRelaysUpdated -> "groupLinkRelaysUpdated" + is GroupLinkDataUpdated -> "groupLinkDataUpdated" is GroupLinkCreated -> "groupLinkCreated" is CRGroupLink -> "groupLink" is GroupLinkDeleted -> "groupLinkDeleted" @@ -6559,6 +6570,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)}") @@ -6651,7 +6663,7 @@ 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 GroupLinkRelaysUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged") is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is CRGroupLink -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) 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 18122c63a9..89bb27de02 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 @@ -209,6 +209,13 @@ fun ChatView( 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) + } + } } } } @@ -1444,6 +1451,10 @@ fun BoxScope.ChatInfoToolbar( } } +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( @@ -1473,6 +1484,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 ( 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 b2b83e8dc4..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 @@ -1466,7 +1466,9 @@ fun ComposeView( } val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo - if (gInfo != null && gInfo.useRelays) { + 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 } 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 index efa6510c95..0cf3a3c96f 100644 --- 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 @@ -1,7 +1,6 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView import SectionView import androidx.compose.foundation.layout.* @@ -15,6 +14,7 @@ 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 @@ -27,66 +27,51 @@ fun ChannelMembersView( showMemberInfo: (GroupMember) -> Unit ) { BackHandler(onBack = close) - val allMembers = remember { chatModel.groupMembers }.value + val members = remember { chatModel.groupMembers }.value .filter { m -> m.memberStatus != GroupMemberStatus.MemLeft && m.memberStatus != GroupMemberStatus.MemRemoved - && m.groupMemberId != groupInfo.membership.groupMemberId + && m.memberRole != GroupMemberRole.Relay } - val owners = allMembers.filter { it.memberRole >= GroupMemberRole.Owner } - // TODO [relays] subscriber/owner counts require backend support for accurate totals - val subscribers = allMembers.filter { it.memberRole < GroupMemberRole.Owner && it.memberRole != GroupMemberRole.Relay } ColumnWithScrollBar { val title = if (groupInfo.isOwner) { - generalGetString(MR.strings.channel_members_title_owners_and_subscribers) + generalGetString(MR.strings.channel_members_title_subscribers) } else { generalGetString(MR.strings.channel_members_section_owners) } AppBarTitle(title) - SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) { - if (groupInfo.membership.memberRole >= GroupMemberRole.Owner) { - SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { - ChannelMemberRow(groupInfo.membership) - } - } - owners.forEachIndexed { index, member -> - if (index > 0 || groupInfo.membership.memberRole >= GroupMemberRole.Owner) { - Divider() - } - SectionItemView( - click = { showMemberInfo(member) }, - minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) - ) { - ChannelMemberRow(member) - } - } - } - if (groupInfo.isOwner) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - SectionView(title = String.format(generalGetString(MR.strings.channel_members_num_subscribers), subscribers.size)) { - if (subscribers.isEmpty()) { - SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Text( - generalGetString(MR.strings.channel_members_no_subscribers), - color = MaterialTheme.colors.secondary - ) + 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 { - subscribers.forEachIndexed { index, member -> - if (index > 0) { - Divider() - } - SectionItemView( - click = { showMemberInfo(member) }, - minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) - ) { - ChannelMemberRow(member) - } + } + } + } 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) } } } @@ -96,7 +81,7 @@ fun ChannelMembersView( } @Composable -private fun ChannelMemberRow(member: GroupMember) { +private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -104,15 +89,30 @@ private fun ChannelMemberRow(member: GroupMember) { ) { MemberProfileImage(size = 38.dp, member) Spacer(Modifier.width(2.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - if (member.verified) { - MemberVerifiedShield() + 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.chatViewName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (member.memberIncognito) Indigo else Color.Unspecified + member.memberRole.text, + color = MaterialTheme.colors.secondary ) } } 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 99e5d6d5e2..5558944bf9 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 @@ -852,6 +852,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) + ) + } + } } } @@ -1118,7 +1129,7 @@ private fun ChannelLinkQRCodeSection(groupLink: String) { @Composable private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { val title = if (groupInfo.isOwner) { - stringResource(MR.strings.channel_members_title_owners_and_subscribers) + stringResource(MR.strings.channel_members_title_subscribers) } else { stringResource(MR.strings.channel_members_section_owners) } 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/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index c5e5517cfc..baa57fb193 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 @@ -503,6 +504,7 @@ fun ownGroupLinkConfirmConnect( } 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, @@ -513,6 +515,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( icon = groupInfo.chatIconName ) }, + subtitle = subscriberCount, confirmText = generalGetString( if (groupInfo.useRelays) { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel @@ -588,6 +591,7 @@ fun showPrepareGroupAlert( 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, @@ -598,6 +602,7 @@ fun showPrepareGroupAlert( icon = if (isChannel) MR.images.ic_bigtop_updates 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() 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 cf8e9431f1..67816bcd69 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2821,10 +2821,11 @@ You can mention up to %1$s members per message! - Owners & subscribers + Subscribers Owners - %1$d subscribers - No subscribers + %1$d subscriber + %1$d subscribers + you Chat relay diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index 39801bcb5c..1dcdf5163c 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -38,7 +38,7 @@ This file is generated automatically. - [MemberAcceptedByOther](#memberacceptedbyother) - [MemberBlockedForAll](#memberblockedforall) - [GroupMemberUpdated](#groupmemberupdated) - - [GroupLinkRelaysUpdated](#grouplinkrelaysupdated) + - [GroupLinkDataUpdated](#grouplinkdataupdated) [File events](#file-events) - Main events @@ -453,16 +453,17 @@ Another group member profile updated. --- -### GroupLinkRelaysUpdated +### GroupLinkDataUpdated -Group link relays updated. +Group link data updated. **Record type**: -- type: "groupLinkRelaysUpdated" +- type: "groupLinkDataUpdated" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) - groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] +- relaysChanged: bool --- diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 2e6b4a755e..e097205aa0 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -136,6 +136,7 @@ This file is generated automatically. - [Profile](#profile) - [ProxyClientError](#proxyclienterror) - [ProxyError](#proxyerror) +- [PublicGroupData](#publicgroupdata) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) - [RcvConnEvent](#rcvconnevent) @@ -2418,6 +2419,7 @@ Public: **Record type**: - groupProfile: [GroupProfile](#groupprofile) +- publicGroupData: [PublicGroupData](#publicgroupdata)? --- @@ -2436,6 +2438,7 @@ Public: **Record type**: - currentMembers: int64 +- publicMemberCount: int64? --- @@ -2893,6 +2896,14 @@ NO_SESSION: - type: "NO_SESSION" +--- + +## PublicGroupData + +**Record type**: +- publicMemberCount: int64 + + --- ## RCErrorType diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index bb48bca06c..03f1e53441 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -369,6 +369,7 @@ undocumentedCommands = "APIGetUsageConditions", "APIGetUserServers", "APIGroupInfo", + "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", "APIGroupMemberQueueInfo", "APIHideUser", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index ec58985ceb..0d1998aa28 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -98,7 +98,7 @@ chatEventsDocsData = ("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."), - ("CEvtGroupLinkRelaysUpdated", "Group link relays updated.") + ("CEvtGroupLinkDataUpdated", "Group link data updated.") ] ), ( "File events", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 1b8b8fbc7f..7c526ec58a 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -319,6 +319,7 @@ chatTypesDocsData = (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), (sti @ProxyError, STUnion, "", [], "", ""), + (sti @PublicGroupData, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), (sti @RcvConnEvent, STUnion, "RCE", [], "", ""), @@ -518,6 +519,7 @@ deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError deriving instance Generic ProxyError +deriving instance Generic PublicGroupData deriving instance Generic RatchetSyncState deriving instance Generic RCErrorType deriving instance Generic RcvConnEvent diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index 4c569c1788..72d1b5e0b9 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -29,7 +29,7 @@ export type ChatEvent = | CEvt.MemberAcceptedByOther | CEvt.MemberBlockedForAll | CEvt.GroupMemberUpdated - | CEvt.GroupLinkRelaysUpdated + | CEvt.GroupLinkDataUpdated | CEvt.RcvFileDescrReady | CEvt.RcvFileComplete | CEvt.SndFileCompleteXFTP @@ -81,7 +81,7 @@ export namespace CEvt { | "memberAcceptedByOther" | "memberBlockedForAll" | "groupMemberUpdated" - | "groupLinkRelaysUpdated" + | "groupLinkDataUpdated" | "rcvFileDescrReady" | "rcvFileComplete" | "sndFileCompleteXFTP" @@ -305,12 +305,13 @@ export namespace CEvt { toMember: T.GroupMember } - export interface GroupLinkRelaysUpdated extends Interface { - type: "groupLinkRelaysUpdated" + export interface GroupLinkDataUpdated extends Interface { + type: "groupLinkDataUpdated" user: T.User groupInfo: T.GroupInfo groupLink: T.GroupLink groupRelays: T.GroupRelay[] + relaysChanged: boolean } export interface RcvFileDescrReady 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 a630a11992..c388072846 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2706,6 +2706,7 @@ export namespace GroupRootKey { export interface GroupShortLinkData { groupProfile: GroupProfile + publicGroupData?: PublicGroupData } export interface GroupShortLinkInfo { @@ -2716,6 +2717,7 @@ export interface GroupShortLinkInfo { export interface GroupSummary { currentMembers: number // int64 + publicMemberCount?: number // int64 } export interface GroupSupportChat { @@ -3204,6 +3206,10 @@ export namespace ProxyError { } } +export interface PublicGroupData { + publicMemberCount: number // int64 +} + export type RCErrorType = | RCErrorType.Internal | RCErrorType.Identity diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8c30984337..3e8c7550dc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -427,6 +427,7 @@ data ChatCommand | APISetMemberSettings GroupId GroupMemberId GroupMemberSettings | APIContactInfo ContactId | APIGroupInfo GroupId + | APIGetUpdatedGroupLinkData {groupId :: GroupId} | APIGroupMemberInfo GroupId GroupMemberId | APIContactQueueInfo ContactId | APIGroupMemberQueueInfo GroupId GroupMemberId @@ -856,7 +857,7 @@ 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} - | CEvtGroupLinkRelaysUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} + | CEvtGroupLinkDataUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay], relaysChanged :: Bool} | 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} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 191c4af8a0..c9d85b9f45 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1685,6 +1685,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 {groupLink}} <- withFastStore $ \db -> getGroupInfo db vr user groupId + case groupLink of + Just 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) @@ -1893,7 +1905,7 @@ processChatCommand vr nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False GRMember + (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 @@ -1919,12 +1931,13 @@ processChatCommand vr nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTDirect chat APIPrepareGroup userId ccLink direct groupSLinkData -> withUserId userId $ \user -> do - let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData + let GroupShortLinkData {groupProfile = gp@GroupProfile {description}, publicGroupData = publicGroupData_} = groupSLinkData + publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId let useRelays = not direct subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays subRole + (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 = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -2009,7 +2022,10 @@ processChatCommand vr nm = \case 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}, ContactLinkData _ UserContactData {owners, relays}) <- getShortLinkConnReq nm user sLnk + (FixedLinkData {linkConnReq = mainCReq@(CRContactUri crData), linkEntityId, rootKey}, cData@(ContactLinkData _ UserContactData {owners, relays})) <- getShortLinkConnReq nm user sLnk + groupSLinkData_ <- liftIO $ decodeLinkUserData cData + 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 @@ -2017,7 +2033,7 @@ processChatCommand vr nm = \case gVar <- asks random (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar gInfo' <- withFastStore $ \db -> do - gInfo' <- updatePreparedRelayedGroup db vr user gInfo mainCReq cReqHash incognitoProfile linkEntityId rootKey memberPrivKey + gInfo' <- updatePreparedRelayedGroup db vr user gInfo mainCReq cReqHash incognitoProfile linkEntityId 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 @@ -2350,13 +2366,13 @@ processChatCommand vr nm = \case 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 + gInfo <- newGroup user incognito gProfile False memberId Nothing Nothing 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) + gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) (gLink, groupRelays) <- setupLink gInfo `catchAllErrors` \e -> do deleteInProgressGroup user gInfo throwError e @@ -2379,7 +2395,7 @@ processChatCommand vr nm = \case memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12) (memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey let groupProfile' = (groupProfile :: GroupProfile) {groupLink = Just sLnk} - userData = encodeShortLinkData $ GroupShortLinkData groupProfile' + 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 @@ -2675,8 +2691,11 @@ processChatCommand vr nm = \case errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 msgSigned = signed2 || signed3 || signed4 - -- Read group info with updated membersRequireAttention - gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- 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 @@ -2834,7 +2853,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 -- TODO [certs rcv] @@ -3430,7 +3449,6 @@ processChatCommand vr nm = \case let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True - g <- asks random chatEvent <- case gInfo_ of Just (Just gInfo) | useRelays' gInfo -> do let GroupInfo {membership = GroupMember {memberId}} = gInfo @@ -3682,12 +3700,12 @@ 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 -> CM GroupInfo - newGroup user incognito gProfile@GroupProfile {displayName} useRelays memberId groupKeys_ = do + 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 - gInfo <- withFastStore $ \db -> createNewGroup db vr user gProfile incognitoProfile useRelays memberId groupKeys_ + gInfo <- withFastStore $ \db -> createNewGroup db vr user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd CIChatBanner (Just epochStart) createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing @@ -4759,6 +4777,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), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 9c99b1d814..6af078dbe6 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1298,7 +1298,7 @@ setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM G setGroupLinkData nm user gInfo gLink = do vr <- chatVersionRange (conn, groupRelays) <- withFastStore $ \db -> - (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getGroupRelays db gInfo) + (,) <$> 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 @@ -1307,16 +1307,29 @@ setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () setGroupLinkDataAsync user gInfo gLink = do vr <- chatVersionRange (conn, groupRelays) <- withStore $ \db -> - (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getGroupRelays db gInfo) + (,) <$> 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 groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) -groupLinkData gInfo@GroupInfo {groupProfile} GroupLink {groupLinkId} groupRelays = +groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}} GroupLink {groupLinkId} groupRelays = let direct = not $ useRelays' gInfo relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays - userData = encodeShortLinkData $ GroupShortLinkData groupProfile + publicGroupData_ = PublicGroupData <$> publicMemberCount + userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_} userLinkData = UserContactLinkData UserContactData {direct, owners = [], relays, userData} crClientData = encodeJSON $ CRDataGroup groupLinkId in (userLinkData, crClientData) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 28e00fea8f..294531e0f8 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) @@ -1233,25 +1233,25 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays) <- withStore $ \db -> 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' <- liftIO $ mapM (updateRelay db) relays + (relays', changed) <- liftIO $ foldrM (updateRelay db) ([], False) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays') - -- TODO [relays] owner: "relays updated" chat item? - toView $ CEvtGroupLinkRelaysUpdated user gInfo gLink relays + 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 -> IO GroupRelay - updateRelay db relay@GroupRelay {relayLink, relayStatus} = + 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 -> - updateRelayStatus db relay RSActive - _ -> pure relay + | 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 @@ -2765,8 +2765,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 _ | useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing @@ -2779,7 +2780,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 @@ -2986,11 +2988,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 msgSigned + 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 @@ -3030,11 +3033,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft - unless (muteEventInChannel gInfo' m) $ do - (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + 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 + toView $ CEvtLeftMember user gInfo''' m' {memberStatus = GSMemLeft} msgSigned pure $ memberEventDeliveryScope m xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () @@ -3605,7 +3609,7 @@ runRelayRequestWorker a Worker {doWork} = 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 gp) -> do + Just GroupShortLinkData {groupProfile = gp} -> do validateGroupProfile gp gVar <- asks random (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar @@ -3628,7 +3632,7 @@ runRelayRequestWorker a Worker {doWork} = do groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode subRole <- asks $ channelSubscriberRole . config - 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, _serviceId)) <- withAgent $ \a' -> createConnection a' NRMBackground (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) CR.IKPQOff subMode diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f1b5aceeec..6d785264a4 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -35,6 +35,7 @@ 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 @@ -1418,12 +1419,20 @@ 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) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index a3d41afbde..348b5b8857 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -144,7 +144,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do 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.use_relays, g.relay_own_status, - 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.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.shared_group_id, 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, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 991e2eaac1..c81044f12d 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -181,7 +181,7 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta case preparedEntity_ of -- For relay groups, setPreparedGroupLinkInfo_ is called via updatePreparedRelayedGroup before the relay loop Just (PCEGroup gInfo _) | not (useRelays' gInfo) -> - setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs + setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId Nothing currentTs _ -> pure () pure Connection diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 8d53bbcbf9..d40d294ee1 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -81,6 +81,7 @@ module Simplex.Chat.Store.Groups getGroupRelayById, getGroupRelayByGMId, getGroupRelays, + getConnectedGroupRelays, createRelayForOwner, getCreateRelayForMember, createRelayConnection, @@ -101,6 +102,8 @@ module Simplex.Chat.Store.Groups createMemberConnection, createMemberConnectionAsync, updatePreparedRelayedGroup, + updatePublicMemberCount, + setPublicMemberCount, updateGroupMemberKeys, updateRelayGroupKeys, updateGroupMemberStatus, @@ -332,8 +335,8 @@ 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 -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> ExceptT StoreError IO GroupInfo -createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys = ExceptT $ do +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, groupLink, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime @@ -364,11 +367,11 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays 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, - shared_group_id, root_priv_key, root_pub_key, member_priv_key) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + shared_group_id, root_priv_key, root_pub_key, member_priv_key, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) - :. (sharedGroupId_, rootPrivKey_, rootPubKey_, memberPrivKey_) + :. (sharedGroupId_, rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_) ) insertedRowId db let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys @@ -394,7 +397,7 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - groupSummary = GroupSummary 1, + groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_}, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, @@ -473,7 +476,7 @@ 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, @@ -593,11 +596,11 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) -createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole = 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 useRelays Nothing currentTs + (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs hostMemberId_ <- if useRelays then pure Nothing @@ -806,7 +809,7 @@ createGroupViaLink' business membershipStatus = do currentTs <- liftIO getCurrentTime - (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing 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 @@ -833,8 +836,8 @@ createGroupViaLink' ) insertedRowId db -createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> UTCTime -> ExceptT StoreError IO (GroupId, Text) -createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus currentTs = ExceptT $ do +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, groupLink, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do liftIO $ do @@ -855,10 +858,10 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus c 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, use_relays, relay_own_status) - 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 :. (BI useRelays, relayOwnStatus)) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus, publicMemberCount_)) groupId <- insertedRowId db pure (groupId, localDisplayName) @@ -1297,6 +1300,20 @@ getGroupRelays db GroupInfo {groupId} = (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| @@ -1446,7 +1463,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe groupPreferences = Nothing, memberAdmission = Nothing } - (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) currentTs + (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 @@ -1708,15 +1725,40 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon -- which is used in single-connection flows. updatePreparedRelayedGroup :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> - Maybe ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> + Maybe ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -updatePreparedRelayedGroup db vr user@User {userId} gInfo cReq cReqHash incognitoProfile linkEntityId rootPubKey memberPrivKey = do +updatePreparedRelayedGroup db vr user@User {userId} gInfo cReq cReqHash incognitoProfile linkEntityId rootPubKey memberPrivKey publicMemberCount_ = do currentTs <- liftIO getCurrentTime customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile - liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs + liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId publicMemberCount_ currentTs liftIO $ updateGroupMemberKeys db (groupId' gInfo) linkEntityId 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 -> Maybe ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () updateGroupMemberKeys db groupId linkEntityId rootPubKey memberPrivKey membershipGMId = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs index 87decab6e8..11e1270e75 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs @@ -42,7 +42,8 @@ ALTER TABLE groups ADD COLUMN shared_group_id BYTEA, ADD COLUMN root_priv_key BYTEA, ADD COLUMN root_pub_key BYTEA, - ADD COLUMN member_priv_key BYTEA; + ADD COLUMN member_priv_key BYTEA, + ADD COLUMN public_member_count BIGINT; ALTER TABLE group_profiles ADD COLUMN group_link BYTEA; @@ -89,7 +90,8 @@ ALTER TABLE groups DROP COLUMN shared_group_id, DROP COLUMN root_priv_key, DROP COLUMN root_pub_key, - DROP COLUMN member_priv_key; + DROP COLUMN member_priv_key, + DROP COLUMN public_member_count; ALTER TABLE group_profiles DROP COLUMN group_link; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs index f5f799914f..f0f0e151dc 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs @@ -56,6 +56,7 @@ ALTER TABLE groups ADD COLUMN shared_group_id BLOB; 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_link BLOB; @@ -103,6 +104,7 @@ ALTER TABLE groups DROP COLUMN shared_group_id; 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_link; 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 ee339cce94..99c1b1899b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -148,7 +148,7 @@ Query: 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.use_relays, g.relay_own_status, - 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.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.shared_group_id, 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, @@ -1215,8 +1215,8 @@ 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, use_relays, relay_own_status) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1224,8 +1224,8 @@ 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, - shared_group_id, root_priv_key, root_pub_key, member_priv_key) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + shared_group_id, root_priv_key, root_pub_key, member_priv_key, public_member_count) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1383,6 +1383,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 @@ -5127,7 +5135,7 @@ Query: 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.use_relays, g.relay_own_status, - 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.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.shared_group_id, 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, @@ -5163,7 +5171,7 @@ Query: 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.use_relays, g.relay_own_status, - 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.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.shared_group_id, 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, @@ -5192,7 +5200,7 @@ Query: 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.use_relays, g.relay_own_status, - 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.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.shared_group_id, 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, @@ -5513,6 +5521,22 @@ 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.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, + gr.relay_status, gr.relay_link + FROM group_relays gr + JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id + 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.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, @@ -6695,6 +6719,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=?) @@ -6979,6 +7007,10 @@ 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=?) @@ -7011,7 +7043,7 @@ Query: UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND g 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 df675b3c05..ecdde196d4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -171,7 +171,8 @@ CREATE TABLE groups( shared_group_id BLOB, root_priv_key BLOB, root_pub_key BLOB, - member_priv_key BLOB, -- received + 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 diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index bcfe78067b..0f25379204 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -665,21 +665,21 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe B64UrlByteString, Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe 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, groupLink) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupLink) :. (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, groupLink} businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow - groupSummary = GroupSummary {currentMembers} + groupSummary = GroupSummary {currentMembers, publicMemberCount} groupKeys = toGroupKeys groupKeysRow 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} @@ -760,7 +760,7 @@ groupInfoQueryFields = 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.use_relays, g.relay_own_status, - 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.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.shared_group_id, 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, @@ -861,12 +861,12 @@ getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") (groupId, userId, userContactId) -setPreparedGroupLinkInfo_ :: DB.Connection -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Int64 -> UTCTime -> IO () -setPreparedGroupLinkInfo_ db GroupInfo {groupId, membership} cReq cReqHash customUserProfileId currentTs = do +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 = ?, updated_at = ? WHERE group_id = ?" - (cReq, cReqHash, BI True, currentTs, groupId) + "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 diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 5dfbdf8f09..32d1c726bf 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -532,7 +532,8 @@ groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary - { currentMembers :: Int64 + { currentMembers :: Int64, + publicMemberCount :: Maybe Int64 } deriving (Eq, Show) @@ -2036,7 +2037,7 @@ $(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) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index da698bf4e0..b94c8c5924 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -465,7 +465,9 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> show (length conns) <> " connections on server " <> showSMPServer srv] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m - CEvtGroupLinkRelaysUpdated u g groupLink relays -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays + CEvtGroupLinkDataUpdated u g groupLink relays relaysChanged + | relaysChanged -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays + | otherwise -> [] 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] @@ -1708,12 +1710,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]) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a1674597d5..b07eb03e86 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -8421,6 +8421,16 @@ testChannels1RelayDeliver ps = 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 @@ -8478,20 +8488,21 @@ prepareChannel2Relays gName owner relay1 relay2 = do owner <## "wait for selected relay(s) to join, then you can invite members via group link" concurrentlyN_ - [ owner - <### [ -- one relay connects - ConsoleString ("#" <> gName <> ": group link relays updated, current relays:"), - StartsWith " - relay id 1: ", - StartsWith " - relay id 2: ", - "group link:", - Predicate (const True), -- consume group link line - -- second relay connects - ConsoleString ("#" <> gName <> ": group link relays updated, current relays:"), - " - relay id 1: active", - " - relay id 2: active", - "group link:", - Predicate (const True) -- consume group link line - ], + [ 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" + owner <## " - 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") ] @@ -8928,6 +8939,15 @@ testChannelRemoveMemberSigned ps = 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" @@ -8946,6 +8966,15 @@ testChannelRemoveMemberSigned ps = 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" @@ -8963,6 +8992,15 @@ testChannelRemoveMemberSigned ps = 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 -> @@ -9069,6 +9107,15 @@ testChannelSubscriberLeave ps = 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" @@ -9084,6 +9131,15 @@ testChannelSubscriberLeave ps = 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") @@ -9104,6 +9160,15 @@ testChannelSubscriberLeave ps = 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")