desktop, android: channels and chat relays ui (#6670)

This commit is contained in:
spaced4ndy
2026-03-13 11:59:29 +00:00
committed by GitHub
parent 2ada82b589
commit 4e16792ddc
28 changed files with 2585 additions and 269 deletions
-4
View File
@@ -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
}
}
@@ -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()
@@ -214,6 +214,8 @@ struct ChatRelayViewLink: View {
@Binding var serverErrors: [UserServersError]
@Binding var serverWarnings: [UserServersWarning]
@Binding var relay: UserChatRelay
var duplicateRelayNames: Set<String>
var duplicateRelayAddresses: Set<String>
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)
@@ -483,6 +483,21 @@ func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set<String> {
return Set(duplicateHostsList)
}
func findDuplicateRelayNames(_ serverErrors: [UserServersError]) -> Set<String> {
Set(serverErrors.compactMap { err in
if case let .duplicateChatRelayName(duplicateChatRelay) = err { return duplicateChatRelay }
else { return nil }
})
}
func findDuplicateRelayAddresses(_ serverErrors: [UserServersError]) -> Set<String> {
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 {
@@ -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
)
@@ -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
)
@@ -78,6 +78,22 @@ object ConnectProgressManager {
val connectProgressManager = ConnectProgressManager
object ChannelRelaysModel {
val groupId = mutableStateOf<Long?>(null)
val groupRelays = mutableStateListOf<GroupRelay>()
fun set(groupId: Long, groupRelays: List<GroupRelay>) {
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<List<Chat>> = chatsContext.chats
// rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val creatingChannelId = mutableStateOf<String?>(null)
val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(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<Long, List<String>>()
// Chat Tags
val userTags = mutableStateOf(emptyList<ChatTag>())
@@ -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<String>,
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<GroupMemberRole>? =
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
@@ -1071,8 +1071,8 @@ object ChatController {
suspend fun apiReorderChatTags(rh: Long?, tagIds: List<Long>) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds))
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
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<ComposedMessage>): List<AChatItem>? {
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<Long>, ttl: Int?): List<ChatItem>? {
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<Long>, ttl: Int?): List<ChatItem>? {
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<UserOperatorServers>): List<UserServersError>? {
suspend fun validateServers(rh: Long?, userServers: List<UserOperatorServers>): Pair<List<UserServersError>, List<UserServersWarning>>? {
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<GroupInfo, List<RelayConnectionResult>>? {
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<Long>, groupProfile: GroupProfile): Triple<GroupInfo, GroupLink, List<GroupRelay>>? {
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<GroupRelay> {
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<ComposedMessage>): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiCreateChatTag(val tag: ChatTagData): CC()
class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List<Long>): 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<Long>): CC()
class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List<Long>, 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<Long>, 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<Long>, 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<UserServer>,
val xftpServers: List<UserServer>
val xftpServers: List<UserServer>,
val chatRelays: List<UserChatRelay> = 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<String>,
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<UserOperatorServers>): CR()
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>): CR()
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>, val serverWarnings: List<UserServersWarning> = 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<RelayConnectionResult> = 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<Long>, 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<GroupRelay>): CR()
@Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List<GroupRelay>): 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<GroupRelay>): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR()
@Serializable @SerialName("groupLink") class CRGroupLink(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR()
@Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR()
@@ -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()
@@ -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<Chat?>, 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/*(
@@ -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<GroupRelay>,
activeCount: Int,
failedCount: Int,
relayListExpanded: MutableState<Boolean>
) {
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<String>,
relayMembers: List<GroupMember>,
connectedCount: Int,
errorCount: Int,
total: Int,
showProgress: Boolean,
relayListExpanded: MutableState<Boolean>
) {
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<Boolean>,
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
}
@@ -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
)
}
}
}
@@ -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<List<GroupRelay>>(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<GroupRelay>,
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<GroupRelay>): 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<String, Color> {
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
}
@@ -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<Long?>,
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 = {},
@@ -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<GroupMemberRole?>,
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)
}
@@ -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<GroupMemberRole>,
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<ConnectionStats?> = 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<ConnectionStats?> = 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<Long>, 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 = {
@@ -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) {
@@ -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<URI?>(null) }
val profileImage = rememberSaveable { mutableStateOf<String?>(null) }
val focusRequester = remember { FocusRequester() }
val hasRelays = rememberSaveable { mutableStateOf(true) }
val groupInfo = remember { mutableStateOf<GroupInfo?>(null) }
val groupLink = rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf<GroupLink?>(null) }
val groupRelays = remember { mutableStateOf<List<GroupRelay>>(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<UserChatRelay> {
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<String>,
profileImage: MutableState<String?>,
chosenImage: MutableState<URI?>,
focusRequester: FocusRequester,
hasRelays: MutableState<Boolean>,
creationInProgress: MutableState<Boolean>,
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<List<GroupRelay>>,
relayListExpanded: MutableState<Boolean>,
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<GroupLink?>,
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 = {})
}
}
@@ -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)
}
}
}
@@ -28,6 +28,15 @@ suspend fun planAndConnect(
filterKnownContact: ((Contact) -> Unit)? = null,
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
): CompletableDeferred<Boolean> {
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)
}
@@ -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,
)
)
@@ -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<String>) {
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>?,
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<UserChatRelay>,
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<UserChatRelay>) {
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<UserChatRelay>,
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<UserChatRelay>,
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<String>,
duplicateRelayAddresses: Set<String>,
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<UserChatRelay>) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.new_chat_relay))
CustomRelay(relay, onDelete = null)
SectionBottomSpacer()
}
}
@@ -54,6 +54,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) {
val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList<UserOperatorServers>() } }
val userServers = remember { stateGetOrPut("userServers") { emptyList<UserOperatorServers>() } }
val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList<UserServersError>() } }
val serverWarnings = remember { stateGetOrPut("serverWarnings") { emptyList<UserServersWarning>() } }
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<OnionHosts>,
currUserServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
userServers: MutableState<List<UserOperatorServers>>,
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<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServ
suspend fun validateServers_(
rhId: Long?,
userServersToValidate: List<UserOperatorServers>,
serverErrors: MutableState<List<UserServersError>>
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>? = 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<UserServersError>): String? {
return null
}
fun globalServersWarning(serverWarnings: List<UserServersWarning>): String? {
for (warn in serverWarnings) {
if (warn.globalWarning != null) {
return warn.globalWarning
}
}
return null
}
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalSMPError != null) {
@@ -943,6 +990,12 @@ fun findDuplicateHosts(serverErrors: List<UserServersError>): Set<String> {
return duplicateHostsList.toSet()
}
fun findDuplicateRelayNames(serverErrors: List<UserServersError>): Set<String> =
serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayName)?.duplicateChatRelay }.toSet()
fun findDuplicateRelayAddresses(serverErrors: List<UserServersError>): Set<String> =
serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayAddress)?.duplicateAddress }.toSet()
private suspend fun saveServers(
rhId: Long?,
currUserServers: MutableState<List<UserOperatorServers>>,
@@ -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()) }
)
}
}
@@ -15,6 +15,7 @@ import kotlinx.coroutines.*
fun ModalData.NewServerView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>? = null,
rhId: Long?,
close: () -> Unit
) {
@@ -47,6 +47,7 @@ fun OperatorView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
operatorIndex: Int,
rhId: Long?,
close: () -> Unit
@@ -36,6 +36,7 @@ fun ProtocolServerView(
serverProtocol: ServerProtocol,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
onDelete: () -> Unit,
onUpdate: (UserServer) -> Unit,
close: () -> Unit,
@@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
fun ModalData.YourServersView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
serverWarnings: MutableState<List<UserServersWarning>>,
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<List<UserOperatorServers>>,
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)
}
)
}
}
}
@@ -100,7 +100,7 @@
<string name="simplex_link_invitation">SimpleX one-time invitation</string>
<string name="simplex_link_group">SimpleX group link</string>
<string name="simplex_link_channel">SimpleX channel link</string>
<string name="simplex_link_relay">SimpleX relay link</string>
<string name="simplex_link_relay">SimpleX relay address</string>
<string name="simplex_link_connection">via %1$s</string>
<string name="simplex_link_mode">SimpleX links</string>
<string name="simplex_link_mode_description">Description</string>
@@ -141,6 +141,8 @@
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="no_chat_relays_enabled">No chat relays enabled.</string>
<string name="server_warning">Server warning</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<string name="blocking_reason_spam">Spam</string>
<string name="blocking_reason_content">Content violates conditions of use</string>
@@ -514,8 +516,11 @@
<string name="chat_banner_your_contact">Your contact</string>
<string name="chat_banner_bot">Bot</string>
<string name="chat_banner_join_group">Tap Join group</string>
<string name="chat_banner_join_channel">Tap Join channel</string>
<string name="chat_banner_your_group">Your group</string>
<string name="chat_banner_your_channel">Your channel</string>
<string name="chat_banner_group">Group</string>
<string name="chat_banner_channel">Channel</string>
<string name="chat_banner_business_connection">Business connection</string>
<string name="chat_banner_your_business_contact">Your business contact</string>
@@ -561,6 +566,8 @@
<string name="report_sent_alert_title">Report sent to moderators</string>
<string name="report_sent_alert_msg_view_in_support_chat">You can view your reports in Chat with admins.</string>
<string name="compose_view_join_group">Join group</string>
<string name="compose_view_join_channel">Join channel</string>
<string name="compose_view_broadcast">Broadcast</string>
<string name="compose_view_add_message">Add message</string>
<string name="compose_view_connect">Connect</string>
<string name="compose_view_send_contact_request_alert_question">Send contact request?</string>
@@ -575,7 +582,9 @@
<string name="cant_send_message_contact_not_synchronized">not synchronized</string>
<string name="cant_send_message_contact_disabled">contact disabled</string>
<string name="observer_cant_send_message_title">you are observer</string>
<string name="you_are_subscriber">you are subscriber</string>
<string name="observer_cant_send_message_desc">Please contact group admin.</string>
<string name="channel_role_label">channel</string>
<string name="cant_send_message_rejected">request to join rejected</string>
<string name="cant_send_message_group_deleted">group is deleted</string>
<string name="cant_send_message_mem_removed">removed from group</string>
@@ -1655,8 +1664,10 @@
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">You joined this group. Connecting to inviting group member.</string>
<string name="leave_group_button">Leave</string>
<string name="leave_group_question">Leave group?</string>
<string name="leave_channel_question">Leave channel?</string>
<string name="leave_chat_question">Leave chat?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">You will stop receiving messages from this group. Chat history will be preserved.</string>
<string name="you_will_stop_receiving_messages_from_this_channel_chat_history_will_be_preserved">You will stop receiving messages from this channel. Chat history will be preserved.</string>
<string name="you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved">You will stop receiving messages from this chat. Chat history will be preserved.</string>
<string name="icon_descr_add_members">Invite members</string>
<string name="icon_descr_group_inactive">Group inactive</string>
@@ -1755,6 +1766,7 @@
<string name="group_member_role_moderator">moderator</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">owner</string>
<string name="group_member_role_relay">relay</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_rejected">rejected</string>
@@ -1803,24 +1815,32 @@
<string name="group_info_section_title_num_members">%1$s MEMBERS</string>
<string name="group_info_member_you">you: %1$s</string>
<string name="button_delete_group">Delete group</string>
<string name="button_delete_channel">Delete channel</string>
<string name="button_delete_chat">Delete chat</string>
<string name="delete_group_question">Delete group?</string>
<string name="delete_channel_question">Delete channel?</string>
<string name="delete_chat_question">Delete chat?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Group will be deleted for all members - this cannot be undone!</string>
<string name="delete_channel_for_all_subscribers_cannot_undo_warning">Channel will be deleted for all subscribers - this cannot be undone!</string>
<string name="delete_chat_for_all_members_cannot_undo_warning">Chat will be deleted for all members - this cannot be undone!</string>
<string name="delete_group_for_self_cannot_undo_warning">Group will be deleted for you - this cannot be undone!</string>
<string name="delete_channel_for_self_cannot_undo_warning">Channel will be deleted for you - this cannot be undone!</string>
<string name="delete_chat_for_self_cannot_undo_warning">Chat will be deleted for you - this cannot be undone!</string>
<string name="button_leave_group">Leave group</string>
<string name="button_leave_channel">Leave channel</string>
<string name="button_leave_chat">Leave chat</string>
<string name="button_edit_group_profile">Edit group profile</string>
<string name="button_edit_channel_profile">Edit channel profile</string>
<string name="button_add_welcome_message">Add welcome message</string>
<string name="button_welcome_message">Welcome message</string>
<string name="group_link">Group link</string>
<string name="channel_link">Channel link</string>
<string name="create_group_link">Create group link</string>
<string name="button_create_group_link">Create link</string>
<string name="delete_link_question">Delete link?</string>
<string name="delete_link">Delete link</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">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.</string>
<string name="you_can_share_channel_link_anybody_will_be_able_to_connect">You can share a link or a QR code - anybody will be able to join the channel.</string>
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
<string name="error_creating_link_for_group">Error creating group link</string>
<string name="error_updating_link_for_group">Error updating group link</string>
@@ -1837,7 +1857,10 @@
<string name="send_receipts_disabled_alert_title">Receipts are disabled</string>
<string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string>
<string name="action_button_add_members">Invite</string>
<string name="action_button_channel_link">Link</string>
<string name="button_support_chat">Chat with admins</string>
<string name="button_channel_members">Channel members</string>
<string name="button_channel_relays">Chat relays</string>
<!-- Chat / Chat item info -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -1872,6 +1895,7 @@
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member_question">Remove member?</string>
<string name="button_remove_subscriber_question">Remove subscriber?</string>
<string name="button_remove_members_question">Remove members?</string>
<string name="button_delete_member_messages_question">Delete member messages?</string>
<string name="button_remove_member">Remove member</string>
@@ -1879,6 +1903,7 @@
<string name="button_support_chat_member">Chat with member</string>
<string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="subscriber_will_be_removed_from_channel_cannot_be_undone">Subscriber will be removed from channel - this cannot be undone!</string>
<string name="members_will_be_removed_from_group_cannot_be_undone">Members will be removed from group - this cannot be undone!</string>
<string name="member_will_be_removed_from_chat_cannot_be_undone">Member will be removed from chat - this cannot be undone!</string>
<string name="members_will_be_removed_from_chat_cannot_be_undone">Members will be removed from chat - this cannot be undone!</string>
@@ -1925,7 +1950,7 @@
<string name="info_row_group">Group</string>
<string name="info_row_chat">Chat</string>
<string name="info_row_connection">Connection</string>
<string name="info_row_connection_failed">Connection failed</string>
<string name="info_row_connection_failed">CONNECTION FAILED</string>
<string name="conn_level_desc_direct">direct</string>
<string name="conn_level_desc_indirect">indirect (%1$s)</string>
<string name="message_queue_info">Message queue info</string>
@@ -2794,4 +2819,99 @@
<!-- GroupMentions.kt -->
<string name="max_group_mentions_per_message_reached">You can mention up to %1$s members per message!</string>
<!-- ChannelMembersView.kt -->
<string name="channel_members_title_owners_and_subscribers">Owners &amp; subscribers</string>
<string name="channel_members_section_owners">Owners</string>
<string name="channel_members_num_subscribers">%1$d subscribers</string>
<string name="channel_members_no_subscribers">No subscribers</string>
<!-- ChatRelayView.kt -->
<string name="chat_relay">Chat relay</string>
<string name="new_chat_relay">New chat relay</string>
<string name="preset_relay_name">Preset relay name</string>
<string name="preset_relay_address">Preset relay address</string>
<string name="your_relay_name">Your relay name</string>
<string name="your_relay_address">Your relay address</string>
<string name="enter_relay_name">Enter relay name…</string>
<string name="use_relay">Use relay</string>
<string name="test_relay">Test relay</string>
<string name="use_for_new_channels">Use for new channels</string>
<string name="delete_relay">Delete relay</string>
<string name="not_implemented">Not implemented</string>
<string name="relay_testing_not_available">Relay testing is not yet available.</string>
<string name="invalid_relay_name">Invalid relay name!</string>
<string name="check_relay_name">Check relay name and try again.</string>
<string name="invalid_relay_address">Invalid relay address!</string>
<string name="check_relay_address">Check relay address and try again.</string>
<string name="error_adding_relay">Error adding relay</string>
<!-- OperatorView.kt / ProtocolServersView.kt -->
<string name="chat_relays">Chat relays</string>
<string name="chat_relays_forward_messages_in_channels">Chat relays forward messages in channels you create.</string>
<!-- ChannelRelaysView.kt -->
<string name="channel_relays_title">Chat relays</string>
<string name="no_chat_relays">No chat relays</string>
<string name="chat_relays_forward_messages">Chat relays forward messages to channel subscribers.</string>
<string name="relay_conn_status_connected">connected</string>
<string name="relay_conn_status_connecting">connecting</string>
<string name="relay_conn_status_deleted">deleted</string>
<string name="relay_conn_status_failed">failed</string>
<string name="relay_status_new">new</string>
<string name="relay_status_invited">invited</string>
<string name="relay_status_accepted">accepted</string>
<string name="relay_status_active">active</string>
<!-- ComposeView.kt channel relay bars -->
<string name="relay_bar_active_with_failures">%1$d/%2$d relays active, %3$d failed</string>
<string name="relay_bar_active">%1$d/%2$d relays active</string>
<string name="relay_bar_connected_with_errors">%1$d/%2$d relays connected, %3$d errors</string>
<string name="relay_bar_connected">%1$d/%2$d relays connected</string>
<string name="relay_bar_count">%1$d relays</string>
<!-- GroupMemberInfoView.kt channel-related -->
<string name="member_info_section_title_relay">RELAY</string>
<string name="member_info_section_title_owner">OWNER</string>
<string name="member_info_section_title_subscriber">SUBSCRIBER</string>
<string name="info_row_channel">Channel</string>
<string name="info_row_relay_link">Relay link</string>
<string name="info_row_relay_address">Relay address</string>
<string name="via_relay_hostname">via %1$s</string>
<string name="share_relay_address">Share relay address</string>
<string name="relay_section_footer_owner">Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel.</string>
<string name="relay_section_footer_subscriber">You connected to the channel via this relay link.</string>
<string name="button_remove_subscriber">Remove subscriber</string>
<string name="block_subscriber_for_all_question">Block subscriber for all?</string>
<!-- AddChannelView.kt -->
<string name="create_channel_title">Create channel</string>
<string name="create_channel_button">Create channel</string>
<string name="create_channel_beta_button">Create channel (BETA)</string>
<string name="channel_display_name_field">Channel name</string>
<string name="creating_channel">Creating channel</string>
<string name="error_creating_channel">Error creating channel</string>
<string name="cancel_creating_channel_question">Cancel creating channel?</string>
<string name="cancel_creating_channel_confirm">Cancel</string>
<string name="enable_at_least_one_chat_relay">Enable at least one chat relay to create a channel.</string>
<string name="your_profile_shared_with_channel_relays">Your profile %1$s will be shared with channel relays and subscribers.</string>
<string name="configure_relays">Configure relays</string>
<string name="relay_status_failed">failed</string>
<string name="relay_connection_failed">Relay connection failed</string>
<string name="not_all_relays_connected">Not all relays connected</string>
<string name="wait_verb">Wait</string>
<string name="proceed_verb">Proceed</string>
<string name="channel_will_start_with_relays">Channel will start working with %1$d of %2$d relays. Proceed?</string>
<!-- ConnectPlan.kt channel-related -->
<string name="relay_address_alert_title">Relay address</string>
<string name="relay_address_alert_message">This is a chat relay address, it cannot be used to connect.</string>
<string name="connect_plan_open_channel">Open channel</string>
<string name="connect_plan_open_new_channel">Open new channel</string>
<string name="connect_plan_this_is_your_link_for_channel">Your channel</string>
<string name="connect_plan_this_is_your_link_for_channel_vName"><![CDATA[This is your link for channel <b>%1$s</b>!]]></string>
<string name="error_opening_channel">Error opening channel</string>
<!-- ChatListNavLinkView.kt channel-related -->
<string name="unblock_subscriber_for_all_question">Unblock subscriber for all?</string>
</resources>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M129-560q0 63 21.5 121.25T213-333q7.5 8 8.25 18.75T214.5-296q-8 8-17.5 6t-17-10q-48-53.5-73.5-120.75T81-560q0-72 25.5-139.25T180-820.5q7.5-7.5 17-9t17.25 6.25q7.75 7.75 7 18T213-787.5q-41 48-62.5 106.25T129-560Zm143.75 71.75q11.75 34.75 34.75 63.75 6.5 8 7 18t-7.25 17.75q-7.75 7.75-17.5 7.25T273.5-390q-29-36-44.75-79.75T213-560q0-46.5 15.75-90.25T273.5-730q6.5-8 16.25-9t17.5 6.75q7.75 7.75 7.25 17.75t-7 17.5q-23 29-34.75 64.5T261-560q0 37 11.75 71.75ZM451.5-150v-325q-28-8.5-44.5-32.25T390.5-560q0-37.5 26-63.75T480-650q37.5 0 63.75 26.25T570-560q0 29-16.75 52.75T509-475v325q0 12.5-8.25 20.5t-20.75 8q-12.5 0-20.5-8t-8-20.5Zm235.75-481.75Q675.5-666.5 653-696q-7-7.5-7.5-17.5t7.25-17.75q7.75-7.75 17.5-7.25t16.25 8.5q29 36 44.75 79.75T747-560q0 46.5-15.75 90.25T686.5-390q-6.5 8-15.75 8.5t-17-7.25Q646-396.5 646-406.5t7-18q22.5-29 34.25-63.75T699-560q0-37-11.75-71.75ZM831-560q0-63-21.5-121.25T747-787.5q-7.5-7.5-8.25-18.25t7-18.5Q753.5-832 763-830t17.5 9.5q47.5 54 73 121.25T879-560q0 72-25.5 139.25T780.5-300q-8 8-17.5 9.5t-17.25-6.25q-7.75-7.75-7-18T747-333q41-47.5 62.5-105.75T831-560Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB