From 4e16792ddc4445bc5f416313fb75d87f3e066b98 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:59:29 +0000 Subject: [PATCH] desktop, android: channels and chat relays ui (#6670) --- apps/ios/Shared/Model/AppAPITypes.swift | 4 - .../Shared/Views/NewChat/AddChannelView.swift | 15 +- .../NetworkAndServers/ChatRelayView.swift | 6 +- .../NetworkAndServers/NetworkAndServers.swift | 15 + .../NetworkAndServers/OperatorView.swift | 4 + .../ProtocolServersView.swift | 4 + .../chat/simplex/common/model/ChatModel.kt | 108 +++- .../chat/simplex/common/model/SimpleXAPI.kt | 134 +++- .../simplex/common/views/chat/ChatView.kt | 140 ++++- .../simplex/common/views/chat/ComposeView.kt | 235 ++++++- .../views/chat/group/ChannelMembersView.kt | 119 ++++ .../views/chat/group/ChannelRelaysView.kt | 167 +++++ .../views/chat/group/GroupChatInfoView.kt | 257 ++++++-- .../common/views/chat/group/GroupLinkView.kt | 29 +- .../views/chat/group/GroupMemberInfoView.kt | 210 ++++--- .../views/chatlist/ChatListNavLinkView.kt | 4 +- .../common/views/newchat/AddChannelView.kt | 581 ++++++++++++++++++ .../common/views/newchat/AddGroupView.kt | 2 +- .../common/views/newchat/ConnectPlan.kt | 140 +++-- .../common/views/newchat/NewChatSheet.kt | 9 + .../networkAndServers/ChatRelayView.kt | 346 +++++++++++ .../networkAndServers/NetworkAndServers.kt | 62 +- .../networkAndServers/NewServerView.kt | 3 + .../networkAndServers/OperatorView.kt | 64 +- .../networkAndServers/ProtocolServerView.kt | 1 + .../networkAndServers/ProtocolServersView.kt | 70 ++- .../commonMain/resources/MR/base/strings.xml | 124 +++- .../resources/MR/images/ic_bigtop_updates.svg | 1 + 28 files changed, 2585 insertions(+), 269 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 336d21da3b..5031592f02 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1807,10 +1807,6 @@ enum UserServersError: Decodable { case .smp: return globalSMPError case .xftp: return globalXFTPError } - case let .duplicateChatRelayName(duplicateChatRelay): - return String.localizedStringWithFormat(NSLocalizedString("Duplicate chat relay name: %@", comment: "servers error"), duplicateChatRelay) - case let .duplicateChatRelayAddress(_, duplicateAddress): - return String.localizedStringWithFormat(NSLocalizedString("Duplicate chat relay address: %@", comment: "servers error"), duplicateAddress) default: return nil } } diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 4402d0a5ed..66cfd0c7b9 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -90,7 +90,8 @@ struct AddChannelView: View { if !hasRelays { ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning")) } else { - Text("Your profile will be shared with chat relays and subscribers.") + let name = ChatModel.shared.currentUser?.displayName ?? "" + Text("Your profile **\(name)** will be shared with channel relays and subscribers.") .foregroundColor(theme.colors.secondary) } } @@ -219,8 +220,8 @@ struct AddChannelView: View { // MARK: - Step 2: Progress private func progressStepView(_ gInfo: GroupInfo) -> some View { - let failedCount = groupRelays.filter { relayConnFailed($0) != nil }.count - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayConnFailed($0) == nil }.count + let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count let total = groupRelays.count return List { Group { @@ -257,7 +258,7 @@ struct AddChannelView: View { if relayListExpanded { ForEach(groupRelays) { relay in - let failed = relayConnFailed(relay) + let failed = relayMemberConnFailed(relay) if let err = failed { Button { showAlert( @@ -312,14 +313,14 @@ struct AddChannelView: View { .onChange(of: channelRelaysModel.groupRelays) { relays in guard channelRelaysModel.groupId == gInfo.groupId else { return } groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } - if relays.allSatisfy({ $0.relayStatus == .rsActive && relayConnFailed($0) == nil }) { + if relays.allSatisfy({ $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }) { showLinkStep = true channelRelaysModel.reset() } } } - private func relayConnFailed(_ relay: GroupRelay) -> String? { + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? .wrapped.activeConn?.connFailedErr } @@ -397,7 +398,7 @@ func relayDisplayName(_ relay: GroupRelay) -> String { } func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View { - let color: Color = connFailed ? .red : (status == .rsActive ? .green : .orange) + let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow) let text: LocalizedStringKey = connFailed ? "failed" : status.text return HStack(spacing: 4) { Circle() diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift index 790edb9be7..14c6b61a34 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -214,6 +214,8 @@ struct ChatRelayViewLink: View { @Binding var serverErrors: [UserServersError] @Binding var serverWarnings: [UserServersWarning] @Binding var relay: UserChatRelay + var duplicateRelayNames: Set + var duplicateRelayAddresses: Set var backLabel: LocalizedStringKey @Binding var selectedServer: String? @@ -233,7 +235,9 @@ struct ChatRelayViewLink: View { } label: { HStack { Group { - if !relay.enabled { + if duplicateRelayNames.contains(relay.name) || duplicateRelayAddresses.contains(relay.address) { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } else if !relay.enabled { Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) } else { showRelayTestStatus(relay: relay) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 3ff1a2ee68..5e9d558917 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -483,6 +483,21 @@ func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set { return Set(duplicateHostsList) } +func findDuplicateRelayNames(_ serverErrors: [UserServersError]) -> Set { + Set(serverErrors.compactMap { err in + if case let .duplicateChatRelayName(duplicateChatRelay) = err { return duplicateChatRelay } + else { return nil } + }) +} + +func findDuplicateRelayAddresses(_ serverErrors: [UserServersError]) -> Set { + Set(serverErrors.compactMap { err in + if case let .duplicateChatRelayAddress(_, duplicateAddress) = err { return duplicateAddress } + else { return nil } + }) +} + + func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) { let userServersToSave = userServers.wrappedValue Task { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index f8b66d3697..bc4fb4a337 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -42,6 +42,8 @@ struct OperatorView: View { private func operatorView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayNames = findDuplicateRelayNames(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return VStack { List { Section { @@ -81,6 +83,8 @@ struct OperatorView: View { serverErrors: $serverErrors, serverWarnings: $serverWarnings, relay: relay, + duplicateRelayNames: duplicateRelayNames, + duplicateRelayAddresses: duplicateRelayAddresses, backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", selectedServer: $selectedServer ) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index e521c7ea26..60ab42e8fa 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -43,6 +43,8 @@ struct YourServersView: View { private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayNames = findDuplicateRelayNames(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return List { if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { Section { @@ -53,6 +55,8 @@ struct YourServersView: View { serverErrors: $serverErrors, serverWarnings: $serverWarnings, relay: relay, + duplicateRelayNames: duplicateRelayNames, + duplicateRelayAddresses: duplicateRelayAddresses, backLabel: "Your servers", selectedServer: $selectedServer ) 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 668e18cf6c..705323606a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -78,6 +78,22 @@ object ConnectProgressManager { val connectProgressManager = ConnectProgressManager +object ChannelRelaysModel { + val groupId = mutableStateOf(null) + val groupRelays = mutableStateListOf() + + fun set(groupId: Long, groupRelays: List) { + this.groupId.value = groupId + this.groupRelays.clear() + this.groupRelays.addAll(groupRelays) + } + + fun reset() { + groupId.value = null + groupRelays.clear() + } +} + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -110,9 +126,13 @@ object ChatModel { val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) + val creatingChannelId = mutableStateOf(null) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) val membersLoaded = mutableStateOf(false) + // Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart. + // APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join. + val channelRelayHostnames = mutableStateMapOf>() // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -847,12 +867,22 @@ object ChatModel { } fun removeChat(rhId: Long?, id: String) { + var groupId: Long? = null val i = getChatIndex(rhId, id) if (i != -1) { val chat = chats.removeAt(i) + groupId = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.groupId removePresetChatTags(chat.chatInfo, chat.chatStats) removeWallpaperFilesFromChat(chat) } + if (chatId.value == id) { + groupMembers.value = emptyList() + groupMembersIndexes.value = emptyMap() + if (groupId != null) { + channelRelayHostnames.remove(groupId) + } + membersLoaded.value = false + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -861,8 +891,8 @@ object ChatModel { updateGroup(rhId, groupInfo) return false } - // update current chat - return if (chatId.value == groupInfo.id) { + // update current chat or channel being created + return if (chatId.value == groupInfo.id || creatingChannelId.value == groupInfo.id) { if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) { // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn groupMembers.value = emptyList() @@ -1220,6 +1250,7 @@ data class User( val autoAcceptMemberContacts: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, + val userChatRelay: Boolean, ): NamedChat, UserLike { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName @@ -1250,6 +1281,7 @@ data class User( autoAcceptMemberContacts = false, viewPwdHash = null, uiThemes = null, + userChatRelay = false, ) } } @@ -1583,7 +1615,11 @@ sealed class ChatInfo: SomeChat, NamedChat { return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) } if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { - return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + return if (groupInfo.useRelays) { + generalGetString(MR.strings.you_are_subscriber) to null + } else { + generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } } return null } @@ -2009,6 +2045,8 @@ sealed class ForwardConfirmation { @Serializable data class GroupInfo ( val groupId: Long, + val useRelays: Boolean, + val relayOwnStatus: RelayStatus? = null, override val localDisplayName: String, val groupProfile: GroupProfile, val businessChat: BusinessChatInfo? = null, @@ -2061,7 +2099,9 @@ data class GroupInfo ( get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive val chatIconName: ImageResource - get() = when (businessChat?.chatType) { + get() = if (useRelays) { + MR.images.ic_bigtop_updates + } else when (businessChat?.chatType) { null -> MR.images.ic_supervised_user_circle_filled BusinessChatType.Business -> MR.images.ic_work_filled_padded BusinessChatType.Customer -> MR.images.ic_account_circle_filled @@ -2085,6 +2125,7 @@ data class GroupInfo ( companion object { val sampleData = GroupInfo( groupId = 1, + useRelays = false, localDisplayName = "team", groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, @@ -2120,6 +2161,7 @@ data class GroupProfile ( override val shortDescr: String?, val description: String? = null, override val image: String? = null, + val groupLink: String? = null, override val localAlias: String = "", val groupPreferences: GroupPreferences? = null, val memberAdmission: GroupMemberAdmission? = null @@ -2166,6 +2208,48 @@ data class GroupShortLinkData ( val groupProfile: GroupProfile ) +@Serializable +enum class RelayStatus { + @SerialName("new") RsNew, + @SerialName("invited") RsInvited, + @SerialName("accepted") RsAccepted, + @SerialName("active") RsActive; + + val text: String get() = when (this) { + RsNew -> generalGetString(MR.strings.relay_status_new) + RsInvited -> generalGetString(MR.strings.relay_status_invited) + RsAccepted -> generalGetString(MR.strings.relay_status_accepted) + RsActive -> generalGetString(MR.strings.relay_status_active) + } +} + +@Serializable +data class UserChatRelay( + val chatRelayId: Long?, + val address: String, + val name: String, + val domains: List, + val preset: Boolean, + val tested: Boolean? = null, + val enabled: Boolean, + val deleted: Boolean, +) { + @Transient + private val createdAt: Date = Date() + val id: String get() = "$address $createdAt" +} + +@Serializable +data class GroupRelay( + val groupRelayId: Long, + val groupMemberId: Long, + val userChatRelay: UserChatRelay, + val relayStatus: RelayStatus, + val relayLink: String? = null +) { + val id: Long get() = groupRelayId +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -2196,7 +2280,8 @@ data class GroupMember ( val memberContactProfileId: Long, var activeConn: Connection? = null, val supportChat: GroupSupportChat? = null, - val memberChatVRange: VersionRange + val memberChatVRange: VersionRange, + val relayLink: String? = null ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -2305,14 +2390,14 @@ data class GroupMember ( } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null + if (memberRole == GroupMemberRole.Relay || !canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null else groupInfo.membership.memberRole.let { userRole -> GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberRole < GroupMemberRole.Moderator + return memberRole != GroupMemberRole.Relay && memberRole < GroupMemberRole.Moderator && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2373,7 +2458,8 @@ data class GroupMemberIds( @Serializable enum class GroupMemberRole(val memberRole: String) { - @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("relay") Relay("relay"), // order matters in comparisons + @SerialName("observer") Observer("observer"), @SerialName("author") Author("author"), @SerialName("member") Member("member"), @SerialName("moderator") Moderator("moderator"), @@ -2385,6 +2471,7 @@ enum class GroupMemberRole(val memberRole: String) { } val text: String get() = when (this) { + Relay -> generalGetString(MR.strings.group_member_role_relay) Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) @@ -2844,6 +2931,8 @@ data class ChatItem ( } else { null } + } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.ChannelRcv) { + null } else { null } @@ -3185,6 +3274,7 @@ sealed class CIDirection { @Serializable @SerialName("directRcv") class DirectRcv: CIDirection() @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection() @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection() + @Serializable @SerialName("channelRcv") class ChannelRcv: CIDirection() @Serializable @SerialName("localSnd") class LocalSnd: CIDirection() @Serializable @SerialName("localRcv") class LocalRcv: CIDirection() @@ -3193,6 +3283,7 @@ sealed class CIDirection { is DirectRcv -> false is GroupSnd -> true is GroupRcv -> false + is ChannelRcv -> false is LocalSnd -> true is LocalRcv -> false } @@ -3733,6 +3824,7 @@ class CIQuote ( is CIDirection.DirectRcv -> null is CIDirection.GroupSnd -> membership?.displayName ?: generalGetString(MR.strings.sender_you_pronoun) is CIDirection.GroupRcv -> chatDir.groupMember.displayName + is CIDirection.ChannelRcv -> null is CIDirection.LocalSnd -> generalGetString(MR.strings.sender_you_pronoun) is CIDirection.LocalRcv -> null null -> null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 388a8064c4..df479e77c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1071,8 +1071,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -1130,8 +1130,8 @@ object ChatController { return null } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, sendAsGroup: Boolean = false, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } @@ -1250,10 +1250,10 @@ object ChatController { return false } - suspend fun validateServers(rh: Long?, userServers: List): List? { + suspend fun validateServers(rh: Long?, userServers: List): Pair, List>? { val userId = currentUserId("validateServers") val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) - if (r is API.Result && r.res is CR.UserServersValidation) return r.res.serverErrors + if (r is API.Result && r.res is CR.UserServersValidation) return Pair(r.res.serverErrors, r.res.serverWarnings) Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") return null } @@ -1552,9 +1552,9 @@ object ChatController { return null } - suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData): Chat? { + suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, groupShortLinkData)) + val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData)) if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}") @@ -1587,9 +1587,9 @@ object ChatController { return null } - suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): GroupInfo? { + suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): Pair>? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) - if (r is API.Result && r.res is CR.StartedConnectionToGroup) return r.res.groupInfo + if (r is API.Result && r.res is CR.StartedConnectionToGroup) return Pair(r.res.groupInfo, r.res.relayResults) if (r != null) { Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") apiConnectResponseAlert(r) @@ -2097,6 +2097,20 @@ object ChatController { return null } + suspend fun apiNewPublicGroup(rh: Long?, incognito: Boolean, relayIds: List, groupProfile: GroupProfile): Triple>? { + val userId = kotlin.runCatching { currentUserId("apiNewPublicGroup") }.getOrElse { return null } + val r = sendCmdWithRetry(rh, CC.ApiNewPublicGroup(userId, incognito, relayIds, groupProfile)) + if (r is API.Result && r.res is CR.PublicGroupCreated) return Triple(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r != null) throw Exception("${r.responseType}: ${r.details}") + return null + } + + suspend fun apiGetGroupRelays(groupId: Long): List { + val r = sendCmd(null, CC.ApiGetGroupRelays(groupId)) + if (r is API.Result && r.res is CR.GroupRelays) return r.res.groupRelays + return emptyList() + } + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member @@ -2812,6 +2826,7 @@ object ChatController { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") @@ -2926,6 +2941,7 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) } if ( chatModel.chatId.value == r.groupInfo.id @@ -2961,6 +2977,16 @@ object ChatController { chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } + is CR.GroupLinkRelaysUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + val relaysModel = ChannelRelaysModel + if (relaysModel.groupId.value == r.groupInfo.groupId) { + relaysModel.set(r.groupInfo.groupId, r.groupRelays) + } + } + } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -3559,7 +3585,7 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3575,8 +3601,10 @@ sealed class CC { class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() + class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List, val groupProfile: GroupProfile): CC() + class ApiGetGroupRelays(val groupId: Long): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() @@ -3633,7 +3661,7 @@ sealed class CC { class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() class APIConnectPlan(val userId: Long, val connLink: String): CC() class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC() - class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val groupShortLinkData: GroupShortLinkData): CC() + class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC() class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC() @@ -3747,7 +3775,7 @@ sealed class CC { is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" @@ -3768,12 +3796,14 @@ sealed class CC { is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) " as_group=on" else ""} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } is ApiPlanForwardChatItems -> { "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" + is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}" + is ApiGetGroupRelays -> "/_get relays #$groupId" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" @@ -3830,7 +3860,7 @@ sealed class CC { is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" is APIConnectPlan -> "/_connect plan $userId $connLink" is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}" - is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(groupShortLinkData)}" + is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}" @@ -3949,6 +3979,8 @@ sealed class CC { is ApiForwardChatItems -> "apiForwardChatItems" is ApiPlanForwardChatItems -> "apiPlanForwardChatItems" is ApiNewGroup -> "apiNewGroup" + is ApiNewPublicGroup -> "apiNewPublicGroup" + is ApiGetGroupRelays -> "apiGetGroupRelays" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiAcceptMember -> "apiAcceptMember" @@ -4120,7 +4152,8 @@ fun onOff(b: Boolean): String = if (b) "on" else "off" @Serializable data class NewUser( val profile: Profile?, - val pastTimestamp: Boolean + val pastTimestamp: Boolean, + val userChatRelay: Boolean = false ) sealed class ChatPagination { @@ -4373,7 +4406,8 @@ data class ServerRoles( data class UserOperatorServers( val operator: ServerOperator?, val smpServers: List, - val xftpServers: List + val xftpServers: List, + val chatRelays: List = emptyList() ) { val id: String get() = operator?.operatorId?.toString() ?: "nil operator" @@ -4412,19 +4446,24 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("duplicateChatRelayName") data class DuplicateChatRelayName(val duplicateChatRelay: String): UserServersError() + @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? get() = when (this.protocol_) { ServerProtocol.SMP -> globalSMPError ServerProtocol.XFTP -> globalXFTPError + null -> null } - private val protocol_: ServerProtocol + private val protocol_: ServerProtocol? get() = when (this) { is NoServers -> this.protocol is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is DuplicateChatRelayName -> null + is DuplicateChatRelayAddress -> null } val globalSMPError: String? @@ -4468,6 +4507,34 @@ sealed class UserServersError { } } +@Serializable +sealed class UserServersWarning { + @Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning() + + val globalWarning: String? + get() = when (this) { + is NoChatRelays -> { + val text = generalGetString(MR.strings.no_chat_relays_enabled) + if (user != null) { + String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text + } else text + } + } +} + +@Serializable +data class RelayConnectionResult( + val relayMember: GroupMember, + val relayError: ChatError? = null +) + +@Serializable +data class GroupShortLinkInfo( + val direct: Boolean, + val groupRelays: List, + val sharedGroupId: String? = null +) + @Serializable data class UserServer( val remoteHostId: Long?, @@ -6125,7 +6192,7 @@ sealed class CR { @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() - @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List, val serverWarnings: List = emptyList()): CR() @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @@ -6156,7 +6223,7 @@ sealed class CR { @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("startedConnectionToContact") class StartedConnectionToContact(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo, val relayResults: List = emptyList()): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @@ -6195,6 +6262,8 @@ sealed class CR { @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @@ -6217,10 +6286,11 @@ sealed class CR { @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() + @Serializable @SerialName("groupLinkRelaysUpdated") class GroupLinkRelaysUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): 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() @@ -6376,6 +6446,8 @@ sealed class CR { is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" + is PublicGroupCreated -> "publicGroupCreated" + is GroupRelays -> "groupRelays" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" @@ -6402,6 +6474,7 @@ sealed class CR { is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" is GroupUpdated -> "groupUpdated" + is GroupLinkRelaysUpdated -> "groupLinkRelaysUpdated" is GroupLinkCreated -> "groupLinkCreated" is CRGroupLink -> "groupLink" is GroupLinkDeleted -> "groupLinkDeleted" @@ -6550,6 +6623,8 @@ sealed class CR { is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) + is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") @@ -6576,6 +6651,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 GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is CRGroupLink -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) @@ -6719,7 +6795,7 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() @@ -7011,6 +7087,7 @@ sealed class ChatErrorType { is UserUnknown -> "userUnknown" is ActiveUserExists -> "activeUserExists" is UserExists -> "userExists" + is ChatRelayExists -> "chatRelayExists" is DifferentActiveUser -> "differentActiveUser" is CantDeleteActiveUser -> "cantDeleteActiveUser" is CantDeleteLastUser -> "cantDeleteLastUser" @@ -7091,6 +7168,7 @@ sealed class ChatErrorType { @Serializable @SerialName("userUnknown") object UserUnknown: ChatErrorType() @Serializable @SerialName("activeUserExists") object ActiveUserExists: ChatErrorType() @Serializable @SerialName("userExists") class UserExists(val contactName: String): ChatErrorType() + @Serializable @SerialName("chatRelayExists") object ChatRelayExists: ChatErrorType() @Serializable @SerialName("differentActiveUser") class DifferentActiveUser(val commandUserId: Long, val activeUserId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteActiveUser") class CantDeleteActiveUser(val userId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteLastUser") class CantDeleteLastUser(val userId: Long): ChatErrorType() @@ -7170,6 +7248,7 @@ sealed class StoreError { get() = when (this) { is DuplicateName -> "duplicateName" is UserNotFound -> "userNotFound $userId" + is RelayUserNotFound -> "relayUserNotFound" is UserNotFoundByName -> "userNotFoundByName $contactName" is UserNotFoundByContactId -> "userNotFoundByContactId $contactId" is UserNotFoundByGroupId -> "userNotFoundByGroupId $groupId" @@ -7193,6 +7272,7 @@ sealed class StoreError { is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound $contactId" is GroupWithoutUser -> "groupWithoutUser" is DuplicateGroupMember -> "duplicateGroupMember" + is DuplicateMemberId -> "duplicateMemberId" is GroupAlreadyJoined -> "groupAlreadyJoined" is GroupInvitationNotFound -> "groupInvitationNotFound" is NoteFolderAlreadyExists -> "noteFolderAlreadyExists $noteFolderId" @@ -7233,6 +7313,9 @@ sealed class StoreError { is HostMemberIdNotFound -> "hostMemberIdNotFound $groupId" is ContactNotFoundByFileId -> "contactNotFoundByFileId $fileId" is NoGroupSndStatus -> "noGroupSndStatus $itemId $groupMemberId" + is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" + is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" + is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" @@ -7248,6 +7331,7 @@ sealed class StoreError { @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @Serializable @SerialName("userNotFound") class UserNotFound(val userId: Long): StoreError() + @Serializable @SerialName("relayUserNotFound") object RelayUserNotFound: StoreError() @Serializable @SerialName("userNotFoundByName") class UserNotFoundByName(val contactName: String): StoreError() @Serializable @SerialName("userNotFoundByContactId") class UserNotFoundByContactId(val contactId: Long): StoreError() @Serializable @SerialName("userNotFoundByGroupId") class UserNotFoundByGroupId(val groupId: Long): StoreError() @@ -7271,6 +7355,7 @@ sealed class StoreError { @Serializable @SerialName("memberContactGroupMemberNotFound") class MemberContactGroupMemberNotFound(val contactId: Long): StoreError() @Serializable @SerialName("groupWithoutUser") object GroupWithoutUser: StoreError() @Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError() + @Serializable @SerialName("duplicateMemberId") object DuplicateMemberId: StoreError() @Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError() @Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError() @Serializable @SerialName("noteFolderAlreadyExists") class NoteFolderAlreadyExists(val noteFolderId: Long): StoreError() @@ -7311,6 +7396,9 @@ sealed class StoreError { @Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): StoreError() @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() + @Serializable @SerialName("userChatRelayNotFound") class UserChatRelayNotFound(val chatRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFound") class GroupRelayNotFound(val groupRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFoundByMemberId") class GroupRelayNotFoundByMemberId(val groupMemberId: Long): StoreError() @Serializable @SerialName("duplicateGroupMessage") class DuplicateGroupMessage(val groupId: Long, val sharedMsgId: String, val authorGroupMemberId: Long?, val forwardedByGroupMemberId: Long?): StoreError() @Serializable @SerialName("remoteHostNotFound") class RemoteHostNotFound(val remoteHostId: Long): StoreError() @Serializable @SerialName("remoteHostUnknown") object RemoteHostUnknown: StoreError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index c518b156d9..18122c63a9 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 @@ -201,6 +201,17 @@ fun ChatView( chatModel.chatSubStatus.value = null } } + if (cInfo is ChatInfo.Group && cInfo.groupInfo.useRelays) { + withBGApi { + setGroupMembers(chatRh, cInfo.groupInfo, chatModel) + if (cInfo.groupInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = chatModel.controller.apiGetGroupRelays(cInfo.groupInfo.groupId) + withContext(Dispatchers.Main) { + ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays) + } + } + } + } } } } @@ -358,6 +369,7 @@ fun ChatView( chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() chatModel.membersLoaded.value = false + ChannelRelaysModel.reset() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -488,7 +500,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) } } } @@ -810,7 +822,7 @@ fun updateAvailableContent(chatRh: Long?, activeChat: State, availableCon withBGApi { Log.e(TAG, "updateAvailableContent") val chatInfo = activeChat.value?.chatInfo - if (chatInfo == null) return@withBGApi + if (chatInfo == null || chatInfo !is ChatInfo.Direct && chatInfo !is ChatInfo.Group && chatInfo !is ChatInfo.Local) return@withBGApi val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null) if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi if (types == null) { @@ -842,10 +854,14 @@ private fun connectingText(chatInfo: ChatInfo): String? { } is ChatInfo.Group -> - when (chatInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null - GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) - else -> null + if (chatInfo.groupInfo.useRelays) { + null + } else { + when (chatInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null + GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) + else -> null + } } else -> null @@ -1944,6 +1960,89 @@ fun BoxScope.ChatItemsList( } } } + } else if (cItem.chatDir is CIDirection.ChannelRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun ChannelNameAndRole() { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + Text( + chatInfo.groupInfo.chatViewName, + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + Text( + generalGetString(MR.strings.channel_role_label), + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + Box(Modifier.clickable { showChatInfo() }) { + ProfileImage( + MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, + chatInfo.groupInfo.image, + chatInfo.groupInfo.chatIconName, + backgroundColor = MaterialTheme.colors.background + ) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } + } + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + ChannelNameAndRole() + Item() + } + } else { + Item() + } + } + } else { + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Row( + Modifier + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(swipeableOrSelectionModifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range) + } + } + } } else { ChatItemBox { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { @@ -2017,13 +2116,14 @@ fun BoxScope.ChatItemsList( val groupInfo = chatInfo.groupInfo when (groupInfo.businessChat?.chatType) { null -> { + val isChannel = groupInfo.useRelays if (groupInfo.nextConnectPrepared) { - generalGetString(MR.strings.chat_banner_join_group) + generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) } else { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> generalGetString(MR.strings.chat_banner_join_group) - GroupMemberStatus.MemCreator -> generalGetString(MR.strings.chat_banner_your_group) - else -> generalGetString(MR.strings.chat_banner_group) + GroupMemberStatus.MemInvited -> generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) + GroupMemberStatus.MemCreator -> generalGetString(if (isChannel) MR.strings.chat_banner_your_channel else MR.strings.chat_banner_your_group) + else -> generalGetString(if (isChannel) MR.strings.chat_banner_channel else MR.strings.chat_banner_group) } } } @@ -3477,6 +3577,8 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa val sameMemberAndDirection = if (prevItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == prevItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && prevItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == prevItem.chatDir.sent val largeGap = !sameMemberAndDirection || (abs(prevItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) @@ -3494,12 +3596,26 @@ private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): val sameMemberAndDirection = if (nextItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == nextItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && nextItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == nextItem.chatDir.sent return !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) } -private fun shouldShowAvatar(current: ChatItem, older: ChatItem?) = - current.chatDir is CIDirection.GroupRcv && (older == null || (older.chatDir !is CIDirection.GroupRcv || older.chatDir.groupMember.memberId != current.chatDir.groupMember.memberId)) +private fun shouldShowAvatar(current: ChatItem, older: ChatItem?): Boolean { + val oldIsGroupRcv = older?.chatDir is CIDirection.GroupRcv || older?.chatDir is CIDirection.ChannelRcv + val sameMember = when { + older?.chatDir is CIDirection.GroupRcv && current.chatDir is CIDirection.GroupRcv -> + older.chatDir.groupMember.memberId == current.chatDir.groupMember.memberId + older?.chatDir is CIDirection.ChannelRcv && current.chatDir is CIDirection.ChannelRcv -> true + else -> false + } + return when { + current.chatDir is CIDirection.GroupRcv -> older == null || !oldIsGroupRcv || !sameMember + current.chatDir is CIDirection.ChannelRcv -> older == null || !oldIsGroupRcv || !sameMember + else -> false + } +} @Preview/*( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index eebf4a7bf8..b2b83e8dc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -30,8 +30,13 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.common.views.chat.group.relayConnStatus import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.RelayProgressIndicator +import chat.simplex.common.views.newchat.RelayStatusIndicator +import chat.simplex.common.views.newchat.relayDisplayName import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* @@ -490,6 +495,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, scope = cInfo.groupChatScope(), + sendAsGroup = (cInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -588,15 +594,19 @@ fun ComposeView( val mc = checkLinkPreview() sending() val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() - val groupInfo = chatModel.controller.apiConnectPreparedGroup( + val result = chatModel.controller.apiConnectPreparedGroup( rh = chat.remoteHostId, groupId = chat.chatInfo.apiId, incognito = incognito, msg = mc ) - if (groupInfo != null) { + if (result != null) { + val (groupInfo, relayResults) = result withContext(Dispatchers.Main) { chatsCtx.updateGroup(chat.remoteHostId, groupInfo) + chatModel.channelRelayHostnames.remove(groupInfo.groupId) + chatModel.groupMembers.value = relayResults.map { it.relayMember } + chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -616,6 +626,7 @@ fun ComposeView( toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, toScope = chat.chatInfo.groupChatScope(), + sendAsGroup = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, fromScope = fromChatInfo.groupChatScope(), @@ -1353,7 +1364,7 @@ fun ComposeView( icon: ImageResource, connect: () -> Unit ) { - var modifier = Modifier.height(60.dp).fillMaxWidth() + var modifier = Modifier.height(57.dp).fillMaxWidth() modifier = if (composeState.value.inProgress) modifier else modifier.clickable(onClick = { connect() }) Box( modifier, @@ -1374,7 +1385,7 @@ fun ComposeView( color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - if (composeState.value.progressByTimeout) { + if (composeState.value.progressByTimeout && chat.chatInfo.groupInfo_?.useRelays != true) { Box( Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING_HALF), contentAlignment = Alignment.CenterEnd @@ -1442,9 +1453,11 @@ fun ComposeView( composeState.value = composeState.value.copy(progressByTimeout = newProgressByTimeout) } + val relayListExpanded = remember { mutableStateOf(false) } + Column { val currentUser = chatModel.currentUser.value - if (chat.chatInfo.nextConnectPrepared && currentUser != null) { + if (chat.chatInfo.nextConnectPrepared && !composeState.value.inProgress && currentUser != null) { ComposeContextProfilePickerView( rhId = rhId, chat = chat, @@ -1452,6 +1465,33 @@ fun ComposeView( ) } + val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo + if (gInfo != null && gInfo.useRelays) { + if (gInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList() + val failedCount = relays.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = relays.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + if (relays.isNotEmpty() && activeCount < relays.size) { + OwnerChannelRelayBar(chatModel, relays, activeCount, failedCount, relayListExpanded) + } + } else { + val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted() + val relayMembers = chatModel.groupMembers.value + .filter { it.memberRole == GroupMemberRole.Relay } + .sortedBy { hostFromRelayLink(it.relayLink ?: "") } + val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress + val connectedCount = relayMembers.count { it.activeConn?.connStatus == ConnStatus.Ready } + val deletedCount = relayMembers.count { it.activeConn?.connStatus == ConnStatus.Deleted } + val failedCount = relayMembers.count { it.activeConn?.connFailedErr != null } + val errorCount = deletedCount + failedCount + val resolvedCount = connectedCount + deletedCount + val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size + if (total > 0 && (!showProgress || resolvedCount < total)) { + SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, errorCount, total, showProgress, relayListExpanded) + } + } + } + if ( chat.chatInfo is ChatInfo.Group && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext @@ -1506,9 +1546,10 @@ fun ComposeView( Divider() if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { if (chat.chatInfo.groupInfo.businessChat == null) { + val isChannel = chat.chatInfo.groupInfo.useRelays ConnectButtonView( - text = stringResource(MR.strings.compose_view_join_group), - icon = MR.images.ic_group_filled, + text = stringResource(if (isChannel) MR.strings.compose_view_join_channel else MR.strings.compose_view_join_group), + icon = if (isChannel) MR.images.ic_bigtop_updates else MR.images.ic_group_filled, connect = { withApi { connectPreparedGroup() } } ) } else { @@ -1579,9 +1620,187 @@ fun ComposeView( } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { AttachmentAndCommandsButtons() - SendMsgView_(disableSendButton = disableSendButton) + val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> + if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner) generalGetString(MR.strings.compose_view_broadcast) + else null + } + SendMsgView_(disableSendButton = disableSendButton, placeholder = broadcastPlaceholder) } } } } } + +@Composable +private fun OwnerChannelRelayBar( + chatModel: ChatModel, + relays: List, + activeCount: Int, + failedCount: Int, + relayListExpanded: MutableState +) { + val total = relays.size + val sorted = relays.sortedBy { relayDisplayName(it) } + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f), color = MaterialTheme.colors.secondary) + } + if (relayListExpanded.value) { + sorted.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + relayDisplayName(relay), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + RelayStatusIndicator(relay.relayStatus, connFailed = failedErr != null) + } + } + } + } +} + +@Composable +private fun SubscriberChannelRelayBar( + hostnames: List, + relayMembers: List, + connectedCount: Int, + errorCount: Int, + total: Int, + showProgress: Boolean, + relayListExpanded: MutableState +) { + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (showProgress && connectedCount + errorCount < total) { + RelayProgressIndicator(active = connectedCount, total = total) + } + val statusText = if (showProgress) { + if (errorCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_connected_with_errors), connectedCount, total, errorCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_connected), connectedCount, total) + } + } else { + String.format(generalGetString(MR.strings.relay_bar_count), total) + } + Text(statusText, modifier = Modifier.weight(1f), color = MaterialTheme.colors.secondary) + } + if (relayListExpanded.value) { + if (relayMembers.isEmpty()) { + hostnames.forEach { relay -> + RelayBarDetailRow { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relay)), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + } + } + } else { + relayMembers.forEach { m -> + val host = m.relayLink?.let { hostFromRelayLink(it) } + val failedErr = m.activeConn?.connFailedErr + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), host ?: m.chatViewName), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + val (statusText, statusColor) = relayConnStatus(m) + androidx.compose.foundation.Canvas(Modifier.size(8.dp)) { + drawCircle(color = statusColor) + } + Spacer(Modifier.width(4.dp)) + Text(statusText, color = MaterialTheme.colors.secondary, fontSize = 12.sp) + if (failedErr != null) { + Spacer(Modifier.width(4.dp)) + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } +} + +@Composable +private fun RelayBarHeader( + expanded: MutableState, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded.value = !expanded.value } + .padding(start = 12.dp, end = DEFAULT_PADDING_HALF, top = 8.dp, bottom = if (expanded.value) 4.dp else 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + content() + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_down else MR.images.ic_chevron_up), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +private fun RelayBarDetailRow( + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit +) { + val modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + Row( + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt new file mode 100644 index 0000000000..efa6510c95 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -0,0 +1,119 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelMembersView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember) -> Unit +) { + BackHandler(onBack = close) + val allMembers = remember { chatModel.groupMembers }.value + .filter { m -> + m.memberStatus != GroupMemberStatus.MemLeft + && m.memberStatus != GroupMemberStatus.MemRemoved + && m.groupMemberId != groupInfo.membership.groupMemberId + } + 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) + } 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 + ) + } + } else { + subscribers.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member) + } + } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun ChannelMemberRow(member: GroupMember) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt new file mode 100644 index 0000000000..e8f2a36fff --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -0,0 +1,167 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelRelaysView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + BackHandler(onBack = close) + var groupRelays by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + setGroupMembers(rhId, groupInfo, chatModel) + if (groupInfo.isOwner) { + groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + } + } + + ChannelRelaysLayout( + groupInfo = groupInfo, + chatModel = chatModel, + groupRelays = groupRelays, + showMemberInfo = showMemberInfo + ) +} + +@Composable +private fun ChannelRelaysLayout( + groupInfo: GroupInfo, + chatModel: ChatModel, + groupRelays: List, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + val relayMembers = remember { chatModel.groupMembers }.value + .filter { it.memberRole == GroupMemberRole.Relay } + + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.channel_relays_title)) + + if (relayMembers.isEmpty()) { + SectionView { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + generalGetString(MR.strings.no_chat_relays), + color = MaterialTheme.colors.secondary + ) + } + } + } else { + SectionView { + relayMembers.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + val statusText = if (groupInfo.isOwner) { + ownerRelayStatusText(member, groupRelays) + } else { + subscriberRelayStatusText(member) + } + RelayMemberRow(member, statusText) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages)) + } + SectionBottomSpacer() + } +} + +@Composable +private fun RelayMemberRow(member: GroupMember, statusText: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onBackground + ) + Text( + statusText, + maxLines = 1, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } +} + +private fun subscriberRelayStatusText(member: GroupMember): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + relayConnStatus(member).first + } +} + +private fun ownerRelayStatusText(member: GroupMember, groupRelays: List): String { + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.relay_conn_status_failed) + } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus?.text + ?: relayConnStatus(member).first + } +} + +fun relayConnStatus(member: GroupMember): Pair { + return when (member.activeConn?.connStatus) { + is ConnStatus.Ready -> generalGetString(MR.strings.relay_conn_status_connected) to Color.Green + is ConnStatus.Deleted -> generalGetString(MR.strings.relay_conn_status_deleted) to Color.Red + is ConnStatus.Failed -> generalGetString(MR.strings.relay_conn_status_failed) to Color.Red + else -> generalGetString(MR.strings.relay_conn_status_connecting) to WarningYellow + } +} + +fun hostFromRelayLink(link: String): String { + val ft = parseToMarkdown(link) + if (ft != null) { + for (f in ft) { + val format = f.format + if (format is Format.SimplexLink) { + val host = format.smpHosts.firstOrNull() + if (host != null) return host + } + } + } + return link +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index dd3374d50b..99e5d6d5e2 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 @@ -41,6 +41,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions +import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -114,7 +115,7 @@ fun ModalData.GroupChatInfoView( } } }, - showMemberInfo = { member -> + showMemberInfo = { member, groupRelay -> withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -126,7 +127,7 @@ fun ModalData.GroupChatInfoView( } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { closeCurrent() close() } @@ -165,7 +166,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -175,9 +176,14 @@ fun ModalData.GroupChatInfoView( fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { val chatInfo = chat.chatInfo - val titleId = if (groupInfo.businessChat == null) MR.strings.delete_group_question else MR.strings.delete_chat_question + val titleId = if (groupInfo.useRelays) MR.strings.delete_channel_question + else if (groupInfo.businessChat == null) MR.strings.delete_group_question + else MR.strings.delete_chat_question val messageId = - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.membership.memberCurrent) MR.strings.delete_channel_for_all_subscribers_cannot_undo_warning + else MR.strings.delete_channel_for_self_cannot_undo_warning + } else if (groupInfo.businessChat == null) { if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning } else { @@ -209,8 +215,12 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl } fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - val titleId = if (groupInfo.businessChat == null) MR.strings.leave_group_question else MR.strings.leave_chat_question - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.leave_channel_question + else if (groupInfo.businessChat == null) MR.strings.leave_group_question + else MR.strings.leave_chat_question + val messageId = if (groupInfo.useRelays) + MR.strings.you_will_stop_receiving_messages_from_this_channel_chat_history_will_be_preserved + else if (groupInfo.businessChat == null) MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved else MR.strings.you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved @@ -229,12 +239,16 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl } private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question + else MR.strings.button_remove_member_question + val messageId = if (groupInfo.useRelays) + MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone + else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone else MR.strings.member_will_be_removed_from_chat_cannot_be_undone AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_member_question), + generalGetString(titleId), generalGetString(messageId), buttons = { Column { @@ -358,6 +372,22 @@ fun AddGroupMembersButton( ) } +@Composable +fun ChannelLinkActionButton( + modifier: Modifier, + groupInfo: GroupInfo, + manageGroupLink: () -> Unit +) { + InfoViewActionButton( + modifier = modifier, + icon = painterResource(MR.images.ic_link), + title = stringResource(MR.strings.action_button_channel_link), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = manageGroupLink + ) +} + @Composable fun UserSupportChatButton( chat: Chat, @@ -409,7 +439,7 @@ fun ModalData.GroupChatInfoLayout( appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, addMembers: () -> Unit, - showMemberInfo: (GroupMember) -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, openMemberSupport: () -> Unit, @@ -478,14 +508,19 @@ fun ModalData.GroupChatInfoLayout( Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { + val showThreeButtons = if (groupInfo.useRelays) groupInfo.isOwner else groupInfo.canAddMembers Row( Modifier - .widthIn(max = if (groupInfo.canAddMembers) 320.dp else 230.dp) + .widthIn(max = if (showThreeButtons) 320.dp else 230.dp) .padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - if (groupInfo.canAddMembers) { + if (groupInfo.useRelays && groupInfo.isOwner) { + SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) + ChannelLinkActionButton(modifier = Modifier.fillMaxWidth(0.5f), groupInfo, manageGroupLink) + MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) + } else if (!groupInfo.useRelays && groupInfo.canAddMembers) { SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) AddGroupMembersButton(modifier = Modifier.fillMaxWidth(0.5f), chat, groupInfo) MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) @@ -499,58 +534,88 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() var anyTopSectionRowShow = false - SectionView { - if (groupInfo.canAddMembers && groupInfo.businessChat == null) { - anyTopSectionRowShow = true - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) + if (groupInfo.useRelays) { + SectionView { + if (groupInfo.isOwner && groupLink != null) { + anyTopSectionRowShow = true + ChannelLinkButton(manageGroupLink) + } else if (groupInfo.groupProfile.groupLink != null) { + anyTopSectionRowShow = true + ChannelLinkQRCodeSection(groupInfo.groupProfile.groupLink!!) + } + if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { + anyTopSectionRowShow = true + ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) } } - if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - anyTopSectionRowShow = true - MemberSupportButton(chat, openMemberSupport) + if (!groupInfo.isOwner && groupInfo.groupProfile.groupLink != null) { + SectionTextFooter(stringResource(MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect)) } - if (groupInfo.canModerate) { - anyTopSectionRowShow = true - GroupReportsButton(chat) { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } else { + SectionView { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { + anyTopSectionRowShow = true + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) } } - } - if ( - groupInfo.membership.memberActive && - (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) - ) { - anyTopSectionRowShow = true - UserSupportChatButton(chat, groupInfo, scrollToItemId) + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton(chat) { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + if ( + groupInfo.membership.memberActive && + (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + ) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } } } + val showEditSection = (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) + || groupInfo.groupProfile.description != null + || !groupInfo.useRelays if (anyTopSectionRowShow) { SectionDividerSpaced(maxBottomPadding = false) } - - SectionView { - if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { - EditGroupProfileButton(editGroupProfile) + if (showEditSection) { + SectionView { + if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { + val editProfileTitleId = if (groupInfo.useRelays) MR.strings.button_edit_channel_profile else MR.strings.button_edit_group_profile + EditGroupProfileButton(editProfileTitleId, editGroupProfile) + } + if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { + AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + } + if (!groupInfo.useRelays) { + val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences + GroupPreferencesButton(prefsTitleId, openPreferences) + } } - if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { - AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + if (!groupInfo.useRelays) { + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) } - val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences - GroupPreferencesButton(prefsTitleId, openPreferences) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) SectionView { - if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { - SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) - } else { - SendReceiptsOptionDisabled() + if (!groupInfo.useRelays) { + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) + } else { + SendReceiptsOptionDisabled() + } } WallpaperButton { ModalManager.end.showModal { @@ -566,7 +631,7 @@ fun ModalData.GroupChatInfoLayout( } SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { if (groupInfo.canAddMembers) { val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers @@ -589,7 +654,7 @@ fun ModalData.GroupChatInfoLayout( } } } - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { items(filteredMembers.value, key = { it.groupMemberId }) { member -> Divider() val showMenu = remember { mutableStateOf(false) } @@ -601,7 +666,7 @@ fun ModalData.GroupChatInfoLayout( toggleItemSelection(member.groupMemberId, selectedItems) } } else { - showMemberInfo(member) + showMemberInfo(member, null) } }, longClick = { showMenu.value = true }, @@ -622,18 +687,30 @@ fun ModalData.GroupChatInfoLayout( } } item { - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } SectionView { + if (groupInfo.useRelays && (groupInfo.isOwner || activeSortedMembers.any { it.memberRole == GroupMemberRole.Relay })) { + ChannelRelaysButton(chat.remoteHostId, groupInfo, showMemberInfo) + } ClearChatButton(clearChat) if (groupInfo.canDelete) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat + val titleId = if (groupInfo.useRelays) MR.strings.button_delete_channel + else if (groupInfo.businessChat == null) MR.strings.button_delete_group + else MR.strings.button_delete_chat DeleteGroupButton(titleId, deleteGroup) } if (groupInfo.membership.memberCurrentOrPending) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat - LeaveGroupButton(titleId, leaveGroup) + val hasOtherOwner = activeSortedMembers.any { + it.memberRole == GroupMemberRole.Owner && it.groupMemberId != groupInfo.membership.groupMemberId + } + if (!groupInfo.useRelays || !groupInfo.isOwner || hasOtherOwner) { + val titleId = if (groupInfo.useRelays) MR.strings.button_leave_channel + else if (groupInfo.businessChat == null) MR.strings.button_leave_group + else MR.strings.button_leave_chat + LeaveGroupButton(titleId, leaveGroup) + } } } @@ -1016,10 +1093,72 @@ private fun CreateGroupLinkButton(onClick: () -> Unit) { } @Composable -fun EditGroupProfileButton(onClick: () -> Unit) { +private fun ChannelLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_link), + stringResource(MR.strings.channel_link), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelLinkQRCodeSection(groupLink: String) { + val clipboard = LocalClipboardManager.current + SimpleXLinkQRCode(connReq = groupLink) + SectionItemView({ + clipboard.shareText(simplexChatLink(groupLink)) + }) { + Icon(painterResource(MR.images.ic_share), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.share_link), color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + val title = if (groupInfo.isOwner) { + stringResource(MR.strings.channel_members_title_owners_and_subscribers) + } else { + stringResource(MR.strings.channel_members_section_owners) + } + SettingsActionItem( + painterResource(MR.images.ic_group), + title, + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelMembersView(rhId, groupInfo, chatModel, close) { member -> showMemberInfo(member, null) } + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelRelaysButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + stringResource(MR.strings.button_channel_relays), + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelRelaysView(rhId, groupInfo, chatModel, close, showMemberInfo) + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +fun EditGroupProfileButton(titleId: StringResource = MR.strings.button_edit_group_profile, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_edit), - stringResource(MR.strings.button_edit_group_profile), + stringResource(titleId), onClick, iconColor = MaterialTheme.colors.secondary ) @@ -1147,7 +1286,7 @@ fun PreviewGroupChatInfoLayout() { appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, - showMemberInfo = {}, + showMemberInfo = { _, _ -> }, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openMemberSupport = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 5a94e7d505..c9745359b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -32,6 +32,7 @@ fun GroupLinkView( groupLink: GroupLink?, onGroupLinkUpdated: ((GroupLink?) -> Unit)?, creatingGroup: Boolean = false, + isChannel: Boolean = false, close: (() -> Unit)? = null ) { var groupLinkVar by rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(groupLink) } @@ -122,6 +123,7 @@ fun GroupLinkView( groupInfo, groupLinkMemberRole, creatingLink, + isChannel = isChannel, createLink = ::createLink, showAddShortLinkAlert = ::showAddShortLinkAlert, updateLink = { @@ -168,6 +170,7 @@ fun GroupLinkLayout( groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, + isChannel: Boolean = false, createLink: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, updateLink: () -> Unit, @@ -185,9 +188,9 @@ fun GroupLinkLayout( } ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.group_link)) + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_link else MR.strings.group_link)) Text( - stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), + stringResource(if (isChannel) MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect else MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp), lineHeight = 22.sp ) @@ -208,7 +211,9 @@ fun GroupLinkLayout( } } } else { - RoleSelectionRow(groupInfo, groupLinkMemberRole) + if (!isChannel) { + RoleSelectionRow(groupInfo, groupLinkMemberRole) + } var initialLaunch by remember { mutableStateOf(true) } LaunchedEffect(groupLinkMemberRole.value) { if (!initialLaunch) { @@ -218,12 +223,12 @@ fun GroupLinkLayout( } val showShortLink = remember { mutableStateOf(true) } Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - if (groupLink.connLinkContact.connShortLink == null) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = false) - } else { - SectionViewWithButton(titleButton = { ToggleShortLinkButton(showShortLink) }) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) - } + SectionViewWithButton( + titleButton = + if (!isChannel && groupLink.connLinkContact.connShortLink != null) { + { ToggleShortLinkButton(showShortLink) } + } else null) { + SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) } Row( horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -235,7 +240,7 @@ fun GroupLinkLayout( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), click = { - if (groupLink.shouldBeUpgraded) { + if (!isChannel && groupLink.shouldBeUpgraded) { showAddShortLinkAlert { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } @@ -246,7 +251,7 @@ fun GroupLinkLayout( ) if (creatingGroup && close != null) { ContinueButton(close) - } else { + } else if (!isChannel) { SimpleButton( stringResource(MR.strings.delete_link), icon = painterResource(MR.images.ic_delete), @@ -255,7 +260,7 @@ fun GroupLinkLayout( ) } } - if (groupLink.shouldBeUpgraded) { + if (!isChannel && groupLink.shouldBeUpgraded) { AddShortLinkButton(text = stringResource(MR.strings.upgrade_group_link)) { showAddShortLinkAlert(null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 8902a0fd9e..fc5d697f4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -50,6 +50,7 @@ fun GroupMemberInfoView( connectionCode: String?, chatModel: ChatModel, openedFromSupportChat: Boolean, + groupRelay: GroupRelay? = null, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView ) { @@ -90,6 +91,7 @@ fun GroupMemberInfoView( newRole, developerTools, connectionCode, + groupRelay = groupRelay, getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { contactId -> scope.launch { @@ -311,6 +313,7 @@ fun GroupMemberInfoLayout( newRole: MutableState, developerTools: Boolean, connectionCode: String?, + groupRelay: GroupRelay? = null, getContactChat: (Long) -> Chat?, openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, @@ -365,7 +368,7 @@ fun GroupMemberInfoLayout( @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) - val canRemove = member.canBeRemoved(groupInfo) + val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay if (canBlockForAll || canRemove) { SectionDividerSpaced(maxBottomPadding = false) SectionView { @@ -380,7 +383,7 @@ fun GroupMemberInfoLayout( if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) { DeleteMemberMessagesButton(deleteMemberMessages) } else { - RemoveMemberButton(removeMember) + RemoveMemberButton(groupInfo.useRelays, removeMember) } } } @@ -417,77 +420,80 @@ fun GroupMemberInfoLayout( val contactId = member.memberContactId - Box( - Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Row( - Modifier - .widthIn(max = 320.dp) - .padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + if (!groupInfo.useRelays) { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - val knownChat = if (contactId != null) knownDirectChat(contactId) else null - if (knownChat != null) { - val (chat, contact) = knownChat - val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } + Row( + Modifier + .widthIn(max = 320.dp) + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val knownChat = if (contactId != null) knownDirectChat(contactId) else null + if (knownChat != null) { + val (chat, contact) = knownChat + val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } - LaunchedEffect(contact.contactId) { - withBGApi { - val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) - if (contactInfo != null) { - knownContactConnectionStats.value = contactInfo.first + LaunchedEffect(contact.contactId) { + withBGApi { + val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) + if (contactInfo != null) { + knownContactConnectionStats.value = contactInfo.first + } } } - } - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) - AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) - VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) - } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { - if (contactId != null) { - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group - } else { - OpenChatButton( - modifier = Modifier.fillMaxWidth(0.33f), - disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), - onClick = { createMemberContact() } - ) + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) + AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) + VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { + if (contactId != null) { + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group + } else { + OpenChatButton( + modifier = Modifier.fillMaxWidth(0.33f), + disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), + onClick = { createMemberContact() } + ) + } + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + } else { // no known contact chat && directMessages are off + val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) } - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - } else { // no known contact chat && directMessages are off - val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) } } - } - SectionSpacer() + SectionSpacer() + } if (member.memberActive) { SectionView { if ( !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + member.memberRole != GroupMemberRole.Relay && (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) ) { SupportChatButton() } - if (connectionCode != null) { + if (connectionCode != null && !(groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay)) { VerifyCodeButton(member.verified, verifyClicked) } if (cStats != null && cStats.ratchetSyncAllowed) { @@ -517,15 +523,46 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() } - SectionView(title = stringResource(MR.strings.member_info_section_title_member)) { - val titleId = if (groupInfo.businessChat == null) MR.strings.info_row_group else MR.strings.info_row_chat + val memberSectionTitle = if (groupInfo.useRelays) { + when (member.memberRole) { + GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay) + GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner) + else -> stringResource(MR.strings.member_info_section_title_subscriber) + } + } else { + stringResource(MR.strings.member_info_section_title_member) + } + SectionView(title = memberSectionTitle) { + val titleId = if (groupInfo.useRelays) MR.strings.info_row_channel + else if (groupInfo.businessChat == null) MR.strings.info_row_group + else MR.strings.info_row_chat InfoRow(stringResource(titleId), groupInfo.displayName) - val roles = remember { member.canChangeRoleTo(groupInfo) } - if (roles != null) { - RoleSelectionRow(roles, newRole, onRoleSelected) + if (!groupInfo.useRelays) { + val roles = remember { member.canChangeRoleTo(groupInfo) } + if (roles != null) { + RoleSelectionRow(roles, newRole, onRoleSelected) + } else { + InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) + } } else { InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) } + val relayLink = member.relayLink + if (relayLink != null) { + InfoRow(stringResource(MR.strings.info_row_relay_link), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayLink))) + } + val relayAddress = groupRelay?.userChatRelay?.address + if (relayAddress != null) { + InfoRow(stringResource(MR.strings.info_row_relay_address), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayAddress))) + val clipboard = LocalClipboardManager.current + ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) } + } + } + if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) { + SectionTextFooter( + if (groupInfo.isOwner) stringResource(MR.strings.relay_section_footer_owner) + else stringResource(MR.strings.relay_section_footer_subscriber) + ) } if (cStats != null) { SectionDividerSpaced() @@ -565,14 +602,19 @@ fun GroupMemberInfoLayout( val connFailedErr = member.activeConn?.connFailedErr if (connFailedErr != null) { SectionDividerSpaced() - SectionView { - InfoRow(stringResource(MR.strings.info_row_connection_failed), connFailedErr) + SectionView(title = stringResource(MR.strings.info_row_connection_failed), icon = painterResource(MR.images.ic_warning), iconTint = Color.Red, leadingIcon = true) { + SectionItemView { + Text( + connFailedErr, + color = MaterialTheme.colors.secondary + ) + } } } if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModeratorDestructiveSection() - } else { + } else if (!groupInfo.useRelays) { NonAdminBlockSection() } @@ -588,18 +630,20 @@ fun GroupMemberInfoLayout( else String.format(generalGetString(MR.strings.conn_level_desc_indirect), conn.connLevel) InfoRow(stringResource(MR.strings.info_row_connection), connLevelDesc) } - SectionItemView({ - withBGApi { - val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) - if (info != null) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.message_queue_info), - text = queueInfoText(info) - ) + if (!groupInfo.useRelays || member.memberRole == GroupMemberRole.Relay) { + SectionItemView({ + withBGApi { + val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) + if (info != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.message_queue_info), + text = queueInfoText(info) + ) + } } + }) { + Text(stringResource(MR.strings.info_row_debug_delivery)) } - }) { - Text(stringResource(MR.strings.info_row_debug_delivery)) } } } @@ -703,10 +747,11 @@ fun UnblockForAllButton(onClick: () -> Unit) { } @Composable -fun RemoveMemberButton(onClick: () -> Unit) { +fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) { + val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member SettingsActionItem( painterResource(MR.images.ic_delete), - stringResource(MR.strings.button_remove_member), + stringResource(label), click = onClick, textColor = Color.Red, iconColor = Color.Red, @@ -724,6 +769,17 @@ fun DeleteMemberMessagesButton(onClick: () -> Unit) { ) } +@Composable +fun ShareRelayAddressButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_share_filled), + stringResource(MR.strings.share_relay_address), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable fun OpenChatButton( modifier: Modifier, @@ -908,8 +964,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem } fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.block_subscriber_for_all_question else MR.strings.block_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.block_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { @@ -932,8 +989,9 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuc } fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.unblock_subscriber_for_all_question else MR.strings.unblock_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.unblock_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 293a93b15a..0cec9ab773 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -316,7 +316,7 @@ fun GroupMenuItems( } } GroupMemberStatus.MemAccepted -> { - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -338,7 +338,7 @@ fun GroupMenuItems( } } ClearChatAction(chat, showMenu) - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt new file mode 100644 index 0000000000..c5085586ba --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -0,0 +1,581 @@ +package chat.simplex.common.views.newchat + +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.res.MR +import java.net.URI +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +@Composable +fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit) { + val view = LocalMultiplatformView() + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val displayName = rememberSaveable { mutableStateOf("") } + val chosenImage = rememberSaveable { mutableStateOf(null) } + val profileImage = rememberSaveable { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val hasRelays = rememberSaveable { mutableStateOf(true) } + val groupInfo = remember { mutableStateOf(null) } + val groupLink = rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(null) } + val groupRelays = remember { mutableStateOf>(emptyList()) } + val creationInProgress = rememberSaveable { mutableStateOf(false) } + val showLinkStep = rememberSaveable { mutableStateOf(false) } + val relayListExpanded = rememberSaveable { mutableStateOf(false) } + + val gInfo = groupInfo.value + if (showLinkStep.value && gInfo != null) { + LinkStepView(chatModel, gInfo, groupLink, closeAll) + } else if (gInfo != null) { + ProgressStepView( + chatModel, gInfo, groupRelays, relayListExpanded, + onLinkReady = if (appPlatform.isDesktop) { + { + chatModel.creatingChannelId.value = null + closeAll() + withBGApi { + openGroupChat(null, gInfo.groupId) + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, close = close) + } + } + } + } else { + { showLinkStep.value = true } + }, + cancelChannelCreation = { + chatModel.creatingChannelId.value = null + ChannelRelaysModel.reset() + closeAll() + withBGApi { + try { + chatModel.controller.apiDeleteChat(rh = null, type = ChatType.Group, id = gInfo.apiId) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(null, gInfo.id) + } + } catch (e: Exception) { + Log.e(TAG, "cancelChannelCreation error: ${e.message}") + } + } + } + ) + } else { + ProfileStepView( + chatModel = chatModel, + displayName = displayName, + profileImage = profileImage, + chosenImage = chosenImage, + focusRequester = focusRequester, + hasRelays = hasRelays, + creationInProgress = creationInProgress, + bottomSheetModalState = bottomSheetModalState, + scope = scope, + view = view, + close = close, + createChannel = { + hideKeyboard(view) + val trimmedName = displayName.value.trim() + displayName.value = trimmedName + val profile = GroupProfile( + displayName = trimmedName, + fullName = "", + shortDescr = null, + image = profileImage.value, + groupPreferences = GroupPreferences(history = GroupPreference(GroupFeatureEnabled.ON)) + ) + creationInProgress.value = true + withBGApi { + try { + val enabledRelays = getEnabledRelays() + val relayIds = enabledRelays.mapNotNull { it.chatRelayId } + if (relayIds.isEmpty()) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + hasRelays.value = false + } + return@withBGApi + } + val result = chatModel.controller.apiNewPublicGroup( + rh = null, + incognito = false, + relayIds = relayIds, + groupProfile = profile + ) + if (result != null) { + val (gI, gL, gR) = result + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId = null, gI) + chatModel.creatingChannelId.value = gI.id + groupInfo.value = gI + groupLink.value = gL + groupRelays.value = gR.sortedBy { relayDisplayName(it) } + ChannelRelaysModel.set(gI.groupId, gR) + creationInProgress.value = false + } + } else { + withContext(Dispatchers.Main) { creationInProgress.value = false } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_creating_channel), + text = e.message + ) + } + } + } + } + ) + } +} + +private suspend fun getEnabledRelays(): List { + val servers = getUserServers(rh = null) ?: return emptyList() + val all = servers.flatMap { op -> + op.chatRelays.filter { it.enabled && !it.deleted && it.chatRelayId != null } + } + return all.shuffled().take(3) +} + +private suspend fun checkHasRelays(): Boolean { + val servers = try { getUserServers(rh = null) } catch (_: Exception) { null } ?: return false + return servers.any { op -> + op.chatRelays.any { it.enabled && !it.deleted && it.chatRelayId != null } + } +} + +@Composable +private fun ProfileStepView( + chatModel: ChatModel, + displayName: MutableState, + profileImage: MutableState, + chosenImage: MutableState, + focusRequester: FocusRequester, + hasRelays: MutableState, + creationInProgress: MutableState, + bottomSheetModalState: ModalBottomSheetState, + scope: CoroutineScope, + view: Any?, + close: () -> Unit, + createChannel: () -> Unit +) { + LaunchedEffect(Unit) { + hasRelays.value = checkHasRelays() + } + + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.imePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + } + ) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + ModalView(close = close) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.create_channel_title)) + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(108.dp, image = profileImage.value) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + Row( + Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + generalGetString(MR.strings.channel_display_name_field), + fontSize = 16.sp + ) + if (!isValidDisplayName(displayName.value.trim())) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) + } + Spacer(Modifier.height(8.dp)) + + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + generalGetString(MR.strings.configure_relays), + click = { + ModalManager.start.showCustomModal { close -> + NetworkAndServersView(close) + } + }, + textColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange, + iconColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange + ) + + val canCreate = canCreateProfile(displayName.value) && hasRelays.value && !creationInProgress.value + SettingsActionItem( + painterResource(MR.images.ic_check), + generalGetString(MR.strings.create_channel_button), + click = createChannel, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreate + ) + + SectionTextFooter( + if (!hasRelays.value) { + generalGetString(MR.strings.enable_at_least_one_chat_relay) + } else { + val name = chatModel.currentUser.value?.displayName ?: "" + String.format(generalGetString(MR.strings.your_profile_shared_with_channel_relays), name) + } + ) + + LaunchedEffect(Unit) { + delay(1000) + focusRequester.requestFocus() + } + } + } + } +} + +@Composable +private fun ProgressStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupRelays: MutableState>, + relayListExpanded: MutableState, + onLinkReady: () -> Unit, + cancelChannelCreation: () -> Unit +) { + val failedCount = groupRelays.value.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + val total = groupRelays.value.size + + if (appPlatform.isDesktop) { + DisposableEffect(Unit) { + chatModel.centerPanelBackgroundClickHandler = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.cancel_creating_channel_question), + confirmText = generalGetString(MR.strings.cancel_creating_channel_confirm), + onConfirm = cancelChannelCreation, + dismissText = generalGetString(MR.strings.wait_verb), + destructive = true, + ) + true + } + onDispose { + chatModel.centerPanelBackgroundClickHandler = null + } + } + } + + LaunchedEffect(gInfo.groupId) { + snapshotFlow { ChannelRelaysModel.groupRelays.toList() } + .collect { relays -> + if (ChannelRelaysModel.groupId.value != gInfo.groupId) return@collect + groupRelays.value = relays.sortedBy { relayDisplayName(it) } + if (relays.all { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }) { + onLinkReady() + ChannelRelaysModel.reset() + } + } + } + + ModalView( + close = cancelChannelCreation, + showClose = false, + endButtons = { + TextButton(onClick = cancelChannelCreation) { + Text(generalGetString(MR.strings.cancel_verb)) + } + } + ) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.creating_channel)) + + Box( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + ProfileImage(108.dp, image = gInfo.groupProfile.image) + } + Text( + gInfo.groupProfile.displayName, + style = MaterialTheme.typography.h6, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + textAlign = TextAlign.Center + ) + + SectionView { + SectionItemView(click = { relayListExpanded.value = !relayListExpanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f)) + Icon( + painterResource(if (relayListExpanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (relayListExpanded.value) { + groupRelays.value.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + if (failedErr != null) { + SectionItemView( + click = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + }, + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = true) + } + } else { + SectionItemView( + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = false) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + SectionView { + val enabled = activeCount > 0 + SettingsActionItem( + painterResource(MR.images.ic_link), + generalGetString(MR.strings.channel_link), + click = { + if (activeCount >= total) { + onLinkReady() + } else if (activeCount > 0) { + val alertText = String.format( + generalGetString(MR.strings.channel_will_start_with_relays), + activeCount, total + ) + if (activeCount + failedCount < total) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + buttons = { + Row(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.wait_verb)) + } + TextButton(onClick = { + AlertManager.shared.hideAlert() + onLinkReady() + }) { + Text(generalGetString(MR.strings.proceed_verb)) + } + } + } + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + confirmText = generalGetString(MR.strings.proceed_verb), + onConfirm = { onLinkReady() } + ) + } + } + }, + textColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + disabled = !enabled + ) + } + } + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + +@Composable +private fun RelayRow(relay: GroupRelay, connFailed: Boolean) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(relayDisplayName(relay)) + RelayStatusIndicator(relay.relayStatus, connFailed = connFailed) + } +} + +@Composable +private fun LinkStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupLink: MutableState, + closeAll: () -> Unit +) { + val close: () -> Unit = { + chatModel.creatingChannelId.value = null + withBGApi { + delay(500) + withContext(Dispatchers.Main) { + ModalManager.start.closeModals() + openGroupChat(null, gInfo.groupId) + } + } + } + ModalView(close = close, showClose = false) { + GroupLinkView( + chatModel = chatModel, + rhId = null, + groupInfo = gInfo, + groupLink = groupLink.value, + onGroupLinkUpdated = { groupLink.value = it }, + creatingGroup = true, + isChannel = true, + close = close + ) + } +} + +fun relayDisplayName(relay: GroupRelay): String { + if (relay.userChatRelay.name.isNotEmpty()) return relay.userChatRelay.name + relay.userChatRelay.domains.firstOrNull()?.let { return it } + relay.relayLink?.let { return hostFromRelayLink(it) } + return "relay ${relay.groupRelayId}" +} + + +@Composable +fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false) { + val color = if (connFailed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow + val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else status.text + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Canvas(Modifier.size(8.dp)) { + drawCircle(color = color) + } + Text( + text, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + if (connFailed) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +fun RelayProgressIndicator(active: Int, total: Int) { + if (active == 0) { + CircularProgressIndicator( + Modifier.size(20.dp), + strokeWidth = 2.5.dp + ) + } else { + val progress = active.toFloat() / total.coerceAtLeast(1).toFloat() + Box(Modifier.size(20.dp)) { + Canvas(Modifier.fillMaxSize()) { + // Background circle + drawCircle( + color = Color.Gray.copy(alpha = 0.3f), + style = Stroke(width = 2.5.dp.toPx()) + ) + // Progress arc + drawArc( + color = Color(0xFF2196F3), // accent blue + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewAddChannelView() { + SimpleXTheme { + AddChannelView(chatModel = ChatModel, close = {}, closeAll = {}) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index e8084e055a..0494cbb463 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -56,7 +56,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 434cb6ce27..c5e5517cfc 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 @@ -28,6 +28,15 @@ suspend fun planAndConnect( filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { + val link = strHasSingleSimplexLink(shortOrFullLink.trim()) + if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.relay_address_alert_title), + generalGetString(MR.strings.relay_address_alert_message), + ) + cleanup?.invoke() + return CompletableDeferred(false) + } connectProgressManager.cancelConnectProgress() val inProgress = mutableStateOf(true) connectProgressManager.startConnectProgress(generalGetString(MR.strings.loading_profile)) { @@ -203,6 +212,7 @@ private suspend fun planAndConnectTask( showPrepareGroupAlert( rhId, connectionLink, + connectionPlan.groupLinkPlan.groupSLinkInfo_, connectionPlan.groupLinkPlan.groupSLinkData_, close, cleanup @@ -421,49 +431,75 @@ fun ownGroupLinkConfirmConnect( close: (() -> Unit)?, cleanup: (() -> Unit)?, ) { - AlertManager.privacySensitive.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_plan_join_your_group), - text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, - buttons = { - Column { - // Open group - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - openKnownGroup(chatModel, rhId, close, groupInfo) - cleanup?.invoke() - }) { - Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - // Use current profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + if (groupInfo.useRelays) { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName), + buttons = { + Column { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - // Use new incognito profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - // Cancel - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - cleanup?.invoke() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } else { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Use current profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Cancel + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } - } - }, - onDismissRequest = cleanup, - hostDevice = hostDevice(rhId), - ) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } } private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { @@ -478,7 +514,9 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( ) }, confirmText = generalGetString( - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel + } else if (groupInfo.businessChat == null) { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_group else MR.strings.connect_plan_open_group } else { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat @@ -544,21 +582,37 @@ fun showPrepareContactAlert( fun showPrepareGroupAlert( rhId: Long?, connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { + val isChannel = !(groupShortLinkInfo?.direct ?: true) AlertManager.privacySensitive.showOpenChatAlert( profileName = groupShortLinkData.groupProfile.displayName, profileFullName = groupShortLinkData.groupProfile.fullName, - profileImage = { ProfileImage(size = alertProfileImageSize, image = groupShortLinkData.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled) }, - confirmText = generalGetString(MR.strings.connect_plan_open_new_group), + profileImage = { + ProfileImage( + size = alertProfileImageSize, + image = groupShortLinkData.groupProfile.image, + icon = if (isChannel) MR.images.ic_bigtop_updates else MR.images.ic_supervised_user_circle_filled + ) + }, + confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { - val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, groupShortLinkData) + val directLink = groupShortLinkInfo?.direct ?: true + val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData) if (chat != null) { withContext(Dispatchers.Main) { + val relays = groupShortLinkInfo?.groupRelays + if (!relays.isNullOrEmpty()) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Group) { + chatModel.channelRelayHostnames[chatInfo.groupInfo.groupId] = relays + } + } ChatController.chatModel.chatsContext.addChat(chat) openChat_(chatModel, rhId, close, chat) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index ef6e426141..292aa10f70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -63,6 +63,9 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { createGroup = { ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) } }, + createChannel = { + ModalManager.start.showCustomModal { close -> AddChannelView(chatModel, close, closeAll) } + }, rh = rh, close = close ) @@ -110,6 +113,7 @@ private fun ModalData.NewChatSheetLayout( addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit, + createChannel: () -> Unit, close: () -> Unit, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -193,6 +197,11 @@ private fun ModalData.NewChatSheetLayout( painterResource(MR.images.ic_group), stringResource(MR.strings.create_group_button), createGroup, + ), + Triple( + painterResource(MR.images.ic_bigtop_updates), + stringResource(MR.strings.create_channel_beta_button), + createChannel, ) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt new file mode 100644 index 0000000000..4611f62991 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -0,0 +1,346 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewSpaceBetween +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.res.MR +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +fun ShowRelayTestStatus(relay: UserChatRelay, modifier: Modifier = Modifier) = + when (relay.tested) { + true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) + false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) + else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent) + } + +fun validRelayName(name: String): Boolean = + name.isNotEmpty() && isValidDisplayName(name) + +fun showInvalidRelayNameAlert(name: MutableState) { + val validName = mkValidName(name.value) + if (validName.isEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_name) + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.invalid_name), + text = String.format(generalGetString(MR.strings.correct_name_to), validName), + onConfirm = { + name.value = validName + } + ) + } +} + +fun validRelayAddress(address: String): Boolean { + val parsedMd = parseToMarkdown(address) + return parsedMd != null && + parsedMd.size == 1 && + parsedMd.first().format is Format.SimplexLink && + (parsedMd.first().format as Format.SimplexLink).linkType == SimplexLinkType.relay +} + +// TODO [relays] TBC matching relay to operator by domain (relay address can be hosted on operator server) +fun addChatRelay( + relay: UserChatRelay, + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>?, + rhId: Long?, + close: () -> Unit +) { + val nameEmpty = relay.name.trim().isEmpty() + val addressEmpty = relay.address.trim().isEmpty() + if (nameEmpty && addressEmpty) { + close() + } else if (!validRelayName(relay.name)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else if (!validRelayAddress(relay.address)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } else { + val i = userServers.value.indexOfFirst { it.operator == null } + if (i != -1) { + val updatedUserServers = userServers.value.toMutableList() + val operatorServers = updatedUserServers[i] + updatedUserServers[i] = operatorServers.copy( + chatRelays = operatorServers.chatRelays + relay + ) + userServers.value = updatedUserServers + withBGApi { + validateServers_(rhId, userServers.value, serverErrors, serverWarnings) + } + close() + } else { // Shouldn't happen + close() + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_relay)) + } + } +} + +@Composable +fun ChatRelayView( + relay: UserChatRelay, + onDelete: () -> Unit, + onUpdate: (UserChatRelay) -> Unit, + close: () -> Unit +) { + val relayToEdit = remember { mutableStateOf(relay) } + + ModalView( + close = { + val validName = validRelayName(relayToEdit.value.name) + val validAddress = validRelayAddress(relayToEdit.value.address) + if (validName && validAddress) { + onUpdate(relayToEdit.value) + close() + } else if (!validName) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } + } + ) { + ChatRelayLayout( + relayToEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun ChatRelayLayout( + relay: MutableState, + onDelete: (() -> Unit)? +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.chat_relay)) + if (relay.value.preset) { + PresetRelay(relay) + } else { + CustomRelay(relay, onDelete) + } + SectionBottomSpacer() + } +} + +@Composable +private fun PresetRelay(relay: MutableState) { + SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionItemView { + Text(relay.value.name) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { + SelectionContainer { + Text( + relay.value.address, + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + color = MaterialTheme.colors.secondary + ) + } + } + SectionDividerSpaced() + UseRelaySection(relay) +} + +@Composable +private fun CustomRelay( + relay: MutableState, + onDelete: (() -> Unit)? +) { + val relayName = remember { mutableStateOf(relay.value.name) } + val relayAddress = remember { mutableStateOf(relay.value.address) } + val validName = remember { derivedStateOf { validRelayName(relayName.value) } } + val validAddress = remember { derivedStateOf { validRelayAddress(relayAddress.value) } } + + LaunchedEffect(Unit) { + snapshotFlow { relayName.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copy(name = it) } + } + LaunchedEffect(Unit) { + snapshotFlow { relayAddress.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copy(address = it) } + } + + Column { + val iconSize = with(LocalDensity.current) { 21.sp.toDp() } + Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.your_relay_name).uppercase(), + color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp + ) + IconButton( + onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) }, + enabled = !validName.value, + modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize) + ) { + Icon( + painterResource(MR.images.ic_error), null, + tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent + ) + } + } + Column(Modifier.fillMaxWidth()) { + TextEditor( + relayName, + Modifier, + placeholder = generalGetString(MR.strings.enter_relay_name) + ) + } + } + SectionDividerSpaced(maxTopPadding = true) + + SectionView( + stringResource(MR.strings.your_relay_address).uppercase(), + icon = painterResource(MR.images.ic_error), + iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, + ) { + TextEditor( + relayAddress, + Modifier.height(144.dp) + ) + } + SectionDividerSpaced(maxTopPadding = true) + + UseRelaySection(relay, validAddress.value) + + if (onDelete != null) { + SectionDividerSpaced() + SectionView { + SectionItemView(onDelete) { + Text(stringResource(MR.strings.delete_relay), color = MaterialTheme.colors.error) + } + } + } +} + +@Composable +private fun UseRelaySection( + relay: MutableState, + valid: Boolean = true +) { + SectionView(stringResource(MR.strings.use_relay).uppercase()) { + SectionItemViewSpaceBetween( + click = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.not_implemented), + text = generalGetString(MR.strings.relay_testing_not_available) + ) + }, + disabled = !valid + ) { + Text( + stringResource(MR.strings.test_relay), + color = if (valid) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary + ) + ShowRelayTestStatus(relay.value) + } + + val enabled = rememberUpdatedState(relay.value.enabled) + PreferenceToggle( + stringResource(MR.strings.use_for_new_channels), + checked = enabled.value + ) { + relay.value = relay.value.copy(enabled = it) + } + } +} + +@Composable +fun ChatRelayViewLink( + relay: UserChatRelay, + duplicateRelayNames: Set, + duplicateRelayAddresses: Set, + onClick: () -> Unit +) { + SectionItemView(onClick) { + Box(Modifier.width(16.dp)) { + when { + relay.name in duplicateRelayNames || relay.address in duplicateRelayAddresses -> InvalidServer() + !relay.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) + else -> ShowRelayTestStatus(relay) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val displayName = relay.name.ifEmpty { relay.domains.firstOrNull() ?: relay.address } + if (relay.enabled) { + Text(displayName, color = MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(displayName, maxLines = 1, color = MaterialTheme.colors.secondary) + } + } +} + +@Composable +fun ModalData.NewChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val relayToEdit = remember { + mutableStateOf( + UserChatRelay( + chatRelayId = null, address = "", name = "", domains = emptyList(), + preset = false, tested = null, enabled = true, deleted = false + ) + ) + } + + ModalView(close = { + addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) + }) { + NewChatRelayLayout(relayToEdit) + } +} + +@Composable +private fun NewChatRelayLayout(relay: MutableState) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat_relay)) + CustomRelay(relay, onDelete = null) + SectionBottomSpacer() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 26ecf151ff..d50946ec3b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -54,6 +54,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } val userServers = remember { stateGetOrPut("userServers") { emptyList() } } val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + val serverWarnings = remember { stateGetOrPut("serverWarnings") { emptyList() } } val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } fun onClose(close: () -> Unit): Boolean = if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) { @@ -91,6 +92,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, toggleSocksProxy = { enable -> val def = NetCfg.defaults val proxyDef = NetCfg.proxyDefaults @@ -158,6 +160,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { onionHosts: MutableState, currUserServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, userServers: MutableState>, toggleSocksProxy: (Boolean) -> Unit, ) { @@ -209,7 +212,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { userServers.value.forEachIndexed { index, srv -> - srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) } + srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, serverWarnings, currentRemoteHost?.remoteHostId) } } } if (conditionsAction != null && anyOperatorEnabled.value) { @@ -234,6 +237,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { YourServersView( userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = nullOperatorIndex, rhId = currentRemoteHost?.remoteHostId ) @@ -284,6 +288,12 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced() @@ -664,6 +674,7 @@ private fun ServerOperatorRow( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { SectionItemView( @@ -673,6 +684,7 @@ private fun ServerOperatorRow( currUserServers, userServers, serverErrors, + serverWarnings, index, rhId ) @@ -848,6 +860,30 @@ fun ServersErrorFooter(errStr: String) { } } +@Composable +fun ServersWarningFooter(warnStr: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = stringResource(MR.strings.server_warning), + tint = WarningOrange, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + warnStr, + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } +} + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.smp_save_servers_question), @@ -887,11 +923,13 @@ fun updateOperatorsConditionsAcceptance(usvs: MutableState, - serverErrors: MutableState> + serverErrors: MutableState>, + serverWarnings: MutableState>? = null ) { try { - val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + val (errors, warnings) = chatController.validateServers(rhId, userServersToValidate) ?: return serverErrors.value = errors + serverWarnings?.value = warnings } catch (ex: Exception) { Log.e(TAG, ex.stackTraceToString()) } @@ -914,6 +952,15 @@ fun globalServersError(serverErrors: List): String? { return null } +fun globalServersWarning(serverWarnings: List): String? { + for (warn in serverWarnings) { + if (warn.globalWarning != null) { + return warn.globalWarning + } + } + return null +} + fun globalSMPServersError(serverErrors: List): String? { for (err in serverErrors) { if (err.globalSMPError != null) { @@ -943,6 +990,12 @@ fun findDuplicateHosts(serverErrors: List): Set { return duplicateHostsList.toSet() } +fun findDuplicateRelayNames(serverErrors: List): Set = + serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayName)?.duplicateChatRelay }.toSet() + +fun findDuplicateRelayAddresses(serverErrors: List): Set = + serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayAddress)?.duplicateAddress }.toSet() + private suspend fun saveServers( rhId: Long?, currUserServers: MutableState>, @@ -987,7 +1040,8 @@ fun PreviewNetworkAndServersLayout() { toggleSocksProxy = {}, currUserServers = remember { mutableStateOf(emptyList()) }, userServers = remember { mutableStateOf(emptyList()) }, - serverErrors = remember { mutableStateOf(emptyList()) } + serverErrors = remember { mutableStateOf(emptyList()) }, + serverWarnings = remember { mutableStateOf(emptyList()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index 6a999aa89d..a3a843d034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.* fun ModalData.NewServerView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long?, close: () -> Unit ) { @@ -28,6 +29,7 @@ fun ModalData.NewServerView( newServer.value, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -101,6 +103,7 @@ fun addServer( server: UserServer, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>? = null, rhId: Long?, close: () -> Unit ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index c619ae6ebc..48faedfb77 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -47,6 +47,7 @@ fun OperatorView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -57,7 +58,7 @@ fun OperatorView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -68,9 +69,10 @@ fun OperatorView( currUserServers, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -87,6 +89,7 @@ fun OperatorView( fun navigateToProtocolView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, serverIndex: Int, @@ -100,6 +103,7 @@ fun navigateToProtocolView( serverProtocol = protocol, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, onDelete = { if (protocol == ServerProtocol.SMP) { deleteSMPServer(userServers, operatorIndex, serverIndex) @@ -130,11 +134,42 @@ fun navigateToProtocolView( } } +fun navigateToChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + operatorIndex: Int, + relayIndex: Int, + relay: UserChatRelay, + rhId: Long? +) { + ModalManager.start.showCustomModal { close -> + ChatRelayView( + relay = relay, + onDelete = { + deleteChatRelay(userServers, operatorIndex, relayIndex) + close() + }, + onUpdate = { updatedRelay -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + chatRelays = this[operatorIndex].chatRelays.toMutableList().apply { + this[relayIndex] = updatedRelay + } + ) + } + }, + close = close + ) + } +} + @Composable fun OperatorViewLayout( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -170,15 +205,21 @@ fun OperatorViewLayout( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId ) } val serversErr = globalServersError(serverErrors.value) + val serversWarn = globalServersWarning(serverWarnings.value) if (serversErr != null) { SectionCustomFooter { ServersErrorFooter(serversErr) } + } else if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } } else { val footerText = when (val c = operator.conditionsAcceptance) { is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) { @@ -194,6 +235,22 @@ fun OperatorViewLayout( } if (operator.enabled) { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayNames = findDuplicateRelayNames(serverErrors.value) + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { index, relay -> + if (!relay.deleted) { + ChatRelayViewLink(relay, duplicateRelayNames, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, index, relay, rhId) + } + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { @@ -458,6 +515,7 @@ private fun UseOperatorToggle( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -485,6 +543,7 @@ private fun UseOperatorToggle( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId, close = close @@ -510,6 +569,7 @@ private fun SingleOperatorUsageConditionsView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, close: () -> Unit diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index ccad962313..01630a2b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -36,6 +36,7 @@ fun ProtocolServerView( serverProtocol: ServerProtocol, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, onDelete: () -> Unit, onUpdate: (UserServer) -> Unit, close: () -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63bf8b1dc4..3776ccf2d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch fun ModalData.YourServersView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -40,7 +41,7 @@ fun ModalData.YourServersView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -51,9 +52,10 @@ fun ModalData.YourServersView( scope, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -72,6 +74,7 @@ fun YourServersViewLayout( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -81,7 +84,22 @@ fun YourServersViewLayout( val duplicateHosts = findDuplicateHosts(serverErrors.value) Column { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayNames = findDuplicateRelayNames(serverErrors.value) + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { i, relay -> + if (relay.deleted) return@forEachIndexed + ChatRelayViewLink(relay, duplicateRelayNames, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, i, relay, rhId) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionDividerSpaced() SectionView(generalGetString(MR.strings.message_servers).uppercase()) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed @@ -150,7 +168,8 @@ fun YourServersViewLayout( if ( userServers.value[operatorIndex].smpServers.any { !it.deleted } || - userServers.value[operatorIndex].xftpServers.any { !it.deleted } + userServers.value[operatorIndex].xftpServers.any { !it.deleted } || + userServers.value[operatorIndex].chatRelays.any { !it.deleted } ) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) } @@ -159,7 +178,7 @@ fun YourServersViewLayout( SettingsActionItem( painterResource(MR.images.ic_add), stringResource(MR.strings.smp_servers_add), - click = { showAddServerDialog(scope, userServers, serverErrors, rhId) }, + click = { showAddServerDialog(scope, userServers, serverErrors, serverWarnings, rhId) }, disabled = testing.value, textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -171,6 +190,12 @@ fun YourServersViewLayout( ServersErrorFooter(serversErr) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) SectionView { @@ -226,6 +251,7 @@ fun showAddServerDialog( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { AlertManager.shared.showAlertDialogButtonsColumn( @@ -235,7 +261,7 @@ fun showAddServerDialog( SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showCustomModal { close -> - NewServerView(userServers, serverErrors, rhId, close) + NewServerView(userServers, serverErrors, serverWarnings, rhId, close) } }) { Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) @@ -250,6 +276,7 @@ fun showAddServerDialog( server, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -260,6 +287,14 @@ fun showAddServerDialog( Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showCustomModal { close -> + NewChatRelayView(userServers, serverErrors, serverWarnings, rhId, close) + } + }) { + Text(stringResource(MR.strings.chat_relay), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } } ) @@ -405,3 +440,28 @@ fun deleteSMPServer( } } } + +fun deleteChatRelay( + userServers: MutableState>, + operatorServersIndex: Int, + relayIndex: Int +) { + val relay = userServers.value[operatorServersIndex].chatRelays[relayIndex] + if (relay.chatRelayId == null) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this.removeAt(relayIndex) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this[relayIndex] = this[relayIndex].copy(deleted = true) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 8b1eb44249..cf8e9431f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -100,7 +100,7 @@ SimpleX one-time invitation SimpleX group link SimpleX channel link - SimpleX relay link + SimpleX relay address via %1$s SimpleX links Description @@ -141,6 +141,8 @@ No servers to receive files. For chat profile %s: Errors in servers configuration. + No chat relays enabled. + Server warning Error accepting conditions Spam Content violates conditions of use @@ -514,8 +516,11 @@ Your contact Bot Tap Join group + Tap Join channel Your group + Your channel Group + Channel Business connection Your business contact @@ -561,6 +566,8 @@ Report sent to moderators You can view your reports in Chat with admins. Join group + Join channel + Broadcast Add message Connect Send contact request? @@ -575,7 +582,9 @@ not synchronized contact disabled you are observer + you are subscriber Please contact group admin. + channel request to join rejected group is deleted removed from group @@ -1655,8 +1664,10 @@ You joined this group. Connecting to inviting group member. Leave Leave group? + Leave channel? Leave chat? You will stop receiving messages from this group. Chat history will be preserved. + You will stop receiving messages from this channel. Chat history will be preserved. You will stop receiving messages from this chat. Chat history will be preserved. Invite members Group inactive @@ -1755,6 +1766,7 @@ moderator admin owner + relay rejected @@ -1803,24 +1815,32 @@ %1$s MEMBERS you: %1$s Delete group + Delete channel Delete chat Delete group? + Delete channel? Delete chat? Group will be deleted for all members - this cannot be undone! + Channel will be deleted for all subscribers - this cannot be undone! Chat will be deleted for all members - this cannot be undone! Group will be deleted for you - this cannot be undone! + Channel will be deleted for you - this cannot be undone! Chat will be deleted for you - this cannot be undone! Leave group + Leave channel Leave chat Edit group profile + Edit channel profile Add welcome message Welcome message Group link + Channel link Create group link Create link Delete link? Delete link You can share a link or a QR code - anybody will be able to join the group. You won\'t lose members of the group if you later delete it. + You can share a link or a QR code - anybody will be able to join the channel. All group members will remain connected. Error creating group link Error updating group link @@ -1837,7 +1857,10 @@ Receipts are disabled This group has over %1$d members, delivery receipts are not sent. Invite + Link Chat with admins + Channel members + Chat relays FOR CONSOLE @@ -1872,6 +1895,7 @@ Remove member? + Remove subscriber? Remove members? Delete member messages? Remove member @@ -1879,6 +1903,7 @@ Chat with member Send direct message Member will be removed from group - this cannot be undone! + Subscriber will be removed from channel - this cannot be undone! Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! Members will be removed from chat - this cannot be undone! @@ -1925,7 +1950,7 @@ Group Chat Connection - Connection failed + CONNECTION FAILED direct indirect (%1$s) Message queue info @@ -2794,4 +2819,99 @@ You can mention up to %1$s members per message! + + + Owners & subscribers + Owners + %1$d subscribers + No subscribers + + + Chat relay + New chat relay + Preset relay name + Preset relay address + Your relay name + Your relay address + Enter relay name… + Use relay + Test relay + Use for new channels + Delete relay + Not implemented + Relay testing is not yet available. + Invalid relay name! + Check relay name and try again. + Invalid relay address! + Check relay address and try again. + Error adding relay + + + Chat relays + Chat relays forward messages in channels you create. + + + Chat relays + No chat relays + Chat relays forward messages to channel subscribers. + connected + connecting + deleted + failed + new + invited + accepted + active + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relays active + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d relays connected + %1$d relays + + + RELAY + OWNER + SUBSCRIBER + Channel + Relay link + Relay address + via %1$s + Share relay address + Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel. + You connected to the channel via this relay link. + Remove subscriber + Block subscriber for all? + + + Create channel + Create channel + Create channel (BETA) + Channel name + Creating channel + Error creating channel + Cancel creating channel? + Cancel + Enable at least one chat relay to create a channel. + Your profile %1$s will be shared with channel relays and subscribers. + Configure relays + failed + Relay connection failed + Not all relays connected + Wait + Proceed + Channel will start working with %1$d of %2$d relays. Proceed? + + + Relay address + This is a chat relay address, it cannot be used to connect. + Open channel + Open new channel + Your channel + %1$s!]]> + Error opening channel + + + Unblock subscriber for all? \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg new file mode 100644 index 0000000000..fc1e09a3cb --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg @@ -0,0 +1 @@ + \ No newline at end of file