Merge branch 'master' into ae/oklch-color-space-plan

This commit is contained in:
Evgeny Poberezkin
2026-05-13 16:11:15 +01:00
167 changed files with 19532 additions and 708 deletions
+9
View File
@@ -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)")
+6 -3
View File
@@ -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)
}
}
}
+16
View File
@@ -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
+31 -5
View File
@@ -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 */,
+1
View File
@@ -4091,6 +4091,7 @@ public enum CIDeleteMode: String, Decodable, Hashable {
case cidmBroadcast = "broadcast"
case cidmInternal = "internal"
case cidmInternalMark = "internalMark"
case cidmHistory = "history"
}
protocol ItemContent {
@@ -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 {
@@ -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")
@@ -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 {
@@ -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,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()
@@ -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)
}
}
@@ -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()
}
}
@@ -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 = {}) {
@@ -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,
)
}
}
}
@@ -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),
@@ -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),
@@ -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 = {
@@ -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)
@@ -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 (
@@ -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))
}
}
@@ -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)
@@ -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

@@ -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

@@ -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

@@ -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
)
}
}
}
)
}
@@ -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)
@@ -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
}
@@ -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()) }
+2 -2
View File
@@ -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.
+243 -18
View File
@@ -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
View File
@@ -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",
+3 -2
View File
@@ -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",
+40 -12
View File
@@ -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
+18 -12
View File
@@ -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
+59
View File
@@ -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
}
+6 -6
View File
@@ -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},
])
+25 -21
View File
@@ -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
+29
View File
@@ -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},
}