mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-15 03:46:23 +00:00
Merge branch 'master' into ae/oklch-color-space-plan
This commit is contained in:
@@ -73,6 +73,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
|
||||
case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile)
|
||||
case apiGetGroupRelays(groupId: Int64)
|
||||
case apiAddGroupRelays(groupId: Int64, relayIds: [Int64])
|
||||
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
|
||||
case apiJoinGroup(groupId: Int64)
|
||||
case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
|
||||
@@ -275,6 +276,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
|
||||
case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))"
|
||||
case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)"
|
||||
case let .apiAddGroupRelays(groupId, relayIds): return "/_add relays #\(groupId) \(relayIds.map(String.init).joined(separator: ","))"
|
||||
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
|
||||
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
|
||||
case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
|
||||
@@ -468,6 +470,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .apiNewGroup: return "apiNewGroup"
|
||||
case .apiNewPublicGroup: return "apiNewPublicGroup"
|
||||
case .apiGetGroupRelays: return "apiGetGroupRelays"
|
||||
case .apiAddGroupRelays: return "apiAddGroupRelays"
|
||||
case .apiAddMember: return "apiAddMember"
|
||||
case .apiJoinGroup: return "apiJoinGroup"
|
||||
case .apiAcceptMember: return "apiAcceptMember"
|
||||
@@ -944,6 +947,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
|
||||
case publicGroupCreationFailed(user: UserRef, addRelayResults: [AddRelayResult])
|
||||
case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay])
|
||||
case groupRelaysAdded(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
|
||||
case groupRelaysAddFailed(user: UserRef, addRelayResults: [AddRelayResult])
|
||||
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
|
||||
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
|
||||
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
|
||||
@@ -997,6 +1002,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case .publicGroupCreated: "publicGroupCreated"
|
||||
case .publicGroupCreationFailed: "publicGroupCreationFailed"
|
||||
case .groupRelays: "groupRelays"
|
||||
case .groupRelaysAdded: "groupRelaysAdded"
|
||||
case .groupRelaysAddFailed: "groupRelaysAddFailed"
|
||||
case .sentGroupInvitation: "sentGroupInvitation"
|
||||
case .userAcceptedGroupSent: "userAcceptedGroupSent"
|
||||
case .userDeletedMembers: "userDeletedMembers"
|
||||
@@ -1046,6 +1053,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
|
||||
case let .publicGroupCreationFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)")
|
||||
case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)")
|
||||
case let .groupRelaysAdded(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
|
||||
case let .groupRelaysAddFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)")
|
||||
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
|
||||
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
|
||||
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
|
||||
|
||||
@@ -344,9 +344,12 @@ class ChannelRelaysModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) {
|
||||
if groupId == groupInfo.groupId,
|
||||
let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
|
||||
groupRelays[i] = relay
|
||||
if groupId == groupInfo.groupId {
|
||||
if let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
|
||||
groupRelays[i] = relay
|
||||
} else {
|
||||
groupRelays.append(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1891,6 +1891,22 @@ func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
|
||||
return []
|
||||
}
|
||||
|
||||
enum AddGroupRelaysResult {
|
||||
case added(GroupInfo, GroupLink, [GroupRelay])
|
||||
case addFailed([AddRelayResult])
|
||||
}
|
||||
|
||||
func apiAddGroupRelays(_ groupId: Int64, relayIds: [Int64]) async throws -> AddGroupRelaysResult? {
|
||||
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAddGroupRelays(groupId: groupId, relayIds: relayIds))
|
||||
switch r {
|
||||
case let .result(.groupRelaysAdded(_, groupInfo, groupLink, groupRelays)):
|
||||
return .added(groupInfo, groupLink, groupRelays)
|
||||
case let .result(.groupRelaysAddFailed(_, addRelayResults)):
|
||||
return .addFailed(addRelayResults)
|
||||
default: if let r { throw r.unexpected } else { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
|
||||
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
|
||||
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -167,7 +167,7 @@ struct FramedItemView: View {
|
||||
case let .report(text, reason):
|
||||
ciMsgContentView(chatItem, txtPrefix: reason.attrString)
|
||||
case let .link(_, preview):
|
||||
CILinkView(linkPreview: preview)
|
||||
CILinkView(linkPreview: preview, maxWidth: maxWidth)
|
||||
ciMsgContentView(chatItem)
|
||||
case let .chat(text, chatLink, ownerSig):
|
||||
let hasText = text != chatLink.connLinkStr
|
||||
|
||||
@@ -745,7 +745,7 @@ struct ChatView: View {
|
||||
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if groupInfo.membership.memberCurrent {
|
||||
Task {
|
||||
if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) {
|
||||
await MainActor.run {
|
||||
@@ -2175,8 +2175,14 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal, moderate: false)
|
||||
if publicGroupEditor(chat) {
|
||||
Button("Delete from history", role: .destructive) {
|
||||
deleteMessage(.cidmHistory, moderate: false)
|
||||
}
|
||||
} else {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal, moderate: false)
|
||||
}
|
||||
}
|
||||
if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport {
|
||||
Button(broadcastDeleteButtonText(chat), role: .destructive) {
|
||||
@@ -2185,8 +2191,14 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessages(chat, deletingItems, moderate: false)
|
||||
if publicGroupEditor(chat) {
|
||||
Button("Delete from history", role: .destructive) {
|
||||
deleteMessages(chat, deletingItems, .cidmHistory, moderate: false)
|
||||
}
|
||||
} else {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessages(chat, deletingItems, moderate: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) {
|
||||
@@ -2817,6 +2829,9 @@ struct ChatView: View {
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error)")
|
||||
await MainActor.run {
|
||||
showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2963,6 +2978,14 @@ class FloatingButtonModel: ObservableObject {
|
||||
|
||||
}
|
||||
|
||||
private func publicGroupEditor(_ chat: Chat) -> Bool {
|
||||
if case let .group(groupInfo, _) = chat.chatInfo {
|
||||
groupInfo.useRelays && groupInfo.membership.memberRole >= .moderator
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
@@ -3010,6 +3033,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
|
||||
await onSuccess()
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessages error: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,13 +395,13 @@ struct ComposeView: View {
|
||||
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
|
||||
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
|
||||
if gInfo.membership.memberRole == .owner {
|
||||
if let s = ownerState, s.activeCount < s.relays.count {
|
||||
if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count {
|
||||
ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount)
|
||||
}
|
||||
} else {
|
||||
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
|
||||
let relayMembers = chatModel.groupMembers
|
||||
.filter { $0.wrapped.memberRole == .relay }
|
||||
.filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
|
||||
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
|
||||
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
|
||||
let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
|
||||
@@ -409,7 +409,7 @@ struct ComposeView: View {
|
||||
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
|
||||
let resolvedCount = connectedCount + removedCount + failedCount
|
||||
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
|
||||
if total > 0, removedCount + failedCount > 0 || resolvedCount < total {
|
||||
if total == 0 || removedCount + failedCount > 0 || resolvedCount < total {
|
||||
subscriberChannelRelayBar(
|
||||
hostnames: hostnames,
|
||||
relayMembers: relayMembers,
|
||||
@@ -735,9 +735,9 @@ struct ComposeView: View {
|
||||
gInfo.membership.memberRole == .owner,
|
||||
![.memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
|
||||
else { return nil }
|
||||
let relays = channelRelaysModel.groupId == gInfo.groupId
|
||||
? channelRelaysModel.groupRelays : []
|
||||
guard !relays.isEmpty else { return nil }
|
||||
guard channelRelaysModel.groupId == gInfo.groupId else { return nil }
|
||||
let relays = channelRelaysModel.groupRelays
|
||||
guard !relays.isEmpty else { return ([], 0, 0, 0, true) }
|
||||
let relayMembers = relays.map { relay in
|
||||
(relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
|
||||
}
|
||||
@@ -763,7 +763,11 @@ struct ComposeView: View {
|
||||
if !allBroken && activeCount + failedCount + removedCount < total {
|
||||
RelayProgressIndicator(active: activeCount, total: total)
|
||||
}
|
||||
if allBroken {
|
||||
if total == 0 {
|
||||
Text("No relays")
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
} else if allBroken {
|
||||
if removedCount == total {
|
||||
Text("All relays removed")
|
||||
} else if failedCount == total {
|
||||
@@ -793,7 +797,7 @@ struct ComposeView: View {
|
||||
}
|
||||
if relayListExpanded {
|
||||
if allBroken {
|
||||
Text("Adding relays will be supported later.")
|
||||
Text("Add relays to restore message delivery.")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -843,7 +847,11 @@ struct ComposeView: View {
|
||||
let allBroken = connectedCount == 0 && errorCount == total
|
||||
VStack(spacing: 0) {
|
||||
relayBarHeader {
|
||||
if allBroken {
|
||||
if total == 0 {
|
||||
Text("No relays")
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
} else if allBroken {
|
||||
if removedCount == total {
|
||||
Text("All relays removed")
|
||||
} else if failedCount == total {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// AddGroupRelayView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by simplex on 29.04.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct AddGroupRelayView: View {
|
||||
var groupInfo: GroupInfo
|
||||
var existingRelayIds: Set<Int64>
|
||||
var onRelayAdded: () -> Void
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var availableRelays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
|
||||
@State private var selectedRelayIds: Set<Int64> = []
|
||||
@State private var isLoading = true
|
||||
@State private var isAdding = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
if isLoading {
|
||||
Section {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} else if availableRelays.isEmpty {
|
||||
Section {
|
||||
Text("No available relays")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(availableRelays, id: \.relayId) { item in
|
||||
relayCheckRow(item.relayId, item.relay, operatorName: item.operatorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationTitle("Add relays")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Add") { addSelectedRelays() }
|
||||
.disabled(selectedRelayIds.isEmpty || isAdding)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadAvailableRelays() }
|
||||
}
|
||||
|
||||
private func relayCheckRow(_ relayId: Int64, _ relay: UserChatRelay, operatorName: String?) -> some View {
|
||||
let selected = selectedRelayIds.contains(relayId)
|
||||
return Button {
|
||||
if selected {
|
||||
selectedRelayIds.remove(relayId)
|
||||
} else {
|
||||
selectedRelayIds.insert(relayId)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(chatRelayDisplayName(relay))
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.lineLimit(1)
|
||||
if let opName = operatorName {
|
||||
Text(opName)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(selected ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAvailableRelays() async {
|
||||
do {
|
||||
let servers = try await getUserServers()
|
||||
var relays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
|
||||
for op in servers {
|
||||
if let oper = op.operator, oper.enabled != true { continue }
|
||||
let opName: String? = op.operator?.operatorTag != nil ? op.operator?.tradeName : nil
|
||||
for relay in op.chatRelays {
|
||||
if relay.enabled && !relay.deleted,
|
||||
let relayId = relay.chatRelayId,
|
||||
!existingRelayIds.contains(relayId) {
|
||||
relays.append((relayId, relay, opName))
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
availableRelays = relays
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
logger.error("loadAvailableRelays error: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSelectedRelays() {
|
||||
let relayIds = Array(selectedRelayIds)
|
||||
guard !relayIds.isEmpty else { return }
|
||||
isAdding = true
|
||||
Task {
|
||||
do {
|
||||
guard let result = try await apiAddGroupRelays(groupInfo.groupId, relayIds: relayIds) else {
|
||||
await MainActor.run { isAdding = false }
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
isAdding = false
|
||||
switch result {
|
||||
case let .added(gInfo, _, relays):
|
||||
ChannelRelaysModel.shared.set(groupId: gInfo.groupId, groupRelays: relays)
|
||||
onRelayAdded()
|
||||
dismiss()
|
||||
case let .addFailed(results):
|
||||
let successIds = Set(results.filter { $0.relayError == nil }.compactMap { $0.relay.chatRelayId })
|
||||
if !successIds.isEmpty {
|
||||
selectedRelayIds.subtract(successIds)
|
||||
availableRelays.removeAll { successIds.contains($0.relayId) }
|
||||
onRelayAdded()
|
||||
}
|
||||
let errorLines = results.filter { $0.relayError != nil }
|
||||
.map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "")" }
|
||||
let successNames = results.filter { $0.relayError == nil }
|
||||
.map { chatRelayDisplayName($0.relay) }
|
||||
var msg = errorLines.joined(separator: "\n")
|
||||
if !successNames.isEmpty {
|
||||
msg += "\n" + String.localizedStringWithFormat(NSLocalizedString("Relays added: %@.", comment: "alert message"), successNames.joined(separator: ", "))
|
||||
}
|
||||
showAlert(
|
||||
NSLocalizedString("Error adding relays", comment: "alert title"),
|
||||
message: msg
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isAdding = false
|
||||
showAlert(NSLocalizedString("Error adding relays", comment: "alert title"), message: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,24 +14,49 @@ struct ChannelRelaysView: View {
|
||||
var groupInfo: GroupInfo
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State private var groupRelays: [GroupRelay] = []
|
||||
@ObservedObject private var channelRelaysModel = ChannelRelaysModel.shared
|
||||
@State private var showAddRelay = false
|
||||
|
||||
private var groupRelays: [GroupRelay] {
|
||||
channelRelaysModel.groupId == groupInfo.groupId ? channelRelaysModel.groupRelays : []
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
relaysList()
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
// if groupInfo.isOwner {
|
||||
// Section {
|
||||
// Button {
|
||||
// showAddRelay = true
|
||||
// } label: {
|
||||
// Label("Add relay", systemImage: "plus")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
// .sheet(isPresented: $showAddRelay) {
|
||||
// let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId })
|
||||
// AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
|
||||
// Task { await chatModel.loadGroupMembers(groupInfo) }
|
||||
// }
|
||||
// }
|
||||
.onAppear {
|
||||
Task {
|
||||
await chatModel.loadGroupMembers(groupInfo)
|
||||
if groupInfo.isOwner {
|
||||
groupRelays = await apiGetGroupRelays(groupInfo.groupId)
|
||||
let relays = await apiGetGroupRelays(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func relaysList() -> some View {
|
||||
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay }
|
||||
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberStatus != .memRemoved && $0.wrapped.memberStatus != .memGroupDeleted }
|
||||
if relayMembers.isEmpty {
|
||||
Section {
|
||||
Text("No chat relays")
|
||||
@@ -40,7 +65,7 @@ struct ChannelRelaysView: View {
|
||||
} else {
|
||||
Section {
|
||||
ForEach(relayMembers) { member in
|
||||
NavigationLink {
|
||||
let link = NavigationLink {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: groupInfo,
|
||||
chat: chat,
|
||||
@@ -55,6 +80,20 @@ struct ChannelRelaysView: View {
|
||||
: subscriberRelayStatusText(member.wrapped)
|
||||
relayMemberRow(member.wrapped, statusText: statusText)
|
||||
}
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
// if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
|
||||
// link.swipeActions(edge: .trailing) {
|
||||
// Button {
|
||||
// showRemoveMemberAlert(groupInfo, member.wrapped)
|
||||
// } label: {
|
||||
// Label("Remove relay", systemImage: "trash")
|
||||
// }
|
||||
// .tint(.red)
|
||||
// }
|
||||
// } else {
|
||||
// link
|
||||
// }
|
||||
link
|
||||
}
|
||||
} footer: {
|
||||
Text("Chat relays forward messages to channel subscribers.")
|
||||
|
||||
@@ -924,26 +924,54 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
|
||||
showAlert(
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Remove subscriber?", comment: "alert title")
|
||||
: NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message:
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
|
||||
: groupInfo.businessChat == nil
|
||||
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
|
||||
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
if mem.memberRole == .relay {
|
||||
let isLastActive = groupInfo.useRelays && mem.memberCurrent && {
|
||||
let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }
|
||||
return activeRelays.count <= 1
|
||||
}()
|
||||
showAlert(
|
||||
NSLocalizedString("Remove relay?", comment: "alert title"),
|
||||
message: isLastActive
|
||||
? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message")
|
||||
: NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
} else if groupInfo.useRelays {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove subscriber?", comment: "alert title"),
|
||||
message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message: groupInfo.businessChat == nil
|
||||
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
|
||||
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
|
||||
|
||||
@@ -641,13 +641,12 @@ struct GroupMemberInfoView: View {
|
||||
blockForAllButton(mem)
|
||||
}
|
||||
}
|
||||
// TODO [relays] removing relay should also remove its link from group link data;
|
||||
// TODO - removing last relay should be prohibited or show warning
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
if canRemove && mem.memberRole != .relay {
|
||||
if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
|
||||
deleteMemberMessagesButton(mem)
|
||||
} else {
|
||||
if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) {
|
||||
removeMemberButton(mem)
|
||||
} else if mem.memberRole != .relay {
|
||||
deleteMemberMessagesButton(mem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,7 +704,10 @@ struct GroupMemberInfoView: View {
|
||||
Button(role: .destructive) {
|
||||
showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
|
||||
} label: {
|
||||
Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash")
|
||||
let text = mem.memberRole == .relay ? "Remove relay"
|
||||
: groupInfo.useRelays ? "Remove subscriber"
|
||||
: "Remove member"
|
||||
Label(text, systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +338,9 @@ struct AddChannelView: View {
|
||||
.compactSectionSpacing()
|
||||
|
||||
Section {
|
||||
Button("Cancel and delete channel", role: .destructive) {
|
||||
showCancelChannelAlert(gInfo)
|
||||
}
|
||||
Button("Continue") {
|
||||
if activeCount >= total {
|
||||
showLinkStep = true
|
||||
@@ -365,11 +368,6 @@ struct AddChannelView: View {
|
||||
}
|
||||
.navigationTitle("Creating channel")
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Delete channel") { showCancelChannelAlert(gInfo) }
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if !showLinkStep && m.creatingChannelId == gInfo.id {
|
||||
showCancelChannelAlert(gInfo)
|
||||
@@ -481,7 +479,7 @@ func relayDisplayName(_ relay: GroupRelay) -> String {
|
||||
return "relay \(relay.groupRelayId)"
|
||||
}
|
||||
|
||||
private func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
if !relay.displayName.isEmpty { return relay.displayName }
|
||||
return relay.address
|
||||
}
|
||||
@@ -489,7 +487,7 @@ private func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View {
|
||||
let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
|
||||
let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow)
|
||||
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : status.text
|
||||
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
|
||||
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
|
||||
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
|
||||
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; };
|
||||
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
|
||||
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; };
|
||||
@@ -546,6 +547,7 @@
|
||||
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
|
||||
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
|
||||
6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = "<group>"; };
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
|
||||
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = "<group>"; };
|
||||
@@ -1173,6 +1175,7 @@
|
||||
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
|
||||
6495D7032F48CFC50060512B /* ChannelMembersView.swift */,
|
||||
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */,
|
||||
6495D7072F48D0000060512B /* AddGroupRelayView.swift */,
|
||||
);
|
||||
path = Group;
|
||||
sourceTree = "<group>";
|
||||
@@ -1633,6 +1636,7 @@
|
||||
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
|
||||
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */,
|
||||
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */,
|
||||
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */,
|
||||
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
|
||||
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
||||
|
||||
@@ -4091,6 +4091,7 @@ public enum CIDeleteMode: String, Decodable, Hashable {
|
||||
case cidmBroadcast = "broadcast"
|
||||
case cidmInternal = "internal"
|
||||
case cidmInternalMark = "internalMark"
|
||||
case cidmHistory = "history"
|
||||
}
|
||||
|
||||
protocol ItemContent {
|
||||
|
||||
+3
-1
@@ -92,6 +92,7 @@ object ChannelRelaysModel {
|
||||
if (groupId.value == groupInfo.groupId) {
|
||||
val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId }
|
||||
if (i >= 0) groupRelays[i] = relay
|
||||
else groupRelays.add(relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3734,7 +3735,8 @@ sealed class CIForwardedFrom {
|
||||
enum class CIDeleteMode(val deleteMode: String) {
|
||||
@SerialName("internal") cidmInternal("internal"),
|
||||
@SerialName("internalMark") cidmInternalMark("internalMark"),
|
||||
@SerialName("broadcast") cidmBroadcast("broadcast");
|
||||
@SerialName("broadcast") cidmBroadcast("broadcast"),
|
||||
@SerialName("history") cidmHistory("history");
|
||||
}
|
||||
|
||||
interface ItemContent {
|
||||
|
||||
+50
-13
@@ -91,6 +91,13 @@ enum class SimplexLinkMode {
|
||||
}
|
||||
}
|
||||
|
||||
enum class CloseBehavior {
|
||||
Ask, Quit, MinimizeToTray;
|
||||
companion object { val default = Ask }
|
||||
}
|
||||
|
||||
class HintPref(val reset: () -> Unit, val isUnchanged: () -> Boolean)
|
||||
|
||||
// Spec: spec/state.md#AppPreferences
|
||||
class AppPreferences {
|
||||
// deprecated, remove in 2024
|
||||
@@ -99,6 +106,7 @@ class AppPreferences {
|
||||
SHARED_PREFS_NOTIFICATIONS_MODE,
|
||||
if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default
|
||||
) { NotificationsMode.values().firstOrNull { it.name == this } }
|
||||
val closeBehavior: SharedPreference<CloseBehavior> = mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default)
|
||||
val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
|
||||
val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true)
|
||||
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||
@@ -257,17 +265,23 @@ class AppPreferences {
|
||||
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true)
|
||||
val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true)
|
||||
|
||||
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
|
||||
laNoticeShown to false,
|
||||
oneHandUICardShown to false,
|
||||
addressCreationCardShown to false,
|
||||
liveMessageAlertShown to false,
|
||||
showHiddenProfilesNotice to true,
|
||||
showMuteProfileAlert to true,
|
||||
showReportsInSupportChatAlert to true,
|
||||
showDeleteConversationNotice to true,
|
||||
showDeleteContactNotice to true,
|
||||
privacyLinkPreviewsShowAlert to true,
|
||||
val hintPreferences: List<HintPref> = listOf(
|
||||
hintPref(laNoticeShown, false),
|
||||
hintPref(oneHandUICardShown, false),
|
||||
hintPref(addressCreationCardShown, false),
|
||||
hintPref(liveMessageAlertShown, false),
|
||||
hintPref(showHiddenProfilesNotice, true),
|
||||
hintPref(showMuteProfileAlert, true),
|
||||
hintPref(showReportsInSupportChatAlert, true),
|
||||
hintPref(showDeleteConversationNotice, true),
|
||||
hintPref(showDeleteContactNotice, true),
|
||||
hintPref(privacyLinkPreviewsShowAlert, true),
|
||||
hintPref(closeBehavior, CloseBehavior.default),
|
||||
)
|
||||
|
||||
private fun <T> hintPref(pref: SharedPreference<T>, default: T) = HintPref(
|
||||
reset = { pref.set(default) },
|
||||
isUnchanged = { pref.state.value == default },
|
||||
)
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
@@ -479,6 +493,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
|
||||
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
|
||||
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
|
||||
private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior"
|
||||
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
|
||||
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
|
||||
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
|
||||
@@ -1198,14 +1213,14 @@ object ChatController {
|
||||
suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List<Long>, mode: CIDeleteMode): List<ChatItemDeletion>? {
|
||||
val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode))
|
||||
if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions
|
||||
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
|
||||
apiErrorAlert("apiDeleteChatItems", generalGetString(MR.strings.error_deleting_message), r)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List<Long>): List<ChatItemDeletion>? {
|
||||
val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds))
|
||||
if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions
|
||||
Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}")
|
||||
apiErrorAlert("apiDeleteMemberChatItems", generalGetString(MR.strings.error_deleting_message), r)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2163,6 +2178,19 @@ object ChatController {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
sealed class AddGroupRelaysResult {
|
||||
data class Added(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): AddGroupRelaysResult()
|
||||
data class AddFailed(val addRelayResults: List<AddRelayResult>): AddGroupRelaysResult()
|
||||
}
|
||||
|
||||
suspend fun apiAddGroupRelays(groupId: Long, relayIds: List<Long>): AddGroupRelaysResult? {
|
||||
val r = sendCmdWithRetry(null, CC.ApiAddGroupRelays(groupId, relayIds))
|
||||
if (r is API.Result && r.res is CR.GroupRelaysAdded) return AddGroupRelaysResult.Added(r.res.groupInfo, r.res.groupLink, r.res.groupRelays)
|
||||
if (r is API.Result && r.res is CR.GroupRelaysAddFailed) return AddGroupRelaysResult.AddFailed(r.res.addRelayResults)
|
||||
if (r != null) throw Exception("${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
@@ -3666,6 +3694,7 @@ sealed class 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 ApiAddGroupRelays(val groupId: Long, val relayIds: List<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()
|
||||
@@ -3870,6 +3899,7 @@ sealed class CC {
|
||||
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 ApiAddGroupRelays -> "/_add relays #$groupId ${relayIds.joinToString(",")}"
|
||||
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
|
||||
is ApiJoinGroup -> "/_join #$groupId"
|
||||
is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}"
|
||||
@@ -4053,6 +4083,7 @@ sealed class CC {
|
||||
is ApiNewGroup -> "apiNewGroup"
|
||||
is ApiNewPublicGroup -> "apiNewPublicGroup"
|
||||
is ApiGetGroupRelays -> "apiGetGroupRelays"
|
||||
is ApiAddGroupRelays -> "apiAddGroupRelays"
|
||||
is ApiAddMember -> "apiAddMember"
|
||||
is ApiJoinGroup -> "apiJoinGroup"
|
||||
is ApiAcceptMember -> "apiAcceptMember"
|
||||
@@ -6402,6 +6433,8 @@ sealed class CR {
|
||||
@Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
|
||||
@Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
|
||||
@Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List<GroupRelay>): CR()
|
||||
@Serializable @SerialName("groupRelaysAdded") class GroupRelaysAdded(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
|
||||
@Serializable @SerialName("groupRelaysAddFailed") class GroupRelaysAddFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): 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()
|
||||
@@ -6591,6 +6624,8 @@ sealed class CR {
|
||||
is PublicGroupCreated -> "publicGroupCreated"
|
||||
is PublicGroupCreationFailed -> "publicGroupCreationFailed"
|
||||
is GroupRelays -> "groupRelays"
|
||||
is GroupRelaysAdded -> "groupRelaysAdded"
|
||||
is GroupRelaysAddFailed -> "groupRelaysAddFailed"
|
||||
is SentGroupInvitation -> "sentGroupInvitation"
|
||||
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
|
||||
is GroupLinkConnecting -> "groupLinkConnecting"
|
||||
@@ -6773,6 +6808,8 @@ sealed class CR {
|
||||
is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
|
||||
is PublicGroupCreationFailed -> withUser(user, "addRelayResults: $addRelayResults")
|
||||
is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays")
|
||||
is GroupRelaysAdded -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
|
||||
is GroupRelaysAddFailed -> withUser(user, "addRelayResults: $addRelayResults")
|
||||
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
|
||||
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
|
||||
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
|
||||
|
||||
+8
-3
@@ -211,7 +211,7 @@ fun ChatView(
|
||||
withContext(Dispatchers.Main) {
|
||||
ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays)
|
||||
}
|
||||
} else {
|
||||
} else if (cInfo.groupInfo.membership.memberCurrent) {
|
||||
val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId)
|
||||
if (gInfo != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -317,6 +317,7 @@ fun ChatView(
|
||||
itemIds.sorted(),
|
||||
questionText = questionText,
|
||||
forAll = canDeleteForAll,
|
||||
editorial = publicGroupEditor(chatInfo),
|
||||
deleteMessages = { ids, forAll ->
|
||||
deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) {
|
||||
selectedChatItems.value = null
|
||||
@@ -3351,7 +3352,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List<Long
|
||||
id = chatInfo.apiId,
|
||||
scope = chatInfo.groupChatScope(),
|
||||
itemIds = itemIds,
|
||||
mode = if (forAll) CIDeleteMode.cidmBroadcast else CIDeleteMode.cidmInternal
|
||||
mode = if (forAll) CIDeleteMode.cidmBroadcast
|
||||
else if (publicGroupEditor(chatInfo)) CIDeleteMode.cidmHistory
|
||||
else CIDeleteMode.cidmInternal
|
||||
)
|
||||
}
|
||||
if (deleted != null) {
|
||||
@@ -3597,7 +3600,6 @@ fun providerForGallery(
|
||||
|
||||
override fun scrollToStart() {
|
||||
initialIndex = 0
|
||||
initialChatId = chatItems.firstOrNull { canShowMedia(it) }?.id ?: return
|
||||
}
|
||||
|
||||
override fun onDismiss(index: Int) {
|
||||
@@ -3614,6 +3616,9 @@ fun providerForGallery(
|
||||
|
||||
typealias ChatViewItemKey = Pair<Long, Long>
|
||||
|
||||
fun publicGroupEditor(chatInfo: ChatInfo): Boolean =
|
||||
chatInfo is ChatInfo.Group && chatInfo.groupInfo.useRelays && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator
|
||||
|
||||
private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds())
|
||||
|
||||
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
|
||||
|
||||
+22
-6
@@ -1543,14 +1543,14 @@ fun ComposeView(
|
||||
) {
|
||||
if (gInfo.membership.memberRole == GroupMemberRole.Owner) {
|
||||
ownerRelayState?.let { s ->
|
||||
if (s.activeCount < s.relays.size) {
|
||||
if (s.relays.isEmpty() || s.activeCount < s.relays.size) {
|
||||
OwnerChannelRelayBar(chatModel, s.relays, s.activeCount, s.failedCount, s.removedCount, relayListExpanded)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted()
|
||||
val relayMembers = chatModel.groupMembers.value
|
||||
.filter { it.memberRole == GroupMemberRole.Relay }
|
||||
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) }
|
||||
.sortedBy { hostFromRelayLink(it.relayLink ?: "") }
|
||||
val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress
|
||||
val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) }
|
||||
@@ -1558,7 +1558,7 @@ fun ComposeView(
|
||||
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
|
||||
val resolvedCount = connectedCount + removedCount + failedCount
|
||||
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
|
||||
if (total > 0 && (removedCount + failedCount > 0 || resolvedCount < total)) {
|
||||
if (total == 0 || removedCount + failedCount > 0 || resolvedCount < total) {
|
||||
SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, showProgress, relayListExpanded)
|
||||
}
|
||||
}
|
||||
@@ -1756,7 +1756,15 @@ private fun OwnerChannelRelayBar(
|
||||
if (!allBroken && activeCount + failedCount + removedCount < total) {
|
||||
RelayProgressIndicator(active = activeCount, total = total)
|
||||
}
|
||||
if (allBroken) {
|
||||
if (total == 0) {
|
||||
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_warning),
|
||||
contentDescription = null,
|
||||
tint = WarningOrange,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
} else if (allBroken) {
|
||||
val statusText = if (removedCount == total) {
|
||||
generalGetString(MR.strings.relay_bar_all_relays_removed)
|
||||
} else if (failedCount == total) {
|
||||
@@ -1842,7 +1850,15 @@ private fun SubscriberChannelRelayBar(
|
||||
val allBroken = connectedCount == 0 && errorCount == total
|
||||
Column(Modifier.background(MaterialTheme.colors.surface)) {
|
||||
RelayBarHeader(relayListExpanded) {
|
||||
if (allBroken) {
|
||||
if (total == 0) {
|
||||
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_warning),
|
||||
contentDescription = null,
|
||||
tint = WarningOrange,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
} else if (allBroken) {
|
||||
val statusText = if (removedCount == total) {
|
||||
generalGetString(MR.strings.relay_bar_all_relays_removed)
|
||||
} else if (failedCount == total) {
|
||||
@@ -1990,7 +2006,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState?
|
||||
gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
|
||||
) return null
|
||||
val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList()
|
||||
if (relays.isEmpty()) return null
|
||||
if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true)
|
||||
val relayMembers = relays.map { relay ->
|
||||
relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId }
|
||||
}
|
||||
|
||||
+36
-10
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.itemPrefixText
|
||||
import chat.simplex.common.views.chat.item.itemSegmentDisplayText
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
@@ -52,8 +53,10 @@ val LocalItemContext = compositionLocalOf { ItemContext() }
|
||||
|
||||
data class SelectionRange(
|
||||
val startIndex: Int,
|
||||
val startItemId: Long,
|
||||
val startOffset: Int,
|
||||
val endIndex: Int,
|
||||
val endItemId: Long,
|
||||
val endOffset: Int
|
||||
)
|
||||
|
||||
@@ -79,11 +82,13 @@ class SelectionManager {
|
||||
var viewportPosition by mutableStateOf(Offset.Zero)
|
||||
var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item
|
||||
var listState: State<LazyListState>? = null
|
||||
var mergedItemsState: State<MergedItems>? = null
|
||||
var onCopySelection: (() -> Unit)? = null
|
||||
private var autoScrollJob: Job? = null
|
||||
|
||||
fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) {
|
||||
range = SelectionRange(startIndex, -1, startIndex, -1)
|
||||
val id = mergedItemsState?.value?.items?.getOrNull(startIndex)?.newest()?.item?.id ?: return
|
||||
range = SelectionRange(startIndex, id, -1, startIndex, id, -1)
|
||||
selectionState = SelectionState.Selecting
|
||||
anchorWindowY = anchorY
|
||||
anchorWindowX = anchorX
|
||||
@@ -96,7 +101,8 @@ class SelectionManager {
|
||||
|
||||
fun updateFocusIndex(index: Int) {
|
||||
val r = range ?: return
|
||||
range = r.copy(endIndex = index)
|
||||
val id = mergedItemsState?.value?.items?.getOrNull(index)?.newest()?.item?.id ?: return
|
||||
range = r.copy(endIndex = index, endItemId = id)
|
||||
}
|
||||
|
||||
fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) {
|
||||
@@ -175,6 +181,15 @@ class SelectionManager {
|
||||
updateFocusIndex(idx)
|
||||
}
|
||||
|
||||
fun resyncIndices() {
|
||||
val r = range ?: return
|
||||
val items = mergedItemsState?.value?.items ?: return
|
||||
val newStartIndex = items.indexOfFirst { it.newest().item.id == r.startItemId }
|
||||
val newEndIndex = items.indexOfFirst { it.newest().item.id == r.endItemId }
|
||||
if (newStartIndex < 0 || newEndIndex < 0) clearSelection()
|
||||
else range = r.copy(startIndex = newStartIndex, endIndex = newEndIndex)
|
||||
}
|
||||
|
||||
fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) {
|
||||
val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop
|
||||
if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) {
|
||||
@@ -240,15 +255,22 @@ fun selectedRange(range: SelectionRange?, index: Int): IntRange? {
|
||||
}
|
||||
|
||||
// Extracts source text for the selected range within one item.
|
||||
// Selection offsets are in display-text space. For transformed segments (mentions, links with showText),
|
||||
// the full source is emitted if any part is selected. For untransformed segments, partial substring works.
|
||||
// Selection offsets are in display-text space (which includes any leading itemPrefixText).
|
||||
// For transformed segments (mentions, links with showText), the full source is emitted if any part
|
||||
// is selected. For untransformed segments, partial substring works.
|
||||
private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String {
|
||||
val formattedText = ci.formattedText ?: return ci.text.substring(
|
||||
sel.first.coerceAtMost(ci.text.length),
|
||||
(sel.last + 1).coerceAtMost(ci.text.length)
|
||||
)
|
||||
val prefix = itemPrefixText(ci)
|
||||
val sb = StringBuilder()
|
||||
var displayOffset = 0
|
||||
if (sel.first < prefix.length) {
|
||||
sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1))
|
||||
}
|
||||
val formattedText = ci.formattedText ?: run {
|
||||
val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length)
|
||||
val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length)
|
||||
if (start < end) sb.append(ci.text, start, end)
|
||||
return sb.toString()
|
||||
}
|
||||
var displayOffset = prefix.length
|
||||
for (ft in formattedText) {
|
||||
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
|
||||
val displayEnd = displayOffset + segDisplay.length
|
||||
@@ -269,7 +291,7 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
|
||||
// Snaps a boundary offset to include full transformed segments.
|
||||
private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int {
|
||||
val formattedText = ci.formattedText ?: return offset
|
||||
var displayOffset = 0
|
||||
var displayOffset = itemPrefixText(ci).length
|
||||
for (ft in formattedText) {
|
||||
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
|
||||
val displayEnd = displayOffset + segDisplay.length
|
||||
@@ -312,11 +334,15 @@ fun BoxScope.SelectionHandler(
|
||||
}
|
||||
|
||||
manager.listState = listState
|
||||
manager.mergedItemsState = mergedItems
|
||||
manager.onCopySelection = {
|
||||
clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, revealedItems.value, linkMode)))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
|
||||
// Resync after the items list mutates (new message arrives, item deleted).
|
||||
SideEffect { manager.resyncIndices() }
|
||||
|
||||
return Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
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.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.helpers.*
|
||||
import chat.simplex.common.views.newchat.chatRelayDisplayName
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AvailableRelay(
|
||||
val relayId: Long,
|
||||
val relay: UserChatRelay,
|
||||
val operatorName: String?
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AddGroupRelayView(
|
||||
groupInfo: GroupInfo,
|
||||
existingRelayIds: Set<Long>,
|
||||
onRelayAdded: () -> Unit,
|
||||
close: () -> Unit
|
||||
) {
|
||||
var availableRelays by remember { mutableStateOf<List<AvailableRelay>>(emptyList()) }
|
||||
var selectedRelayIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isAdding by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(onBack = close)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val servers = ChatController.getUserServers(null)
|
||||
if (servers != null) {
|
||||
val relays = mutableListOf<AvailableRelay>()
|
||||
for (op in servers) {
|
||||
if (op.operator != null && op.operator.enabled != true) continue
|
||||
val opName: String? = if (op.operator?.operatorTag != null) op.operator.tradeName else null
|
||||
for (relay in op.chatRelays) {
|
||||
val relayId = relay.chatRelayId
|
||||
if (relay.enabled && !relay.deleted && relayId != null && relayId !in existingRelayIds) {
|
||||
relays.add(AvailableRelay(relayId, relay, opName))
|
||||
}
|
||||
}
|
||||
}
|
||||
availableRelays = relays
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadAvailableRelays error: ${e.message}")
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
AddGroupRelayLayout(
|
||||
availableRelays = availableRelays,
|
||||
selectedRelayIds = selectedRelayIds,
|
||||
isLoading = isLoading,
|
||||
isAdding = isAdding,
|
||||
onToggleRelay = { relayId ->
|
||||
selectedRelayIds = if (relayId in selectedRelayIds) selectedRelayIds - relayId else selectedRelayIds + relayId
|
||||
},
|
||||
onAddRelays = {
|
||||
val relayIds = selectedRelayIds.toList()
|
||||
if (relayIds.isEmpty()) return@AddGroupRelayLayout
|
||||
isAdding = true
|
||||
scope.launch {
|
||||
addSelectedRelays(groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays ->
|
||||
selectedRelayIds = newSelectedIds
|
||||
availableRelays = newAvailableRelays
|
||||
isAdding = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddGroupRelayLayout(
|
||||
availableRelays: List<AvailableRelay>,
|
||||
selectedRelayIds: Set<Long>,
|
||||
isLoading: Boolean,
|
||||
isAdding: Boolean,
|
||||
onToggleRelay: (Long) -> Unit,
|
||||
onAddRelays: () -> Unit
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(generalGetString(MR.strings.add_relays_title))
|
||||
|
||||
if (isLoading) {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (availableRelays.isEmpty()) {
|
||||
SectionView {
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Text(
|
||||
generalGetString(MR.strings.no_available_relays),
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SectionView {
|
||||
AddRelaysButton(
|
||||
onClick = onAddRelays,
|
||||
disabled = selectedRelayIds.isEmpty() || isAdding
|
||||
)
|
||||
}
|
||||
SectionCustomFooter {
|
||||
val count = selectedRelayIds.size
|
||||
Text(
|
||||
if (count == 0) generalGetString(MR.strings.no_relays_selected)
|
||||
else String.format(generalGetString(MR.strings.num_relays_selected), count),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
lineHeight = 18.sp,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(generalGetString(MR.strings.select_relays).uppercase()) {
|
||||
availableRelays.forEach { item ->
|
||||
val selected = item.relayId in selectedRelayIds
|
||||
SectionItemView(
|
||||
click = { onToggleRelay(item.relayId) },
|
||||
padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp)
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
chatRelayDisplayName(item.relay),
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
if (item.operatorName != null) {
|
||||
Text(
|
||||
item.operatorName,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(
|
||||
painterResource(if (selected) MR.images.ic_check_circle_filled else MR.images.ic_circle),
|
||||
contentDescription = null,
|
||||
tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddRelaysButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
generalGetString(MR.strings.add_relays_title),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = disabled,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun addSelectedRelays(
|
||||
groupInfo: GroupInfo,
|
||||
relayIds: List<Long>,
|
||||
selectedRelayIds: Set<Long>,
|
||||
availableRelays: List<AvailableRelay>,
|
||||
onRelayAdded: () -> Unit,
|
||||
close: () -> Unit,
|
||||
updateState: (Set<Long>, List<AvailableRelay>) -> Unit
|
||||
) {
|
||||
try {
|
||||
val result = ChatController.apiAddGroupRelays(groupInfo.groupId, relayIds)
|
||||
if (result == null) {
|
||||
updateState(selectedRelayIds, availableRelays)
|
||||
return
|
||||
}
|
||||
when (result) {
|
||||
is ChatController.AddGroupRelaysResult.Added -> {
|
||||
ChannelRelaysModel.set(groupId = result.groupInfo.groupId, groupRelays = result.groupRelays)
|
||||
onRelayAdded()
|
||||
close()
|
||||
}
|
||||
is ChatController.AddGroupRelaysResult.AddFailed -> {
|
||||
val results = result.addRelayResults
|
||||
val successIds = results.filter { it.relayError == null }.mapNotNull { it.relay.chatRelayId }.toSet()
|
||||
var newSelectedIds = selectedRelayIds
|
||||
var newAvailableRelays = availableRelays
|
||||
if (successIds.isNotEmpty()) {
|
||||
newSelectedIds = selectedRelayIds - successIds
|
||||
newAvailableRelays = availableRelays.filter { it.relayId !in successIds }
|
||||
onRelayAdded()
|
||||
}
|
||||
val errorLines = results.filter { it.relayError != null }
|
||||
.map { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: ""}" }
|
||||
val successNames = results.filter { it.relayError == null }
|
||||
.map { chatRelayDisplayName(it.relay) }
|
||||
var msg = errorLines.joinToString("\n")
|
||||
if (successNames.isNotEmpty()) {
|
||||
msg += "\n" + String.format(generalGetString(MR.strings.relays_added_format), successNames.joinToString(", "))
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_adding_relays),
|
||||
text = msg
|
||||
)
|
||||
updateState(newSelectedIds, newAvailableRelays)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_adding_relays),
|
||||
text = e.message ?: ""
|
||||
)
|
||||
updateState(selectedRelayIds, availableRelays)
|
||||
}
|
||||
}
|
||||
+52
-4
@@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionItemViewLongClickable
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -16,9 +17,11 @@ 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.chat.item.ItemAction
|
||||
import chat.simplex.common.views.chatlist.setGroupMembers
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
||||
@Composable
|
||||
fun ChannelRelaysView(
|
||||
@@ -29,16 +32,18 @@ fun ChannelRelaysView(
|
||||
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
var groupRelays by remember { mutableStateOf<List<GroupRelay>>(emptyList()) }
|
||||
val groupRelays = ChannelRelaysModel.groupRelays
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
setGroupMembers(rhId, groupInfo, chatModel)
|
||||
if (groupInfo.isOwner) {
|
||||
groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
|
||||
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
|
||||
ChannelRelaysModel.set(groupId = groupInfo.groupId, groupRelays = relays)
|
||||
}
|
||||
}
|
||||
|
||||
ChannelRelaysLayout(
|
||||
rhId = rhId,
|
||||
groupInfo = groupInfo,
|
||||
chatModel = chatModel,
|
||||
groupRelays = groupRelays,
|
||||
@@ -48,13 +53,14 @@ fun ChannelRelaysView(
|
||||
|
||||
@Composable
|
||||
private fun ChannelRelaysLayout(
|
||||
rhId: Long?,
|
||||
groupInfo: GroupInfo,
|
||||
chatModel: ChatModel,
|
||||
groupRelays: List<GroupRelay>,
|
||||
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
|
||||
) {
|
||||
val relayMembers = remember { chatModel.groupMembers }.value
|
||||
.filter { it.memberRole == GroupMemberRole.Relay }
|
||||
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus != GroupMemberStatus.MemRemoved && it.memberStatus != GroupMemberStatus.MemGroupDeleted }
|
||||
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(generalGetString(MR.strings.channel_relays_title))
|
||||
@@ -74,11 +80,24 @@ private fun ChannelRelaysLayout(
|
||||
if (index > 0) {
|
||||
Divider()
|
||||
}
|
||||
SectionItemView(
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
SectionItemViewLongClickable(
|
||||
click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) },
|
||||
longClick = { showMenu.value = true },
|
||||
minHeight = 54.dp,
|
||||
padding = PaddingValues(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
/*
|
||||
if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
|
||||
removeMemberAlert(rhId, groupInfo, member)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
val statusText = if (groupInfo.isOwner) {
|
||||
ownerRelayStatusText(member, groupRelays)
|
||||
} else {
|
||||
@@ -90,6 +109,35 @@ private fun ChannelRelaysLayout(
|
||||
}
|
||||
SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages))
|
||||
}
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
/*
|
||||
if (groupInfo.isOwner) {
|
||||
SectionView {
|
||||
SectionItemView(click = {
|
||||
val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
|
||||
ModalManager.end.showModalCloseable(true) { close ->
|
||||
AddGroupRelayView(
|
||||
groupInfo = groupInfo,
|
||||
existingRelayIds = existingRelayIds,
|
||||
onRelayAdded = { withBGApi { setGroupMembers(rhId, groupInfo, chatModel) } },
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_add),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
generalGetString(MR.strings.add_relay_button),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
+79
-30
@@ -239,39 +239,88 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
|
||||
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(titleId),
|
||||
generalGetString(messageId),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
|
||||
if (mem.memberRole == GroupMemberRole.Relay) {
|
||||
val isLastActive = groupInfo.useRelays && mem.memberCurrent && run {
|
||||
val activeRelays = ChatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
|
||||
activeRelays.size <= 1
|
||||
}
|
||||
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
|
||||
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_relay_question),
|
||||
message,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
})
|
||||
} else if (groupInfo.useRelays) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_subscriber_question),
|
||||
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
})
|
||||
} else {
|
||||
val titleId = MR.strings.button_remove_member_question
|
||||
val messageId = 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(titleId),
|
||||
generalGetString(messageId),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
|
||||
|
||||
+7
-2
@@ -281,8 +281,13 @@ fun GroupLinkLayout(
|
||||
)
|
||||
}
|
||||
if (creatingGroup && close != null) {
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
ContinueButton(close)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.continue_to_next_step),
|
||||
click = close,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-30
@@ -242,34 +242,86 @@ fun GroupMemberInfoView(
|
||||
}
|
||||
|
||||
fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
val messageId = 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(messageId),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
if (member.memberRole == GroupMemberRole.Relay) {
|
||||
val isLastActive = groupInfo.useRelays && run {
|
||||
val activeRelays = chatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
|
||||
activeRelays.size <= 1
|
||||
}
|
||||
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
|
||||
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_relay_question),
|
||||
message,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
})
|
||||
} else if (groupInfo.useRelays) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_subscriber_question),
|
||||
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
})
|
||||
} else {
|
||||
val messageId = 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(messageId),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
@@ -368,6 +420,7 @@ fun GroupMemberInfoLayout(
|
||||
@Composable
|
||||
fun ModeratorDestructiveSection() {
|
||||
val canBlockForAll = member.canBlockForAll(groupInfo)
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay
|
||||
if (canBlockForAll || canRemove) {
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
@@ -380,10 +433,10 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
if (canRemove) {
|
||||
if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) {
|
||||
if (member.memberStatus != GroupMemberStatus.MemRemoved && (member.memberStatus != GroupMemberStatus.MemLeft || member.memberRole == GroupMemberRole.Relay)) {
|
||||
RemoveMemberButton(groupInfo.useRelays, member.memberRole == GroupMemberRole.Relay, removeMember)
|
||||
} else if (member.memberRole != GroupMemberRole.Relay) {
|
||||
DeleteMemberMessagesButton(deleteMemberMessages)
|
||||
} else {
|
||||
RemoveMemberButton(groupInfo.useRelays, removeMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -753,8 +806,10 @@ fun UnblockForAllButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) {
|
||||
val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member
|
||||
fun RemoveMemberButton(useRelays: Boolean = false, isRelay: Boolean = false, onClick: () -> Unit) {
|
||||
val label = if (isRelay) MR.strings.button_remove_relay
|
||||
else if (useRelays) MR.strings.button_remove_subscriber
|
||||
else MR.strings.button_remove_member
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_delete),
|
||||
stringResource(label),
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ fun CIImageView(
|
||||
.then(
|
||||
if (!smallView) {
|
||||
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
|
||||
Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat())
|
||||
Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceAtLeast(1f / 2.33f))
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
|
||||
+31
-21
@@ -374,7 +374,7 @@ fun ChatItemView(
|
||||
@Composable
|
||||
fun DeleteItemMenu() {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -392,7 +392,7 @@ fun ChatItemView(
|
||||
if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
|
||||
ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports)
|
||||
}
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report))
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report))
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
}
|
||||
@@ -482,7 +482,7 @@ fun ChatItemView(
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||
}
|
||||
if (!(live && cItem.meta.isLive) && !preview) {
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
}
|
||||
if (cItem.chatDir !is CIDirection.GroupSnd) {
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
@@ -508,7 +508,7 @@ fun ChatItemView(
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -518,7 +518,7 @@ fun ChatItemView(
|
||||
cItem.isDeletedContent -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -532,7 +532,7 @@ fun ChatItemView(
|
||||
} else {
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -541,7 +541,7 @@ fun ChatItemView(
|
||||
}
|
||||
else -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (selectedChatItems.value == null) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -558,7 +558,7 @@ fun ChatItemView(
|
||||
RevealItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -587,7 +587,7 @@ fun ChatItemView(
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -661,7 +661,7 @@ fun ChatItemView(
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -866,6 +866,7 @@ fun ItemInfoAction(
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
chatsCtx: ChatModel.ChatsContext,
|
||||
cInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
revealed: State<Boolean>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
@@ -898,13 +899,13 @@ fun DeleteItemAction(
|
||||
deleteMessages = { ids, _ -> deleteMessages(ids) }
|
||||
)
|
||||
} else {
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage)
|
||||
}
|
||||
} else {
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage)
|
||||
}
|
||||
} else {
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage)
|
||||
}
|
||||
},
|
||||
color = Color.Red
|
||||
@@ -1371,7 +1372,9 @@ fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, chatInfo: ChatInfo, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
val canDeleteForEveryone = chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport
|
||||
val editorial = publicGroupEditor(chatInfo)
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(MR.strings.delete_message__question),
|
||||
text = questionText,
|
||||
@@ -1382,11 +1385,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
|
||||
if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) {
|
||||
if (editorial) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmHistory)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(MR.strings.from_history), color = MaterialTheme.colors.error) }
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
|
||||
}
|
||||
if (canDeleteForEveryone) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
@@ -1398,7 +1408,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll: Boolean, deleteMessages: (List<Long>, Boolean) -> Unit) {
|
||||
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll: Boolean, editorial: Boolean = false, deleteMessages: (List<Long>, Boolean) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size),
|
||||
text = questionText,
|
||||
@@ -1412,7 +1422,7 @@ fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll:
|
||||
TextButton(onClick = {
|
||||
deleteMessages(itemIds, false)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
|
||||
}) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) }
|
||||
|
||||
if (forAll) {
|
||||
TextButton(onClick = {
|
||||
|
||||
+1
-1
@@ -365,7 +365,7 @@ fun FramedItemView(
|
||||
is MsgContent.MCReport -> {
|
||||
val prefix = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
|
||||
append(itemPrefixText(ci))
|
||||
}
|
||||
}
|
||||
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
|
||||
|
||||
+7
@@ -85,6 +85,13 @@ fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String {
|
||||
return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) }
|
||||
}
|
||||
|
||||
// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports).
|
||||
// Renderers and selection code MUST share this string — otherwise selection offsets drift from screen.
|
||||
fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCReport -> if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: "
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// Text transformations in MarkdownText must match itemSegmentDisplayText above
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
|
||||
+2
-2
@@ -255,11 +255,11 @@ fun ChatPreviewView(
|
||||
ci.content.msgContent is MsgContent.MCChat -> null
|
||||
else -> ci.formattedText
|
||||
}
|
||||
val prefix = when (val mc = ci.content.msgContent) {
|
||||
val prefix = when (ci.content.msgContent) {
|
||||
is MsgContent.MCReport ->
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
|
||||
append(itemPrefixText(ci))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -404,11 +404,6 @@ private fun ProgressStepView(
|
||||
ModalView(
|
||||
close = { showCancelAlert() },
|
||||
showClose = false,
|
||||
endButtons = {
|
||||
TextButton(onClick = { showCancelAlert() }) {
|
||||
Text(generalGetString(MR.strings.button_delete_channel))
|
||||
}
|
||||
}
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(generalGetString(MR.strings.creating_channel))
|
||||
@@ -481,9 +476,16 @@ private fun ProgressStepView(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_delete),
|
||||
generalGetString(MR.strings.button_cancel_and_delete_channel),
|
||||
click = { showCancelAlert() },
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
val enabled = activeCount > 0
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_link),
|
||||
painterResource(MR.images.ic_check),
|
||||
generalGetString(MR.strings.continue_to_next_step),
|
||||
click = {
|
||||
if (activeCount >= total) {
|
||||
@@ -586,7 +588,7 @@ fun relayDisplayName(relay: GroupRelay): String {
|
||||
return "relay ${relay.groupRelayId}"
|
||||
}
|
||||
|
||||
private fun chatRelayDisplayName(relay: UserChatRelay): String {
|
||||
fun chatRelayDisplayName(relay: UserChatRelay): String {
|
||||
if (relay.displayName.isNotEmpty()) return relay.displayName
|
||||
return relay.address
|
||||
}
|
||||
@@ -595,7 +597,7 @@ private fun chatRelayDisplayName(relay: UserChatRelay): String {
|
||||
fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) {
|
||||
val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
|
||||
val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow
|
||||
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else status.text
|
||||
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
|
||||
+2
-6
@@ -295,14 +295,10 @@ fun ChatLockItem(
|
||||
}
|
||||
|
||||
private fun resetHintPreferences() {
|
||||
for ((pref, def) in appPreferences.hintPreferences) {
|
||||
pref.set(def)
|
||||
}
|
||||
appPreferences.hintPreferences.forEach { it.reset() }
|
||||
}
|
||||
|
||||
fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { (pref, def) ->
|
||||
pref.state.value == def
|
||||
}
|
||||
fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { it.isUnchanged() }
|
||||
|
||||
@Composable
|
||||
fun AppVersionItem(showVersion: () -> Unit) {
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
<string name="error_deleting_group">Error deleting group</string>
|
||||
<string name="error_deleting_note_folder">Error deleting private notes</string>
|
||||
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
||||
<string name="error_deleting_message">Error deleting message</string>
|
||||
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
||||
<string name="error_changing_address">Error changing address</string>
|
||||
<string name="error_aborting_address_change">Error aborting address change</string>
|
||||
@@ -424,6 +425,7 @@
|
||||
<string name="moderate_messages_will_be_marked_warning">The messages will be marked as moderated for all members.</string>
|
||||
<string name="for_me_only">Delete for me</string>
|
||||
<string name="for_everybody">For everyone</string>
|
||||
<string name="from_history">From history</string>
|
||||
<string name="stop_file__action">Stop file</string>
|
||||
<string name="stop_snd_file__title">Stop sending file?</string>
|
||||
<string name="stop_snd_file__message">Sending file will be stopped.</string>
|
||||
@@ -1887,6 +1889,7 @@
|
||||
<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_cancel_and_delete_channel">Cancel and 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>
|
||||
@@ -2985,6 +2988,7 @@
|
||||
<string name="relay_conn_status_deleted">deleted</string>
|
||||
<string name="relay_conn_status_failed">failed</string>
|
||||
<string name="relay_conn_status_removed_by_operator">removed by operator</string>
|
||||
<string name="relay_conn_status_removed">removed</string>
|
||||
<string name="relay_status_new">new</string>
|
||||
<string name="relay_status_invited">invited</string>
|
||||
<string name="relay_status_accepted">accepted</string>
|
||||
@@ -3006,7 +3010,8 @@
|
||||
<string name="relay_bar_connected_with_failures">%1$d/%2$d relays connected, %3$d failed</string>
|
||||
<string name="relay_bar_connected_with_removed">%1$d/%2$d relays connected, %3$d removed</string>
|
||||
<string name="relay_bar_connected">%1$d/%2$d relays connected</string>
|
||||
<string name="relay_bar_owner_no_delivery">Adding relays will be supported later.</string>
|
||||
<string name="relay_bar_no_relays">No relays</string>
|
||||
<string name="relay_bar_owner_no_delivery">Add relays to restore message delivery.</string>
|
||||
<string name="relay_bar_subscriber_waiting">Waiting for channel owner to add relays.</string>
|
||||
|
||||
<!-- GroupMemberInfoView.kt channel-related -->
|
||||
@@ -3021,6 +3026,10 @@
|
||||
<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="button_remove_relay">Remove relay</string>
|
||||
<string name="button_remove_relay_question">Remove relay?</string>
|
||||
<string name="relay_will_be_removed_from_channel">Relay will be removed from channel - this cannot be undone!</string>
|
||||
<string name="last_active_relay_warning">This is the last active relay. Removing it will prevent message delivery to subscribers.</string>
|
||||
<string name="block_subscriber_for_all_question">Block subscriber for all?</string>
|
||||
|
||||
<!-- AddChannelView.kt -->
|
||||
@@ -3040,6 +3049,15 @@
|
||||
<string name="your_profile_shared_with_channel_relays">Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages.</string>
|
||||
<string name="configure_relays">Configure relays</string>
|
||||
<string name="relay_status_failed">failed</string>
|
||||
<string name="add_button">Add</string>
|
||||
<string name="add_relay_button">Add relay</string>
|
||||
<string name="add_relays_title">Add relays</string>
|
||||
<string name="no_available_relays">No available relays</string>
|
||||
<string name="error_adding_relays">Error adding relays</string>
|
||||
<string name="relays_added_format">Relays added: %1$s.</string>
|
||||
<string name="select_relays">Select relays</string>
|
||||
<string name="no_relays_selected">No relays selected</string>
|
||||
<string name="num_relays_selected">%d relay(s) selected</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>
|
||||
@@ -3063,4 +3081,16 @@
|
||||
<string name="link_previews_alert_desc_socks">Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver.</string>
|
||||
<string name="link_previews_alert_enable">Enable</string>
|
||||
<string name="link_previews_alert_disable">Disable</string>
|
||||
|
||||
<!-- Desktop tray / minimize-to-tray -->
|
||||
<string name="close_behavior_dialog_title">Minimize to tray?</string>
|
||||
<string name="close_behavior_dialog_text">If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings.</string>
|
||||
<string name="close_behavior_dialog_close">Close the app</string>
|
||||
<string name="close_behavior_dialog_minimize">Minimize to tray</string>
|
||||
<string name="tray_show">Show SimpleX</string>
|
||||
<string name="tray_quit">Quit SimpleX</string>
|
||||
<string name="tray_tooltip">SimpleX</string>
|
||||
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
|
||||
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
|
||||
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="121 0 40 40"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
|
||||
fill="#030749"
|
||||
id="path1"
|
||||
style="stroke-width:0.866122" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
|
||||
fill="url(#paint0_linear_40_164)"
|
||||
id="path2"
|
||||
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
|
||||
<!-- Unread dot in bottom-right; cy ≤ 34 to keep it inside the 40×40 viewBox bottom edge -->
|
||||
<circle cx="155" cy="34" r="6" fill="#e53935" />
|
||||
<defs
|
||||
id="defs3">
|
||||
<linearGradient
|
||||
x1="135.948"
|
||||
y1="-0.81632602"
|
||||
x2="132.09599"
|
||||
y2="36.985699"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_40_164"
|
||||
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
|
||||
<stop
|
||||
stop-color="#01f1ff"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#0197ff"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
+46
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="121 0 40 40"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
|
||||
fill="#ffffff"
|
||||
id="path1"
|
||||
style="stroke-width:0.866122" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
|
||||
fill="url(#paint0_linear_40_164)"
|
||||
id="path2"
|
||||
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
|
||||
<!-- Unread dot in bottom-right; cy ≤ 34 to keep it inside the 40×40 viewBox bottom edge -->
|
||||
<circle cx="155" cy="34" r="6" fill="#e53935" />
|
||||
<defs
|
||||
id="defs3">
|
||||
<linearGradient
|
||||
x1="135.948"
|
||||
y1="-0.81632602"
|
||||
x2="132.09599"
|
||||
y2="36.985699"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_40_164"
|
||||
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
|
||||
<stop
|
||||
stop-color="#01f1ff"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#0197ff"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
+44
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="121 0 40 40"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 126.52238,11.425398 5.80302,5.716401 5.88962,-5.889626 2.8582,2.858201 L 135.1836,20 l 5.7164,5.716402 -2.94482,2.8582 -5.7164,-5.629789 -5.88962,5.803014 -2.8582,-2.858201 5.88962,-5.803014 -5.803,-5.716402 z"
|
||||
fill="#ffffff"
|
||||
id="path1"
|
||||
style="stroke-width:0.866122" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 137.86858,28.661214 2.94481,-2.944812 v 0 l 5.88963,-5.803014 -5.80302,-5.62979 v 0 l -2.8582,-2.8582 -5.7164,-5.7164023 2.94481,-2.9448129 5.7164,5.7164017 5.88963,-5.8030138 2.8582,2.8582008 -5.88962,5.8030145 5.7164,5.716401 5.88962,-5.803014 2.8582,2.858201 -5.88962,5.803014 5.803,5.716402 -2.9448,2.858201 -5.7164,-5.716402 -5.88963,5.803013 5.7164,5.716402 -2.8582,2.944813 -5.80301,-5.716402 -5.80302,5.803015 -2.8582,-2.858201 z"
|
||||
fill="url(#paint0_linear_40_164)"
|
||||
id="path2"
|
||||
style="fill:url(#paint0_linear_40_164);stroke-width:0.866122" />
|
||||
<defs
|
||||
id="defs3">
|
||||
<linearGradient
|
||||
x1="135.948"
|
||||
y1="-0.81632602"
|
||||
x2="132.09599"
|
||||
y2="36.985699"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_40_164"
|
||||
gradientTransform="matrix(0.86612147,0,0,0.86612147,18.863485,2.6775707)">
|
||||
<stop
|
||||
stop-color="#01f1ff"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#0197ff"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
+67
@@ -0,0 +1,67 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.views.chat.providerForGallery
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
// Regression for PR #6869: scrollToStart() must not rewrite initialChatId.
|
||||
class ProviderForGalleryTest {
|
||||
|
||||
// Synthetic items pass canShowMedia only when chatModel.connectedToRemote() is true.
|
||||
@BeforeTest
|
||||
fun connectChatModelToRemote() {
|
||||
chatModel.currentRemoteHost.value = RemoteHostInfo(
|
||||
remoteHostId = 0L,
|
||||
hostDeviceName = "",
|
||||
storePath = "",
|
||||
bindAddress_ = null,
|
||||
bindPort_ = null,
|
||||
sessionState = null,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun resetChatModel() {
|
||||
chatModel.currentRemoteHost.value = null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScrollToStartPreservesAnchor() {
|
||||
val items = listOf(imageItem(1L), imageItem(2L), imageItem(3L))
|
||||
var scrolledTo: Int? = null
|
||||
val provider = providerForGallery(items, cItemId = 3L) { scrolledTo = it }
|
||||
|
||||
provider.currentPageChanged(provider.initialIndex - 1)
|
||||
provider.scrollToStart()
|
||||
provider.onDismiss(0)
|
||||
|
||||
assertEquals(1, scrolledTo)
|
||||
}
|
||||
|
||||
// Pins the onDismiss early-return contract that testScrollToStartPreservesAnchor
|
||||
// relies on to read the anchor back through the scrollTo callback.
|
||||
@Test
|
||||
fun testOnDismissOnActiveItemDoesNotScroll() {
|
||||
val items = listOf(imageItem(1L), imageItem(2L), imageItem(3L))
|
||||
var scrolledTo: Int? = null
|
||||
val provider = providerForGallery(items, cItemId = 3L) { scrolledTo = it }
|
||||
|
||||
provider.onDismiss(provider.initialIndex)
|
||||
|
||||
assertEquals(null, scrolledTo)
|
||||
}
|
||||
|
||||
private fun imageItem(id: Long): ChatItem =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(id, Clock.System.now(), text = ""),
|
||||
content = CIContent.RcvMsgContent(MsgContent.MCImage(text = "", image = "")),
|
||||
reactions = emptyList(),
|
||||
file = CIFile.getSample(fileId = id, fileName = "img-$id.jpg", filePath = "img-$id.jpg"),
|
||||
)
|
||||
}
|
||||
@@ -31,8 +31,11 @@ import kotlin.system.exitProcess
|
||||
val simplexWindowState = SimplexWindowState()
|
||||
|
||||
fun showApp() {
|
||||
val closedByError = mutableStateOf(true)
|
||||
while (closedByError.value) {
|
||||
// Probe SystemTray off the EDT — the lazy's first read would otherwise block the
|
||||
// EDT during composition; JDK-8322750's GNOME detection forks a subprocess.
|
||||
trayIsAvailable
|
||||
while (true) {
|
||||
val closedByError = mutableStateOf(false)
|
||||
application(exitProcessOnExit = false) {
|
||||
CompositionLocalProvider(
|
||||
LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window ->
|
||||
@@ -43,8 +46,9 @@ fun showApp() {
|
||||
shareText = true
|
||||
)
|
||||
Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString())
|
||||
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
|
||||
// Must precede dispatchEvent — handleCloseRequest reads this flag.
|
||||
closedByError.value = true
|
||||
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
|
||||
includeMoreFailedComposables()
|
||||
// If the left side of screen has open modal, it's probably caused the crash
|
||||
if (ModalManager.start.hasModalsOpen()) {
|
||||
@@ -73,9 +77,11 @@ fun showApp() {
|
||||
}
|
||||
}
|
||||
) {
|
||||
SimplexTray()
|
||||
AppWindow(closedByError)
|
||||
}
|
||||
}
|
||||
if (!closedByError.value) break
|
||||
}
|
||||
exitProcess(0)
|
||||
}
|
||||
@@ -115,7 +121,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
|
||||
simplexWindowState.windowState = windowState
|
||||
// Reload all strings in all @Composable's after language change at runtime
|
||||
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
|
||||
Window(state = windowState, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = {
|
||||
Window(state = windowState, visible = simplexWindowState.windowVisible.value, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { handleCloseRequest(closedByError) }, onKeyEvent = {
|
||||
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
|
||||
simplexWindowState.backstack.lastOrNull()?.invoke() != null
|
||||
} else {
|
||||
@@ -224,6 +230,30 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Not invoked for macOS Cmd+Q — that goes through AWT's default QuitHandler and
|
||||
// exits the process directly. Intentional: Cmd+Q is canonical "always quit" on macOS.
|
||||
private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Boolean>) {
|
||||
// Crash dispatch — bypass user-facing policy and exit; outer loop will restart.
|
||||
if (closedByError.value) {
|
||||
exitApplication()
|
||||
return
|
||||
}
|
||||
val pref = ChatController.appPrefs.closeBehavior
|
||||
when (pref.get()) {
|
||||
CloseBehavior.Quit -> exitApplication()
|
||||
CloseBehavior.MinimizeToTray -> if (trayIsAvailable) {
|
||||
simplexWindowState.windowVisible.value = false
|
||||
} else exitApplication()
|
||||
CloseBehavior.Ask -> if (trayIsAvailable) {
|
||||
requestCloseBehavior()
|
||||
} else {
|
||||
// Tray unavailable — Minimize is not a real option; remember Quit and exit.
|
||||
pref.set(CloseBehavior.Quit)
|
||||
exitApplication()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexWindowState {
|
||||
lateinit var windowState: WindowState
|
||||
val backstack = mutableStateListOf<() -> Unit>()
|
||||
@@ -232,6 +262,7 @@ class SimplexWindowState {
|
||||
val saveDialog = DialogState<File?>()
|
||||
val toasts = mutableStateListOf<Pair<String, Long>>()
|
||||
var windowFocused = mutableStateOf(true)
|
||||
val windowVisible = mutableStateOf(true)
|
||||
var window: ComposeWindow? = null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.window.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.CloseBehavior
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.TAG
|
||||
import chat.simplex.common.ui.theme.isInDarkTheme
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import java.awt.AWTException
|
||||
import java.awt.SystemTray
|
||||
import java.awt.TrayIcon
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
// Probed once at startup. False on stock GNOME ≥ JDK 21.0.3 per JDK-8322750, and
|
||||
// also when SystemTray.add() fails despite isSupported() returning true (an older
|
||||
// JDK pattern Compose-MP does not catch). When false: the Appearance toggle is
|
||||
// hidden, the first-close dialog is skipped (Ask migrates silently to Quit), and
|
||||
// the close handler treats MinimizeToTray as Quit.
|
||||
val trayIsAvailable: Boolean by lazy {
|
||||
if (!SystemTray.isSupported()) return@lazy false
|
||||
try {
|
||||
val tray = SystemTray.getSystemTray()
|
||||
val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB))
|
||||
tray.add(probe)
|
||||
tray.remove(probe)
|
||||
true
|
||||
} catch (e: AWTException) {
|
||||
Log.w(TAG, "SystemTray probe failed: ${e.stackTraceToString()}")
|
||||
false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "SystemTray probe denied: ${e.stackTraceToString()}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun showWindow() {
|
||||
simplexWindowState.windowVisible.value = true
|
||||
simplexWindowState.window?.toFront()
|
||||
simplexWindowState.window?.requestFocus()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ApplicationScope.SimplexTray() {
|
||||
if (!trayIsAvailable) return
|
||||
if (remember { appPrefs.closeBehavior.state }.value != CloseBehavior.MinimizeToTray) return
|
||||
// Sum of per-profile unread (UserInfo.unreadCount, the same field UserPicker renders
|
||||
// per row). Skip muted profiles unless they're the active one.
|
||||
val unread by remember {
|
||||
derivedStateOf {
|
||||
ChatModel.users.sumOf {
|
||||
if (!it.user.showNtfs && !it.user.activeUser) 0 else it.unreadCount
|
||||
}
|
||||
}
|
||||
}
|
||||
val iconRes = if (unread > 0) {
|
||||
if (isInDarkTheme()) MR.images.ic_simplex_tray_dot_light else MR.images.ic_simplex_tray_dot
|
||||
} else {
|
||||
if (isInDarkTheme()) MR.images.ic_simplex_tray_light else MR.images.ic_simplex
|
||||
}
|
||||
val tooltip =
|
||||
if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread)
|
||||
else stringResource(MR.strings.tray_tooltip)
|
||||
Tray(
|
||||
icon = painterResource(iconRes),
|
||||
tooltip = tooltip,
|
||||
onAction = ::showWindow,
|
||||
menu = {
|
||||
Item(stringResource(MR.strings.tray_show), onClick = ::showWindow)
|
||||
Separator()
|
||||
Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Renders in the main app window via AlertManager (same surface as e.g. the link
|
||||
// previews confirmation). Lambdas close over the calling ApplicationScope; if the
|
||||
// app crashes while the dialog is open, the crash handler's alert replaces it, so
|
||||
// stale closures never get clicked.
|
||||
fun ApplicationScope.requestCloseBehavior() {
|
||||
val pref = appPrefs.closeBehavior
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.close_behavior_dialog_title),
|
||||
text = AnnotatedString(generalGetString(MR.strings.close_behavior_dialog_text)),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
pref.set(CloseBehavior.Quit)
|
||||
exitApplication()
|
||||
}) {
|
||||
Text(
|
||||
stringResource(MR.strings.close_behavior_dialog_close),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
pref.set(CloseBehavior.MinimizeToTray)
|
||||
simplexWindowState.windowVisible.value = false
|
||||
}) {
|
||||
Text(
|
||||
stringResource(MR.strings.close_behavior_dialog_minimize),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -86,8 +86,8 @@ actual fun PlatformTextField(
|
||||
// Different padding here is for a text that is considered RTL with non-RTL locale set globally.
|
||||
// In this case padding from right side should be bigger
|
||||
val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp
|
||||
val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp
|
||||
val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding
|
||||
val startPadding = 0.dp
|
||||
val endPadding = startEndPadding
|
||||
val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp)
|
||||
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) }
|
||||
val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection)
|
||||
|
||||
+22
-13
@@ -17,6 +17,7 @@ import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse
|
||||
import org.nanohttpd.protocols.http.response.Status
|
||||
import org.nanohttpd.protocols.websockets.*
|
||||
import java.io.IOException
|
||||
import java.net.BindException
|
||||
import java.net.URI
|
||||
|
||||
private const val SERVER_HOST = "localhost"
|
||||
@@ -157,17 +158,18 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
val server = remember {
|
||||
try {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.unable_to_open_browser_title),
|
||||
text = generalGetString(MR.strings.unable_to_open_browser_desc)
|
||||
)
|
||||
endCall()
|
||||
startServer(onResponse).apply {
|
||||
try {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.unable_to_open_browser_title),
|
||||
text = generalGetString(MR.strings.unable_to_open_browser_desc)
|
||||
)
|
||||
endCall()
|
||||
}
|
||||
}
|
||||
startServer(onResponse)
|
||||
}
|
||||
fun processCommand(cmd: WCallCommand) {
|
||||
val apiCall = WVAPICall(command = cmd)
|
||||
@@ -206,8 +208,8 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
|
||||
}
|
||||
}
|
||||
|
||||
fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
|
||||
val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) {
|
||||
fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD {
|
||||
val server = object: NanoWSD(SERVER_HOST, port) {
|
||||
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
|
||||
|
||||
fun resourcesToResponse(path: String): Response {
|
||||
@@ -231,7 +233,14 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start(60_000_000)
|
||||
try {
|
||||
server.start(60_000_000)
|
||||
} catch (e: BindException) {
|
||||
if (port == 0) throw e
|
||||
Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}")
|
||||
server.stop()
|
||||
return startServer(onResponse, port = 0)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -3,6 +3,7 @@ package chat.simplex.common.views.usersettings
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -18,7 +19,9 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.CloseBehavior
|
||||
import chat.simplex.common.model.SharedPreference
|
||||
import chat.simplex.common.trayIsAvailable
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -65,6 +68,11 @@ fun AppearanceScope.AppearanceLayout(
|
||||
SectionDividerSpaced()
|
||||
ThemesSection(systemDarkTheme)
|
||||
|
||||
if (trayIsAvailable) {
|
||||
SectionDividerSpaced()
|
||||
MinimizeToTraySection()
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
AppToolbarsSection()
|
||||
|
||||
@@ -84,6 +92,21 @@ fun AppearanceScope.AppearanceLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MinimizeToTraySection() {
|
||||
val pref = remember { appPrefs.closeBehavior.state }
|
||||
val on = pref.value == CloseBehavior.MinimizeToTray
|
||||
SectionView {
|
||||
PreferenceToggle(
|
||||
stringResource(MR.strings.appearance_minimize_to_tray),
|
||||
checked = on,
|
||||
) { checked ->
|
||||
appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.appearance_minimize_to_tray_desc))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DensityScaleSection() {
|
||||
val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) }
|
||||
|
||||
@@ -119,9 +119,9 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos
|
||||
|
||||
Desktop calls run WebRTC in the system browser, not an embedded WebView:
|
||||
|
||||
- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`.
|
||||
- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`.
|
||||
- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`.
|
||||
- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Opens `http://localhost:50395/simplex/call/` via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server.
|
||||
- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:<listeningPort>/simplex/call/` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server.
|
||||
- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display.
|
||||
- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance.
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {describe, test, expect, beforeEach, vi} from "vitest"
|
||||
import {mkdtempSync, writeFileSync} from "fs"
|
||||
import {tmpdir} from "os"
|
||||
import {join} from "path"
|
||||
import {core} from "simplex-chat"
|
||||
import {SupportBot} from "./src/bot.js"
|
||||
import {CardManager} from "./src/cards.js"
|
||||
import {parseConfig} from "./src/config.js"
|
||||
import {GrokApiClient} from "./src/grok.js"
|
||||
import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage} from "./src/messages.js"
|
||||
import {loadGrokContext} from "./src/context.js"
|
||||
import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage, teamAlreadyInvitedMessage} from "./src/messages.js"
|
||||
|
||||
// Silence console output during tests
|
||||
vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
@@ -83,15 +88,39 @@ class MockChatApi {
|
||||
async apiListMembers(groupId: number) {
|
||||
return this.members.get(groupId) || []
|
||||
}
|
||||
async apiGetChat(_chatType: string, chatId: number, _count: number) {
|
||||
async apiGetChat(chatType: string, chatId: number, _count: number) {
|
||||
if (chatType === ChatType.Direct) {
|
||||
// Tests don't exercise direct lookups; throw the same shape production
|
||||
// would so getContact() resolves to null instead of synthesizing a contact.
|
||||
throw new core.ChatAPIError("contact not found", {
|
||||
type: "errorStore",
|
||||
storeError: {type: "contactNotFound", contactId: chatId},
|
||||
} as any)
|
||||
}
|
||||
const baseGroupInfo = this.groups.get(chatId)
|
||||
if (!baseGroupInfo) {
|
||||
// Mirror production behavior: the real apiGetChat throws "groupNotFound"
|
||||
// for an unknown id; getGroupInfo() catches and returns null.
|
||||
throw new core.ChatAPIError("group not found", {
|
||||
type: "errorStore",
|
||||
storeError: {type: "groupNotFound", groupId: chatId},
|
||||
} as any)
|
||||
}
|
||||
const items = this.chatItems.get(chatId) || []
|
||||
const groupInfo = this.groups.get(chatId)
|
||||
const groupInfo = {...baseGroupInfo, customData: this.customData.get(chatId)}
|
||||
return {
|
||||
chatInfo: {type: "group", groupInfo: groupInfo || makeGroupInfo(chatId)},
|
||||
chatInfo: {type: "group", groupInfo},
|
||||
chatItems: items,
|
||||
chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false},
|
||||
}
|
||||
}
|
||||
async apiGetChats(_userId: number, _pagination: any, _query?: any, _pcc?: boolean) {
|
||||
return [...this.groups.values()].map(g => ({
|
||||
chatInfo: {type: "group", groupInfo: {...g, customData: this.customData.get(g.groupId)}},
|
||||
chatItems: [],
|
||||
chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false},
|
||||
}))
|
||||
}
|
||||
async apiListGroups(_userId: number) {
|
||||
return [...this.groups.values()].map(g => ({...g, customData: this.customData.get(g.groupId)}))
|
||||
}
|
||||
@@ -187,13 +216,15 @@ const GROK_LOCAL_GROUP_ID = 200
|
||||
const CUSTOMER_ID = "customer-1"
|
||||
|
||||
// Commands passed into SupportBot; matches what index.ts constructs when
|
||||
// Grok is enabled. Tests that disable grokApi still pass the full list
|
||||
// because the ctor doesn't care; the value is pushed to a group's
|
||||
// groupPreferences on the first sendToGroup() call.
|
||||
// Grok is enabled. The ctor uses this to decide which `/keyword` messages
|
||||
// from customers are commands vs. plain text — tests that disable grokApi
|
||||
// should pass a list that excludes "grok" to mirror production wiring (see
|
||||
// index.ts where `grokEnabled` gates that entry).
|
||||
const DESIRED_COMMANDS = [
|
||||
{type: "command" as const, keyword: "grok", label: "Ask Grok"},
|
||||
{type: "command" as const, keyword: "team", label: "Switch to team"},
|
||||
]
|
||||
const DESIRED_COMMANDS_NO_GROK = [DESIRED_COMMANDS[1]]
|
||||
|
||||
// ─── Member factories ───
|
||||
|
||||
@@ -638,7 +669,7 @@ describe("/grok Activation", () => {
|
||||
await joinPromise
|
||||
await bot.flush()
|
||||
expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID)
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage)
|
||||
})
|
||||
|
||||
test("/grok as first message → WELCOME→GROK directly, no queue message", async () => {
|
||||
@@ -646,7 +677,7 @@ describe("/grok Activation", () => {
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
await joinPromise
|
||||
await bot.flush()
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage)
|
||||
expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message")
|
||||
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -654,7 +685,7 @@ describe("/grok Activation", () => {
|
||||
test("/grok in TEAM → rejected with teamLockedMessage", async () => {
|
||||
await reachTeam()
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "team mode")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, teamLockedMessage)
|
||||
})
|
||||
|
||||
test("/grok when grokContactId is null → grokUnavailableMessage", async () => {
|
||||
@@ -704,6 +735,28 @@ describe("Grok Conversation", () => {
|
||||
expect(grokApi.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("Grok answers messages containing a slash mid-word", async () => {
|
||||
// Regression: an unanchored regex in ciBotCommand once parsed `/read`
|
||||
// inside "follow/read" as a command, causing Grok to skip the message.
|
||||
grokApi.willRespond("We post on X and Mastodon.")
|
||||
await bot.onGrokNewChatItems(grokViewCustomerMessage(
|
||||
"What social media do you use? Anything I can follow/read for updates?"
|
||||
))
|
||||
expect(grokApi.calls.length).toBe(1)
|
||||
expect(grokApi.calls[0].message).toBe(
|
||||
"What social media do you use? Anything I can follow/read for updates?"
|
||||
)
|
||||
})
|
||||
|
||||
test("Grok answers an unknown slash-prefixed message", async () => {
|
||||
// `/help` is not in desiredCommands, so it should be treated as plain
|
||||
// text and reach Grok rather than being silently dropped.
|
||||
grokApi.willRespond("Sure, here's what I can do.")
|
||||
await bot.onGrokNewChatItems(grokViewCustomerMessage("/help me with groups"))
|
||||
expect(grokApi.calls.length).toBe(1)
|
||||
expect(grokApi.calls[0].message).toBe("/help me with groups")
|
||||
})
|
||||
|
||||
test("Grok per-message: history includes prior Grok sent response as assistant", async () => {
|
||||
addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID)
|
||||
addBotMessage("To create a group, tap + then New Group.", GROK_LOCAL_GROUP_ID)
|
||||
@@ -841,6 +894,52 @@ describe("Grok Conversation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Grok requests /team", () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
test("Grok per-message reply containing /team → team added, teamAddedMessage sent, reply still sent", async () => {
|
||||
await reachGrok()
|
||||
await bot.flush()
|
||||
grokApi.willRespond("I can't help with billing — please send /team for a human.")
|
||||
addCustomerMessageToHistory("Can you refund me?", GROK_LOCAL_GROUP_ID)
|
||||
await bot.onGrokNewChatItems(grokViewCustomerMessage("Can you refund me?"))
|
||||
|
||||
expectAnySent("I can't help with billing")
|
||||
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
|
||||
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID)
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within")
|
||||
})
|
||||
|
||||
test("Grok per-message reply without /team → no team members added", async () => {
|
||||
await reachGrok()
|
||||
await bot.flush()
|
||||
grokApi.willRespond("To create a group, tap +, then New Group.")
|
||||
addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID)
|
||||
await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?"))
|
||||
|
||||
expect(chat.added.some(a => a.groupId === CUSTOMER_GROUP_ID && a.contactId === TEAM_MEMBER_1_ID)).toBe(false)
|
||||
})
|
||||
|
||||
test("/team in Grok's initial reply after /grok → escalates", async () => {
|
||||
await reachQueue()
|
||||
addBotMessage("The team will reply to your message")
|
||||
// Customer's question visible in Grok's view → activateGrok reads it for the initial reply
|
||||
chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID))
|
||||
addCustomerMessageToHistory("I'm really stuck, please help", GROK_LOCAL_GROUP_ID)
|
||||
grokApi.willRespond("That sounds urgent — send /team to reach a person.")
|
||||
|
||||
const grokJoinPromise = simulateGrokJoinSuccess()
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
await grokJoinPromise
|
||||
await bot.flush()
|
||||
|
||||
expectAnySent("That sounds urgent")
|
||||
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
|
||||
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID)
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within")
|
||||
})
|
||||
})
|
||||
|
||||
describe("/team Activation", () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
@@ -864,7 +963,7 @@ describe("/team Activation", () => {
|
||||
addBotMessage("We will reply within 24 hours.")
|
||||
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
|
||||
await bot.onNewChatItems(customerMessage("/team"))
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "already been invited")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, teamAlreadyInvitedMessage)
|
||||
})
|
||||
|
||||
test("/team with no team members → noTeamMembersMessage", async () => {
|
||||
@@ -898,7 +997,7 @@ describe("One-Way Gate", () => {
|
||||
test("/grok after gate → teamLockedMessage", async () => {
|
||||
await reachTeam()
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "team mode")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, teamLockedMessage)
|
||||
})
|
||||
|
||||
test("customer text in TEAM → card update scheduled, no bot reply", async () => {
|
||||
@@ -948,6 +1047,17 @@ describe("One-Way Gate with Grok Disabled", () => {
|
||||
// Grok should not respond (grokApi is null)
|
||||
expect(grokApi.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("Grok disabled: customer /grok is treated as text and queued", async () => {
|
||||
// When Grok is disabled, index.ts excludes "grok" from desiredCommands,
|
||||
// so /grok from a customer parses as an unknown command → routed as
|
||||
// plain text → first-message-in-WELCOME transitions to QUEUE.
|
||||
setup()
|
||||
bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS_NO_GROK)
|
||||
bot.cards = cards
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Team Member Lifecycle", () => {
|
||||
@@ -1456,7 +1566,7 @@ describe("Error Handling", () => {
|
||||
// Only the "Inviting Grok" message is sent — no activated/unavailable result
|
||||
expect(chat.sent.length).toBe(sentBefore + 1)
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok")
|
||||
expectNotSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok")
|
||||
expectNotSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage)
|
||||
expectNotSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable")
|
||||
})
|
||||
|
||||
@@ -1646,7 +1756,7 @@ describe("Grok Join Flow", () => {
|
||||
await bot.flush()
|
||||
|
||||
expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID)
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage)
|
||||
})
|
||||
|
||||
test("per-message responses suppressed during activateGrok initial response", async () => {
|
||||
@@ -1794,7 +1904,7 @@ describe("End-to-End Flows", () => {
|
||||
await joinPromise
|
||||
await bot.flush()
|
||||
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok")
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage)
|
||||
expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message")
|
||||
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -1834,8 +1944,8 @@ describe("Message Templates", () => {
|
||||
expect(grokActivatedMessage).toContain("chatting with Grok")
|
||||
})
|
||||
|
||||
test("teamLockedMessage mentions team mode", () => {
|
||||
expect(teamLockedMessage).toContain("team mode")
|
||||
test("teamLockedMessage tells customer the team will handle the conversation", () => {
|
||||
expect(teamLockedMessage).toContain("team")
|
||||
})
|
||||
|
||||
test("queueMessage mentions hours", () => {
|
||||
@@ -2402,7 +2512,7 @@ describe("GrokApiClient HTTP timeout", () => {
|
||||
new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}),
|
||||
)
|
||||
|
||||
const client = new GrokApiClient("test-key", "system prompt")
|
||||
const client = new GrokApiClient("test-key", [{role: "system", content: "system prompt"}])
|
||||
await client.chat([], "hello")
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(60_000)
|
||||
@@ -2479,3 +2589,118 @@ describe("Command sync in sendToGroup", () => {
|
||||
expect(prefs.reactions).toEqual({enable: "on"})
|
||||
})
|
||||
})
|
||||
|
||||
// loadGrokContext: documented behavior is "plain text → single system
|
||||
// message". A `.yaml` / `.yml` extension is an undocumented alternative
|
||||
// that parses the harness transcript format and surfaces only `system`
|
||||
// and `assistant` turns; `user` entries are dropped so they don't merge
|
||||
// with the customer's runtime message.
|
||||
describe("loadGrokContext", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "support-bot-context-"))
|
||||
const writeFile = (name: string, content: string): string => {
|
||||
const p = join(dir, name)
|
||||
writeFileSync(p, content)
|
||||
return p
|
||||
}
|
||||
|
||||
test("plain text (.txt) → single system message with full file content", () => {
|
||||
const path = writeFile("ctx.txt", "You are Grok.\n\nBe concise.")
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "system", content: "You are Grok.\n\nBe concise."},
|
||||
])
|
||||
})
|
||||
|
||||
test("no extension → treated as plain text", () => {
|
||||
const path = writeFile("plain", "raw context")
|
||||
expect(loadGrokContext(path)).toEqual([{role: "system", content: "raw context"}])
|
||||
})
|
||||
|
||||
test(".md → treated as plain text (does not look like YAML)", () => {
|
||||
const path = writeFile("ctx.md", "# Heading\n\nbody")
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "system", content: "# Heading\n\nbody"},
|
||||
])
|
||||
})
|
||||
|
||||
test(".yaml → parses transcript and keeps only system + assistant turns", () => {
|
||||
const path = writeFile("ctx.yaml",
|
||||
"- role: system\n message: Be terse.\n" +
|
||||
"- role: user\n message: What is async?\n" +
|
||||
"- role: assistant\n message: Cooperative concurrency.\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "system", content: "Be terse."},
|
||||
{role: "assistant", content: "Cooperative concurrency."},
|
||||
])
|
||||
})
|
||||
|
||||
test(".yml extension also triggers YAML parsing", () => {
|
||||
const path = writeFile("ctx.yml",
|
||||
"- role: system\n message: hi\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}])
|
||||
})
|
||||
|
||||
test("YAML parsing is case-insensitive on extension", () => {
|
||||
const path = writeFile("ctx.YAML",
|
||||
"- role: system\n message: hi\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}])
|
||||
})
|
||||
|
||||
test("YAML preserves multi-line literal block scalars verbatim", () => {
|
||||
const path = writeFile("multiline.yaml",
|
||||
"- role: assistant\n message: |\n line one\n line two\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "assistant", content: "line one\nline two\n"},
|
||||
])
|
||||
})
|
||||
|
||||
test("YAML with only user-role entries → empty array", () => {
|
||||
const path = writeFile("only-user.yaml",
|
||||
"- role: user\n message: a\n" +
|
||||
"- role: user\n message: b\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([])
|
||||
})
|
||||
|
||||
test("empty YAML file → empty array", () => {
|
||||
const path = writeFile("empty.yaml", "")
|
||||
expect(loadGrokContext(path)).toEqual([])
|
||||
})
|
||||
|
||||
test("YAML non-list top level throws", () => {
|
||||
const path = writeFile("not-list.yaml", "role: system\nmessage: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/top-level must be a list/)
|
||||
})
|
||||
|
||||
test("YAML entry with unknown role throws", () => {
|
||||
const path = writeFile("bad-role.yaml", "- role: bogus\n message: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/)
|
||||
})
|
||||
|
||||
test("YAML entry missing role throws", () => {
|
||||
const path = writeFile("no-role.yaml", "- message: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/)
|
||||
})
|
||||
|
||||
test("YAML entry with non-string message throws", () => {
|
||||
const path = writeFile("bad-message.yaml", "- role: user\n message: 42\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 has non-string message/)
|
||||
})
|
||||
|
||||
test("YAML entry that is not a mapping throws", () => {
|
||||
const path = writeFile("bad-entry.yaml", "- just a string\n- role: user\n message: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 is not a mapping/)
|
||||
})
|
||||
|
||||
test("malformed YAML throws", () => {
|
||||
const path = writeFile("malformed.yaml", "- role: user\n message: [unclosed\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/failed to parse YAML/)
|
||||
})
|
||||
|
||||
test("missing file throws ENOENT", () => {
|
||||
expect(() => loadGrokContext(join(dir, "does-not-exist.yaml"))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
+25
-9
@@ -9,10 +9,11 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.5.0",
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^14.0.3",
|
||||
"simplex-chat": "^6.5.0"
|
||||
"simplex-chat": "^6.5.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -782,9 +783,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@simplex-chat/types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.5.0.tgz",
|
||||
"integrity": "sha512-f680CRlf+O8WfIaPb7wxVj3PB8mTIOE+HqmetCSe0NBheVAjU3ovg3+zkrWwDlavrHuCLbb7Gmeu4HyNtjDfog==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.6.0.tgz",
|
||||
"integrity": "sha512-QVYvaRsS6TnS+IROjNkekZYvhvy8QoA8vKCuGe9E6lplXDVutJo9tdDOSWS9NDdtwxT1wRZ29zN4xEZEEG/NHw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
@@ -1675,13 +1676,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/simplex-chat": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.0.tgz",
|
||||
"integrity": "sha512-QFGI734HhYJ7trSrEKiZ2mbodI0V8CLDGEv2+yt5zsg0FqftxSpFik6zUSezTRZtN1M8WmSlT44qlEt2a1fXQw==",
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.1.tgz",
|
||||
"integrity": "sha512-1cv91iMCqtP+9R1PQM3NKazocoUInKT2/06pIOuzORD5/VzulR6cMZnEQmaT02Jz+8FVkEKTsaAIJfVP6/tJmw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.5.0",
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"node-addon-api": "^8.5.0"
|
||||
@@ -1995,6 +1996,21 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.5.0",
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^14.0.3",
|
||||
"simplex-chat": "^6.5.0"
|
||||
"simplex-chat": "^6.5.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -8,7 +8,23 @@ import {
|
||||
teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage,
|
||||
grokUnavailableMessage, grokErrorMessage, grokNoHistoryMessage,
|
||||
} from "./messages.js"
|
||||
import {profileMutex, log, logError} from "./util.js"
|
||||
import {profileMutex, log, logError, getGroupInfo} from "./util.js"
|
||||
|
||||
// Collects the keyword of every "command" entry in the bot's registered
|
||||
// commands tree, descending into "menu" entries. Used to distinguish real
|
||||
// commands from arbitrary text that happens to start with `/` (e.g. URLs,
|
||||
// "/help" the user invented).
|
||||
function commandKeywords(commands: T.ChatBotCommand[]): Set<string> {
|
||||
const out = new Set<string>()
|
||||
const visit = (cmds: T.ChatBotCommand[]): void => {
|
||||
for (const c of cmds) {
|
||||
if (c.type === "command") out.add(c.keyword)
|
||||
else if (c.type === "menu") visit(c.commands)
|
||||
}
|
||||
}
|
||||
visit(commands)
|
||||
return out
|
||||
}
|
||||
|
||||
// True for any non-terminal status — invited but not yet accepted, through
|
||||
// connected. Used to decide whether a contact is already in the group so we
|
||||
@@ -62,6 +78,11 @@ export class SupportBot {
|
||||
// send to each group.
|
||||
private syncedGroups = new Set<number>()
|
||||
|
||||
// Keywords from desiredCommands. A customer message is treated as a
|
||||
// command only when its parsed keyword is in this set; anything else
|
||||
// (URLs, "/help", arbitrary slashes) is routed as plain text.
|
||||
private readonly customerKeywords: ReadonlySet<string>
|
||||
|
||||
constructor(
|
||||
private chat: api.ChatApi,
|
||||
private grokApi: GrokApiClient | null,
|
||||
@@ -71,6 +92,12 @@ export class SupportBot {
|
||||
private desiredCommands: T.ChatBotCommand[],
|
||||
) {
|
||||
this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000)
|
||||
this.customerKeywords = commandKeywords(desiredCommands)
|
||||
}
|
||||
|
||||
private customerCommand(chatItem: T.ChatItem): util.BotCommand | undefined {
|
||||
const cmd = util.ciBotCommand(chatItem)
|
||||
return cmd && this.customerKeywords.has(cmd.keyword) ? cmd : undefined
|
||||
}
|
||||
|
||||
private get grokEnabled(): boolean {
|
||||
@@ -357,7 +384,7 @@ export class SupportBot {
|
||||
if (chatInfo.type !== "group") continue
|
||||
if (chatItem.chatDir.type !== "groupRcv") continue
|
||||
if (!util.ciContentText(chatItem)?.trim()) continue
|
||||
if (util.ciBotCommand(chatItem)) continue
|
||||
if (this.customerCommand(chatItem)) continue
|
||||
const bc = chatInfo.groupInfo.businessChat
|
||||
if (!bc) continue
|
||||
if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue
|
||||
@@ -444,9 +471,7 @@ export class SupportBot {
|
||||
|
||||
// 8. Customer message → derive state and dispatch
|
||||
const state = await this.cards.deriveState(groupId)
|
||||
const rawCmd = util.ciBotCommand(chatItem)
|
||||
// When Grok is disabled, ignore /grok so it behaves like an unknown command
|
||||
const cmd = rawCmd?.keyword === "grok" && !this.grokEnabled ? null : rawCmd
|
||||
const cmd = this.customerCommand(chatItem)
|
||||
const text = util.ciContentText(chatItem)?.trim() || null
|
||||
|
||||
switch (state) {
|
||||
@@ -547,7 +572,7 @@ export class SupportBot {
|
||||
if (!text) return // ignore non-text
|
||||
|
||||
// Ignore bot commands
|
||||
if (util.ciBotCommand(chatItem)) return
|
||||
if (this.customerCommand(chatItem)) return
|
||||
|
||||
// Only respond in business groups (survives restart without in-memory maps)
|
||||
const bc = groupInfo.businessChat
|
||||
@@ -569,7 +594,7 @@ export class SupportBot {
|
||||
history.push({role: "assistant", content: histText})
|
||||
} else if (histCi.chatDir.type === "groupRcv"
|
||||
&& histCi.chatDir.groupMember.memberId === bc.customerId
|
||||
&& !util.ciBotCommand(histCi)) {
|
||||
&& !this.customerCommand(histCi)) {
|
||||
history.push({role: "user", content: histText})
|
||||
}
|
||||
}
|
||||
@@ -587,6 +612,9 @@ export class SupportBot {
|
||||
await this.withGrokProfile(() =>
|
||||
this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], response)
|
||||
)
|
||||
|
||||
// Grok asked for the team → escalate as if the customer sent /team
|
||||
if (mainGroupId !== undefined && response.includes("/team")) await this.activateTeam(mainGroupId)
|
||||
} catch (err) {
|
||||
logError(`Grok per-message error for grokGroup ${grokGroupId}`, err)
|
||||
try {
|
||||
@@ -706,7 +734,7 @@ export class SupportBot {
|
||||
if (ci.chatDir.type !== "groupRcv") continue
|
||||
if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue
|
||||
const t = util.ciContentText(ci)?.trim()
|
||||
if (t && !util.ciBotCommand(ci)) customerMessages.push(t)
|
||||
if (t && !this.customerCommand(ci)) customerMessages.push(t)
|
||||
}
|
||||
|
||||
if (customerMessages.length === 0) {
|
||||
@@ -722,6 +750,9 @@ export class SupportBot {
|
||||
await this.withGrokProfile(() =>
|
||||
this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
|
||||
)
|
||||
|
||||
// Grok asked for the team → escalate as if the customer sent /team
|
||||
if (response.includes("/team")) await this.activateTeam(groupId)
|
||||
} catch (err) {
|
||||
logError(`Grok initial response failed for group ${groupId}`, err)
|
||||
await this.sendToGroup(groupId, grokUnavailableMessage)
|
||||
@@ -795,10 +826,7 @@ export class SupportBot {
|
||||
|
||||
private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise<void> {
|
||||
// Validate target is a business group
|
||||
const groups = await this.withMainProfile(() =>
|
||||
this.chat.apiListGroups(this.mainUserId)
|
||||
)
|
||||
const targetGroup = groups.find(g => g.groupId === targetGroupId)
|
||||
const targetGroup = await this.withMainProfile(() => getGroupInfo(this.chat, targetGroupId))
|
||||
if (!targetGroup?.businessChat) {
|
||||
await this.sendToGroup(this.config.teamGroup.id, `Error: group ${targetGroupId} is not a business chat`)
|
||||
return
|
||||
|
||||
@@ -2,7 +2,7 @@ import {T} from "@simplex-chat/types"
|
||||
import {api, util} from "simplex-chat"
|
||||
import {Mutex} from "async-mutex"
|
||||
import {Config} from "./config.js"
|
||||
import {profileMutex, log, logError} from "./util.js"
|
||||
import {profileMutex, log, logError, getGroupInfo} from "./util.js"
|
||||
|
||||
// State derivation types
|
||||
export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM"
|
||||
@@ -117,8 +117,7 @@ export class CardManager {
|
||||
|
||||
// Dispatches to create-path when cardItemId is absent so a failed createCard retries.
|
||||
private async flushOne(groupId: number): Promise<void> {
|
||||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||||
const groupInfo = groups.find(g => g.groupId === groupId)
|
||||
const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
|
||||
if (!groupInfo) return
|
||||
const data = groupInfo.customData as Record<string, unknown> | undefined
|
||||
if (typeof data?.cardItemId === "number") {
|
||||
@@ -129,12 +128,22 @@ export class CardManager {
|
||||
}
|
||||
|
||||
async refreshAllCards(): Promise<void> {
|
||||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||||
// Scan the most recently active 1000 chats. Active cards live on
|
||||
// recently-active customer chats by definition — a card stays open
|
||||
// while the conversation is in flight. If the bot has been offline
|
||||
// long enough that an active card has fallen outside this window, the
|
||||
// card refreshes lazily on the next customer message (which moves the
|
||||
// chat back into the recent window).
|
||||
const chats = await this.withMainProfile(() =>
|
||||
this.chat.apiGetChats(this.mainUserId, {type: "last", count: 1000})
|
||||
)
|
||||
const activeCards: {groupId: number; cardItemId: number}[] = []
|
||||
for (const group of groups) {
|
||||
const customData = group.customData as Record<string, unknown> | undefined
|
||||
for (const c of chats) {
|
||||
if (c.chatInfo.type !== "group") continue
|
||||
const groupInfo = c.chatInfo.groupInfo
|
||||
const customData = groupInfo.customData as Record<string, unknown> | undefined
|
||||
if (customData && typeof customData.cardItemId === "number" && !customData.complete) {
|
||||
activeCards.push({groupId: group.groupId, cardItemId: customData.cardItemId})
|
||||
activeCards.push({groupId: groupInfo.groupId, cardItemId: customData.cardItemId})
|
||||
}
|
||||
}
|
||||
if (activeCards.length === 0) return
|
||||
@@ -210,8 +219,7 @@ export class CardManager {
|
||||
// --- Custom data ---
|
||||
|
||||
async getRawCustomData(groupId: number): Promise<Partial<CardData> | null> {
|
||||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||||
const group = groups.find(g => g.groupId === groupId)
|
||||
const group = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
|
||||
if (!group?.customData) return null
|
||||
const data = group.customData as Record<string, unknown>
|
||||
const result: Partial<CardData> = {}
|
||||
@@ -247,9 +255,7 @@ export class CardManager {
|
||||
// --- Internal ---
|
||||
|
||||
private async updateCard(groupId: number): Promise<void> {
|
||||
// Read customData and groupInfo in one apiListGroups call
|
||||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||||
const groupInfo = groups.find(g => g.groupId === groupId)
|
||||
const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
|
||||
if (!groupInfo) return
|
||||
|
||||
const customData = groupInfo.customData as Record<string, unknown> | undefined
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {readFileSync} from "fs"
|
||||
import {parse as parseYaml} from "yaml"
|
||||
import {GrokMessage} from "./grok.js"
|
||||
|
||||
const ALLOWED_ROLES: ReadonlySet<GrokMessage["role"]> = new Set(["system", "user", "assistant"])
|
||||
// Roles surfaced from a YAML transcript. `user` entries from the file are
|
||||
// validated but dropped — the customer's runtime message is the only
|
||||
// `user` content sent to Grok.
|
||||
const PREPEND_ROLES: ReadonlySet<GrokMessage["role"]> = new Set(["system", "assistant"])
|
||||
|
||||
// Loads --context-file. The flag is documented as "text file with Grok
|
||||
// system context"; a `.yaml` / `.yml` extension is an undocumented
|
||||
// alternative that switches to a multi-turn transcript in the harness
|
||||
// format (a flat list of `{role, message}` entries).
|
||||
export function loadGrokContext(path: string): GrokMessage[] {
|
||||
const text = readFileSync(path, "utf-8")
|
||||
return isYamlPath(path) ? parseYamlTranscript(path, text) : [{role: "system", content: text}]
|
||||
}
|
||||
|
||||
function isYamlPath(path: string): boolean {
|
||||
const lower = path.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
// Parses the harness transcript format. Returns only `system` and
|
||||
// `assistant` turns; `user` entries are intentionally excluded so they
|
||||
// don't merge with the customer's runtime message. Malformed YAML,
|
||||
// unknown roles, or non-string messages throw — operator-supplied
|
||||
// configuration should fail-fast at startup, not silently degrade.
|
||||
function parseYamlTranscript(path: string, text: string): GrokMessage[] {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = parseYaml(text)
|
||||
} catch (e) {
|
||||
throw new Error(`${path}: failed to parse YAML: ${(e as Error).message}`)
|
||||
}
|
||||
if (raw === null || raw === undefined) return []
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error(`${path}: top-level must be a list, got ${typeof raw}`)
|
||||
}
|
||||
const context: GrokMessage[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i]
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`${path}: entry ${i} is not a mapping`)
|
||||
}
|
||||
const {role, message} = entry as {role?: unknown; message?: unknown}
|
||||
if (typeof role !== "string" || !ALLOWED_ROLES.has(role as GrokMessage["role"])) {
|
||||
throw new Error(`${path}: entry ${i} has invalid role: ${JSON.stringify(role)}`)
|
||||
}
|
||||
if (typeof message !== "string") {
|
||||
throw new Error(`${path}: entry ${i} has non-string message`)
|
||||
}
|
||||
if (PREPEND_ROLES.has(role as GrokMessage["role"])) {
|
||||
context.push({role: role as GrokMessage["role"], content: message})
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -7,11 +7,11 @@ export interface GrokMessage {
|
||||
|
||||
export class GrokApiClient {
|
||||
private readonly apiKey: string
|
||||
private readonly systemPrompt: string
|
||||
private readonly initialContext: readonly GrokMessage[]
|
||||
|
||||
constructor(apiKey: string, systemPrompt: string) {
|
||||
constructor(apiKey: string, initialContext: readonly GrokMessage[]) {
|
||||
this.apiKey = apiKey
|
||||
this.systemPrompt = systemPrompt
|
||||
this.initialContext = initialContext
|
||||
}
|
||||
|
||||
async chatRaw(messages: GrokMessage[]): Promise<string> {
|
||||
@@ -22,7 +22,7 @@ export class GrokApiClient {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "grok-3-mini",
|
||||
model: "grok-latest",
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 1024,
|
||||
@@ -45,9 +45,9 @@ export class GrokApiClient {
|
||||
}
|
||||
|
||||
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
|
||||
log(`Grok API call: ${history.length} history msgs, user msg ${userMessage.length} chars`)
|
||||
log(`Grok API call: ${this.initialContext.length} context msgs, ${history.length} history msgs, user msg ${userMessage.length} chars`)
|
||||
return this.chatRaw([
|
||||
{role: "system", content: this.systemPrompt},
|
||||
...this.initialContext,
|
||||
...history,
|
||||
{role: "user", content: userMessage},
|
||||
])
|
||||
|
||||
@@ -3,9 +3,10 @@ import {api, bot, util} from "simplex-chat"
|
||||
import {T} from "@simplex-chat/types"
|
||||
import {parseConfig} from "./config.js"
|
||||
import {SupportBot} from "./bot.js"
|
||||
import {GrokApiClient} from "./grok.js"
|
||||
import {GrokApiClient, GrokMessage} from "./grok.js"
|
||||
import {loadGrokContext} from "./context.js"
|
||||
import {welcomeMessage} from "./messages.js"
|
||||
import {profileMutex, log, logError} from "./util.js"
|
||||
import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js"
|
||||
|
||||
interface BotState {
|
||||
teamGroupId?: number
|
||||
@@ -163,14 +164,12 @@ async function main(): Promise<void> {
|
||||
await chat.apiSetAutoAcceptMemberContacts(mainUser.userId, true)
|
||||
log("Auto-accept member contacts enabled")
|
||||
|
||||
// Step 5: List contacts, resolve Grok contact
|
||||
const contacts = await chat.apiListContacts(mainUser.userId)
|
||||
log(`Contacts connected: ${contacts.length || "(none)"}`)
|
||||
|
||||
// Step 5: Resolve Grok contact by ID. Avoid apiListContacts — it loads
|
||||
// every contact in one response and OOMs the native binding on large DBs.
|
||||
// Always restore grokContactId so the one-way gate can find and remove
|
||||
// Grok members even when Grok API is disabled.
|
||||
if (typeof state.grokContactId === "number") {
|
||||
const found = contacts.find(c => c.contactId === state.grokContactId)
|
||||
const found = await getContact(chat, state.grokContactId)
|
||||
if (found) {
|
||||
config.grokContactId = found.contactId
|
||||
log(`Grok contact from state: ID=${config.grokContactId}`)
|
||||
@@ -210,14 +209,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Resolve team group
|
||||
// Step 6: Resolve team group by ID. Avoid apiListGroups — it loads every
|
||||
// group in one response and OOMs the native binding on large DBs.
|
||||
log("Resolving team group...")
|
||||
const groups = await chat.apiListGroups(mainUser.userId)
|
||||
|
||||
let existingGroup: T.GroupInfo | undefined
|
||||
let existingGroup: T.GroupInfo | null = null
|
||||
|
||||
if (typeof state.teamGroupId === "number") {
|
||||
existingGroup = groups.find(g => g.groupId === state.teamGroupId)
|
||||
existingGroup = await getGroupInfo(chat, state.teamGroupId)
|
||||
if (existingGroup) {
|
||||
config.teamGroup.id = existingGroup.groupId
|
||||
log(`Team group from state: ${config.teamGroup.id}:${existingGroup.groupProfile.displayName}`)
|
||||
@@ -302,13 +300,13 @@ async function main(): Promise<void> {
|
||||
inviteLinkTimer.unref()
|
||||
}
|
||||
|
||||
// Step 9: Validate team members
|
||||
// Step 9: Validate team members (lookup by ID, one round-trip per member)
|
||||
if (config.teamMembers.length > 0) {
|
||||
log("Validating team members...")
|
||||
for (const member of config.teamMembers) {
|
||||
const contact = contacts.find(c => c.contactId === member.id)
|
||||
const contact = await getContact(chat, member.id)
|
||||
if (!contact) {
|
||||
console.error(`Team member not found: ID=${member.id}. Available: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
|
||||
console.error(`Team member not found: ID=${member.id}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (contact.profile.displayName !== member.name) {
|
||||
@@ -322,16 +320,22 @@ async function main(): Promise<void> {
|
||||
// Load Grok context and build API client only if enabled
|
||||
let grokApi: GrokApiClient | null = null
|
||||
if (grokEnabled) {
|
||||
let contextFile = ""
|
||||
let initialContext: GrokMessage[] = []
|
||||
if (config.contextFile) {
|
||||
try {
|
||||
contextFile = readFileSync(config.contextFile, "utf-8")
|
||||
log(`Loaded Grok context: ${contextFile.length} chars from ${config.contextFile}`)
|
||||
} catch {
|
||||
log(`Warning: context file not found: ${config.contextFile}`)
|
||||
initialContext = loadGrokContext(config.contextFile)
|
||||
log(`Loaded Grok context: ${initialContext.length} message(s) from ${config.contextFile}`)
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException
|
||||
if (e.code === "ENOENT") {
|
||||
log(`Warning: context file not found: ${config.contextFile}`)
|
||||
} else {
|
||||
logError(`Failed to load Grok context file ${config.contextFile}`, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
grokApi = new GrokApiClient(config.grokApiKey!, contextFile)
|
||||
grokApi = new GrokApiClient(config.grokApiKey!, initialContext)
|
||||
}
|
||||
|
||||
// Create SupportBot
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
import {Mutex} from "async-mutex"
|
||||
import {api, core} from "simplex-chat"
|
||||
import {T} from "@simplex-chat/types"
|
||||
|
||||
export const profileMutex = new Mutex()
|
||||
|
||||
export function isChatNotFound(err: unknown, kind: "group" | "contact"): boolean {
|
||||
if (!(err instanceof core.ChatAPIError)) return false
|
||||
if (err.chatError?.type !== "errorStore") return false
|
||||
const seType = err.chatError.storeError.type
|
||||
return kind === "group" ? seType === "groupNotFound" : seType === "contactNotFound"
|
||||
}
|
||||
|
||||
export async function getGroupInfo(chat: api.ChatApi, groupId: number): Promise<T.GroupInfo | null> {
|
||||
try {
|
||||
const c = await chat.apiGetChat(T.ChatType.Group, groupId, 0)
|
||||
return c.chatInfo.type === "group" ? c.chatInfo.groupInfo : null
|
||||
} catch (err) {
|
||||
if (isChatNotFound(err, "group")) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContact(chat: api.ChatApi, contactId: number): Promise<T.Contact | null> {
|
||||
try {
|
||||
const c = await chat.apiGetChat(T.ChatType.Direct, contactId, 0)
|
||||
return c.chatInfo.type === "direct" ? c.chatInfo.contact : null
|
||||
} catch (err) {
|
||||
if (isChatNotFound(err, "contact")) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function isWeekend(timezone: string): boolean {
|
||||
const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date())
|
||||
return day === "Sat" || day === "Sun"
|
||||
|
||||
@@ -9,7 +9,7 @@ function ciContentText(chatItem) {
|
||||
function ciBotCommand(chatItem) {
|
||||
const text = ciContentText(chatItem)?.trim()
|
||||
if (text) {
|
||||
const r = text.match(/\/([^\s]+)(.*)/)
|
||||
const r = text.match(/^\/([^\s]+)(.*)/)
|
||||
if (r && r.length >= 3) return {keyword: r[1], params: r[2].trim()}
|
||||
}
|
||||
return undefined
|
||||
@@ -19,8 +19,18 @@ function contactAddressStr(link) {
|
||||
return link.connShortLink || link.connFullLink
|
||||
}
|
||||
|
||||
// Mirrors core.ChatAPIError so isChatNotFound's instanceof check passes when
|
||||
// MockChatApi throws. Tests should construct these directly.
|
||||
class ChatAPIError extends Error {
|
||||
constructor(message, chatError) {
|
||||
super(message)
|
||||
this.chatError = chatError
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
api: {ChatApi: {}},
|
||||
bot: {},
|
||||
core: {ChatAPIError},
|
||||
util: {ciContentText, ciBotCommand, contactAddressStr},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user