The main window controller was created as a local variable in ConnectWindowController.authSucceeded and never retained — ARC deallocated it immediately, which destroyed the VoiceCatClient (connection silently dropped), nil'd every button's weak target (clicks did nothing), and killed event delivery (channel list, messages, voice never worked). Symptom: TOFU (in the retained connect controller) worked, but everything in the main window was a zombie shell. Fix: store the MainWindowController in a new field on ConnectWindowController (which AppDelegate retains for the app's lifetime). Also add NSLog diagnostics in deinit/bootstrap/handleEvent so lifecycle and event delivery are observable from the terminal or Console.app.
1197 lines
50 KiB
Swift
1197 lines
50 KiB
Swift
import AppKit
|
|
import VoiceCatCore
|
|
|
|
// MARK: - Channel tree node
|
|
|
|
private final class ChannelNode: NSObject {
|
|
let channel: Channel
|
|
var children: [ChannelNode]
|
|
init(_ channel: Channel, children: [ChannelNode] = []) {
|
|
self.channel = channel
|
|
self.children = children
|
|
}
|
|
}
|
|
|
|
// MARK: - Window controller
|
|
|
|
final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|
|
|
// MARK: - Owned client
|
|
private let client: VoiceCatClient
|
|
private let selfUserId: UInt32
|
|
private let nickname: String
|
|
|
|
// MARK: - State
|
|
private var currentChannelId: UInt32 = 0
|
|
private var channels: [Channel] = []
|
|
private var users: [UInt32: User] = [:]
|
|
private var talkingUsers: Set<UInt32> = []
|
|
private var ownPermissions = Permissions(canCreateTempChannel: false, canKick: false, canBan: false,
|
|
canMoveUsers: false, canAdminAccounts: false, isAdmin: false)
|
|
private var micStreamId: UInt32 = 0
|
|
private var screenStreamId: UInt32 = 0
|
|
private var pttKeyCode: UInt16 = 0x60 // F8
|
|
private var pttMonitor: Any?
|
|
private var serverMuted = false
|
|
private var serverDeafened = false
|
|
private var channelTree: [ChannelNode] = []
|
|
private var activityLog: [String] = []
|
|
private var displayedUsers: [User] = []
|
|
private var displayedAccounts: [Account] = []
|
|
private var adminMenuItem: NSMenuItem?
|
|
|
|
// MARK: - UI components
|
|
private let channelOutlineView = NSOutlineView()
|
|
private let userTableView = NSTableView()
|
|
private let chatTextView: NSTextView = {
|
|
let tv = NSTextView()
|
|
tv.isEditable = false
|
|
tv.isSelectable = true
|
|
return tv
|
|
}()
|
|
private let activityTableView = NSTableView()
|
|
private let statusLabel = NSTextField(labelWithString: "")
|
|
private let micToggleButton = NSButton()
|
|
private let screenAudioButton = NSButton()
|
|
private let muteCheckbox = NSButton(checkboxWithTitle: "Mute", target: nil, action: nil)
|
|
private let deafenCheckbox = NSButton(checkboxWithTitle: "Deafen", target: nil, action: nil)
|
|
private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"],
|
|
trackingMode: .selectOne,
|
|
target: nil, action: nil)
|
|
private let vadSlider: NSSlider = {
|
|
let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil)
|
|
s.numberOfTickMarks = 0
|
|
return s
|
|
}()
|
|
private let vadLabel = NSTextField(labelWithString: "Sensitivity:")
|
|
private let pttKeyLabel = NSTextField(labelWithString: "(F8)")
|
|
private let changePttButton = NSButton()
|
|
private let devicePicker = NSPopUpButton()
|
|
private let refreshDevicesButton = NSButton()
|
|
private let levelMeter: NSProgressIndicator = {
|
|
let p = NSProgressIndicator()
|
|
p.style = .bar
|
|
p.isIndeterminate = false
|
|
p.minValue = 0
|
|
p.maxValue = 100
|
|
p.doubleValue = 0
|
|
return p
|
|
}()
|
|
private let scopePicker = NSPopUpButton()
|
|
private let composeField = NSTextField()
|
|
private let sendButton = NSButton()
|
|
|
|
// MARK: - Init
|
|
|
|
init(client: VoiceCatClient, selfUserId: UInt32, nickname: String) {
|
|
self.client = client
|
|
self.selfUserId = selfUserId
|
|
self.nickname = nickname
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 900, height: 600),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "VoiceCat — \(nickname)"
|
|
window.minSize = NSSize(width: 640, height: 480)
|
|
window.center()
|
|
super.init(window: window)
|
|
window.delegate = self
|
|
|
|
buildUI()
|
|
wireEvents()
|
|
bootstrap()
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
deinit {
|
|
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
|
|
}
|
|
|
|
// MARK: - UI construction
|
|
|
|
private func buildUI() {
|
|
guard let contentView = window?.contentView else { return }
|
|
|
|
// Outer split: left panel (channels+users) | right panel (chat+activity)
|
|
let outerSplit = NSSplitView()
|
|
outerSplit.isVertical = true
|
|
outerSplit.dividerStyle = .thin
|
|
outerSplit.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
// Left panel: channel outline + user table stacked vertically
|
|
let innerSplit = NSSplitView()
|
|
innerSplit.isVertical = false
|
|
innerSplit.dividerStyle = .thin
|
|
|
|
let channelPanel = buildChannelPanel()
|
|
let userPanel = buildUserPanel()
|
|
innerSplit.addArrangedSubview(channelPanel)
|
|
innerSplit.addArrangedSubview(userPanel)
|
|
innerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0)
|
|
innerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1)
|
|
|
|
// Right panel: chat + activity stacked vertically
|
|
let rightSplit = NSSplitView()
|
|
rightSplit.isVertical = false
|
|
rightSplit.dividerStyle = .thin
|
|
let chatPanel = buildChatPanel()
|
|
let activityPanel = buildActivityPanel()
|
|
rightSplit.addArrangedSubview(chatPanel)
|
|
rightSplit.addArrangedSubview(activityPanel)
|
|
rightSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 0)
|
|
rightSplit.setHoldingPriority(.defaultLow, forSubviewAt: 1)
|
|
|
|
outerSplit.addArrangedSubview(innerSplit)
|
|
outerSplit.addArrangedSubview(rightSplit)
|
|
outerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0)
|
|
outerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1)
|
|
|
|
let statusBar = buildStatusBar()
|
|
let voicePanel = buildVoicePanel()
|
|
let composeBar = buildComposeBar()
|
|
|
|
contentView.addSubview(outerSplit)
|
|
contentView.addSubview(statusBar)
|
|
contentView.addSubview(voicePanel)
|
|
contentView.addSubview(composeBar)
|
|
|
|
NSLayoutConstraint.activate([
|
|
outerSplit.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
outerSplit.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
outerSplit.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
outerSplit.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
|
|
|
|
statusBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
statusBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
statusBar.bottomAnchor.constraint(equalTo: voicePanel.topAnchor),
|
|
statusBar.heightAnchor.constraint(equalToConstant: 24),
|
|
|
|
voicePanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
voicePanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
voicePanel.bottomAnchor.constraint(equalTo: composeBar.topAnchor),
|
|
|
|
composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
composeBar.heightAnchor.constraint(equalToConstant: 36),
|
|
])
|
|
}
|
|
|
|
private func buildChannelPanel() -> NSView {
|
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("channel"))
|
|
channelOutlineView.addTableColumn(col)
|
|
channelOutlineView.headerView = nil
|
|
channelOutlineView.outlineTableColumn = col
|
|
channelOutlineView.dataSource = self
|
|
channelOutlineView.delegate = self
|
|
channelOutlineView.doubleAction = #selector(channelDoubleClicked)
|
|
channelOutlineView.target = self
|
|
channelOutlineView.setAccessibilityLabel("Channel list")
|
|
channelOutlineView.setAccessibilityRole(.outline)
|
|
|
|
let sv = NSScrollView()
|
|
sv.documentView = channelOutlineView
|
|
sv.hasVerticalScroller = true
|
|
sv.borderType = .noBorder
|
|
sv.setAccessibilityLabel("Channel list")
|
|
|
|
let menu = NSMenu()
|
|
menu.delegate = self
|
|
channelOutlineView.menu = menu
|
|
|
|
return sv
|
|
}
|
|
|
|
private func buildUserPanel() -> NSView {
|
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user"))
|
|
userTableView.addTableColumn(col)
|
|
userTableView.headerView = nil
|
|
userTableView.dataSource = self
|
|
userTableView.delegate = self
|
|
userTableView.doubleAction = #selector(userDoubleClicked)
|
|
userTableView.target = self
|
|
userTableView.setAccessibilityLabel("Users in current channel")
|
|
userTableView.setAccessibilityRole(.table)
|
|
|
|
let menu = NSMenu()
|
|
menu.delegate = self
|
|
userTableView.menu = menu
|
|
|
|
let sv = NSScrollView()
|
|
sv.documentView = userTableView
|
|
sv.hasVerticalScroller = true
|
|
sv.borderType = .noBorder
|
|
sv.setAccessibilityLabel("Users in current channel")
|
|
return sv
|
|
}
|
|
|
|
private func buildChatPanel() -> NSView {
|
|
chatTextView.setAccessibilityLabel("Chat messages")
|
|
chatTextView.setAccessibilityRole(.textArea)
|
|
chatTextView.textContainerInset = NSSize(width: 4, height: 4)
|
|
chatTextView.isAutomaticQuoteSubstitutionEnabled = false
|
|
|
|
let sv = NSScrollView()
|
|
sv.documentView = chatTextView
|
|
sv.hasVerticalScroller = true
|
|
sv.borderType = .noBorder
|
|
sv.setAccessibilityLabel("Chat messages")
|
|
return sv
|
|
}
|
|
|
|
private func buildActivityPanel() -> NSView {
|
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("activity"))
|
|
activityTableView.addTableColumn(col)
|
|
activityTableView.headerView = nil
|
|
activityTableView.dataSource = self
|
|
activityTableView.delegate = self
|
|
activityTableView.setAccessibilityLabel("Activity log")
|
|
activityTableView.setAccessibilityRole(.table)
|
|
|
|
let sv = NSScrollView()
|
|
sv.documentView = activityTableView
|
|
sv.hasVerticalScroller = true
|
|
sv.borderType = .noBorder
|
|
sv.setAccessibilityLabel("Activity log")
|
|
return sv
|
|
}
|
|
|
|
private func buildStatusBar() -> NSView {
|
|
let bar = NSView()
|
|
bar.translatesAutoresizingMaskIntoConstraints = false
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
statusLabel.textColor = .secondaryLabelColor
|
|
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
bar.addSubview(statusLabel)
|
|
NSLayoutConstraint.activate([
|
|
statusLabel.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8),
|
|
statusLabel.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8),
|
|
statusLabel.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
|
])
|
|
let sep = NSBox()
|
|
sep.boxType = .separator
|
|
sep.translatesAutoresizingMaskIntoConstraints = false
|
|
bar.addSubview(sep)
|
|
NSLayoutConstraint.activate([
|
|
sep.topAnchor.constraint(equalTo: bar.topAnchor),
|
|
sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
|
|
sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
|
|
sep.heightAnchor.constraint(equalToConstant: 1),
|
|
])
|
|
return bar
|
|
}
|
|
|
|
private func buildVoicePanel() -> NSView {
|
|
let panel = NSView()
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false
|
|
panel.addSubview(sep)
|
|
|
|
// Mic + Screen buttons
|
|
micToggleButton.title = "Join Voice"
|
|
micToggleButton.bezelStyle = .rounded
|
|
micToggleButton.target = self; micToggleButton.action = #selector(micToggleClicked)
|
|
micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio")
|
|
|
|
screenAudioButton.title = "Share Screen Audio"
|
|
screenAudioButton.bezelStyle = .rounded
|
|
screenAudioButton.target = self; screenAudioButton.action = #selector(screenAudioClicked)
|
|
screenAudioButton.setAccessibilityLabel("Share Screen Audio")
|
|
|
|
// Mute/Deafen
|
|
muteCheckbox.target = self; muteCheckbox.action = #selector(muteChanged)
|
|
muteCheckbox.setAccessibilityLabel("Mute microphone")
|
|
muteCheckbox.isEnabled = false
|
|
deafenCheckbox.target = self; deafenCheckbox.action = #selector(deafenChanged)
|
|
deafenCheckbox.setAccessibilityLabel("Deafen — mute all incoming audio")
|
|
deafenCheckbox.isEnabled = false
|
|
|
|
// Input mode
|
|
inputModeControl.target = self; inputModeControl.action = #selector(inputModeChanged)
|
|
inputModeControl.selectedSegment = 0
|
|
inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On")
|
|
|
|
// VAD slider
|
|
vadLabel.setAccessibilityLabel("VAD sensitivity")
|
|
vadSlider.target = self; vadSlider.action = #selector(vadSliderChanged)
|
|
vadSlider.setAccessibilityLabel("Voice activation sensitivity")
|
|
vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less")
|
|
|
|
// PTT
|
|
pttKeyLabel.setAccessibilityLabel("Current PTT key")
|
|
changePttButton.title = "Change…"
|
|
changePttButton.bezelStyle = .rounded
|
|
changePttButton.target = self; changePttButton.action = #selector(changePttClicked)
|
|
changePttButton.setAccessibilityLabel("Change push-to-talk key")
|
|
pttKeyLabel.isHidden = true
|
|
changePttButton.isHidden = true
|
|
|
|
// Device picker
|
|
let deviceLabel = NSTextField(labelWithString: "Input:")
|
|
devicePicker.setAccessibilityLabel("Input audio device")
|
|
refreshDevicesButton.title = "↺"
|
|
refreshDevicesButton.bezelStyle = .rounded
|
|
refreshDevicesButton.target = self; refreshDevicesButton.action = #selector(refreshDevicesClicked)
|
|
refreshDevicesButton.setAccessibilityLabel("Refresh device list")
|
|
refreshDevicesButton.toolTip = "Refresh"
|
|
|
|
// Level meter
|
|
levelMeter.setAccessibilityLabel("Microphone input level")
|
|
levelMeter.setAccessibilityHelp("Shows current microphone volume level")
|
|
|
|
// Layout all voice controls
|
|
let row1 = hstack([micToggleButton, screenAudioButton, muteCheckbox, deafenCheckbox])
|
|
let row2 = hstack([inputModeControl, vadLabel, vadSlider, pttKeyLabel, changePttButton])
|
|
let row3 = hstack([deviceLabel, devicePicker, refreshDevicesButton, levelMeter])
|
|
|
|
[sep, row1, row2, row3].forEach { v in
|
|
v.translatesAutoresizingMaskIntoConstraints = false
|
|
panel.addSubview(v)
|
|
}
|
|
|
|
NSLayoutConstraint.activate([
|
|
sep.topAnchor.constraint(equalTo: panel.topAnchor),
|
|
sep.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
|
|
sep.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
|
|
sep.heightAnchor.constraint(equalToConstant: 1),
|
|
|
|
row1.topAnchor.constraint(equalTo: sep.bottomAnchor, constant: 6),
|
|
row1.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8),
|
|
row1.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8),
|
|
|
|
row2.topAnchor.constraint(equalTo: row1.bottomAnchor, constant: 4),
|
|
row2.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8),
|
|
row2.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8),
|
|
|
|
row3.topAnchor.constraint(equalTo: row2.bottomAnchor, constant: 4),
|
|
row3.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8),
|
|
row3.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8),
|
|
row3.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -6),
|
|
|
|
levelMeter.widthAnchor.constraint(equalToConstant: 100),
|
|
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
|
])
|
|
return panel
|
|
}
|
|
|
|
private func buildComposeBar() -> NSView {
|
|
let bar = NSView()
|
|
bar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
scopePicker.setAccessibilityLabel("Message scope — channel or private")
|
|
composeField.placeholderString = "Type a message…"
|
|
composeField.setAccessibilityLabel("Compose message")
|
|
composeField.target = self; composeField.action = #selector(sendClicked)
|
|
|
|
sendButton.title = "Send"
|
|
sendButton.bezelStyle = .rounded
|
|
sendButton.target = self; sendButton.action = #selector(sendClicked)
|
|
sendButton.setAccessibilityLabel("Send message")
|
|
sendButton.keyEquivalent = "\r"
|
|
|
|
let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false
|
|
[sep, scopePicker, composeField, sendButton].forEach { v in
|
|
v.translatesAutoresizingMaskIntoConstraints = false
|
|
bar.addSubview(v)
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
sep.topAnchor.constraint(equalTo: bar.topAnchor),
|
|
sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
|
|
sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
|
|
sep.heightAnchor.constraint(equalToConstant: 1),
|
|
|
|
scopePicker.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8),
|
|
scopePicker.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
|
|
|
composeField.leadingAnchor.constraint(equalTo: scopePicker.trailingAnchor, constant: 6),
|
|
composeField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6),
|
|
composeField.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
|
|
|
sendButton.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8),
|
|
sendButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
|
sendButton.widthAnchor.constraint(equalToConstant: 60),
|
|
])
|
|
return bar
|
|
}
|
|
|
|
private func hstack(_ views: [NSView]) -> NSStackView {
|
|
let s = NSStackView(views: views)
|
|
s.orientation = .horizontal
|
|
s.spacing = 8
|
|
s.alignment = .centerY
|
|
return s
|
|
}
|
|
|
|
// MARK: - Event wiring
|
|
|
|
private func wireEvents() {
|
|
client.onEvent = { [weak self] event in self?.handleEvent(event) }
|
|
client.onLevel = { [weak self] streamId, rms in self?.handleLevel(streamId, rms) }
|
|
|
|
pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
|
|
guard let self, self.inputModeControl.selectedSegment == 1,
|
|
self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event }
|
|
self.client.setPushToTalk(event.type == .keyDown)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Bootstrap
|
|
|
|
private func bootstrap() {
|
|
channels = client.listChannels()
|
|
let allUsers = client.listUsers()
|
|
users.removeAll()
|
|
for u in allUsers {
|
|
users[u.id] = u
|
|
if u.id == selfUserId { currentChannelId = u.channelId }
|
|
}
|
|
ownPermissions = client.getPermissions()
|
|
|
|
NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
|
|
channels.count, allUsers.count,
|
|
ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)
|
|
|
|
refreshChannelTree()
|
|
refreshUserList()
|
|
rebuildScopePicker()
|
|
updateStatusLabel()
|
|
loadInputDevices()
|
|
buildAdminMenu()
|
|
addActivity("Connected to server as \(nickname)")
|
|
}
|
|
|
|
// MARK: - Event handling
|
|
|
|
private func handleEvent(_ event: VoiceCatEvent) {
|
|
NSLog("[VoiceCatMac] event type=%d result=%d userId=%u channelId=%u streamId=%u text=%@",
|
|
event.type.rawValue, event.result.rawValue, event.userId, event.channelId,
|
|
event.streamId, event.text ?? "(nil)")
|
|
switch event.type {
|
|
case .channelList:
|
|
channels = client.listChannels()
|
|
let allUsers = client.listUsers()
|
|
users.removeAll()
|
|
for u in allUsers {
|
|
users[u.id] = u
|
|
if u.id == selfUserId { currentChannelId = u.channelId }
|
|
}
|
|
refreshChannelTree(); refreshUserList(); rebuildScopePicker()
|
|
|
|
case .userJoined:
|
|
let u = User(id: event.userId,
|
|
nickname: event.text ?? "User#\(event.userId)",
|
|
isGuest: true,
|
|
channelId: event.channelId,
|
|
selfMicMuted: false, selfDeafened: false,
|
|
serverMuted: false, serverDeafened: false)
|
|
users[event.userId] = u
|
|
refreshUserList(); rebuildScopePicker()
|
|
if event.channelId == currentChannelId && event.userId != selfUserId {
|
|
addActivity("\(u.nickname) joined the channel")
|
|
}
|
|
|
|
case .userLeft:
|
|
let nick = users[event.userId]?.nickname ?? "User#\(event.userId)"
|
|
let wasHere = users[event.userId]?.channelId == currentChannelId && event.userId != selfUserId
|
|
users.removeValue(forKey: event.userId)
|
|
talkingUsers.remove(event.userId)
|
|
refreshUserList(); rebuildScopePicker()
|
|
if wasHere { addActivity("\(nick) left the channel") }
|
|
|
|
case .userUpdated:
|
|
let allUsers = client.listUsers()
|
|
users.removeAll()
|
|
for u in allUsers {
|
|
users[u.id] = u
|
|
if u.id == selfUserId {
|
|
currentChannelId = u.channelId
|
|
applyServerMuteState(muted: u.serverMuted, deafened: u.serverDeafened)
|
|
}
|
|
}
|
|
refreshChannelTree(); refreshUserList(); rebuildScopePicker()
|
|
|
|
case .joinResult:
|
|
if event.result == .ok {
|
|
currentChannelId = event.channelId
|
|
if let self_ = users[selfUserId] {
|
|
users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest,
|
|
channelId: event.channelId,
|
|
selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened,
|
|
serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened)
|
|
}
|
|
refreshChannelTree(); refreshUserList(); updateStatusLabel()
|
|
let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)"
|
|
addActivity("Joined \(name)")
|
|
NSAccessibility.post(element: activityTableView, notification: .announcementRequested,
|
|
userInfo: [.announcement: "Joined \(name)", .priority: NSAccessibilityPriorityLevel.medium])
|
|
} else {
|
|
addActivity("Could not join channel: \(event.text ?? "\(event.result)")")
|
|
}
|
|
|
|
case .genericResult:
|
|
let prefix = event.result == .ok ? "Success" : "Failed"
|
|
let detail = event.text.map { ": \($0)" } ?? ""
|
|
addActivity("\(prefix)\(detail) (\(event.result))")
|
|
|
|
case .accountList:
|
|
addActivity("Account list updated")
|
|
|
|
case .textMessage:
|
|
appendChatMessage(event)
|
|
|
|
case .talkState:
|
|
let talking = event.u32a == 1
|
|
if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
|
|
refreshUserList()
|
|
if talking && event.userId != selfUserId,
|
|
let u = users[event.userId], u.channelId == currentChannelId {
|
|
addActivity("\(u.nickname) started talking")
|
|
NSAccessibility.post(element: activityTableView, notification: .announcementRequested,
|
|
userInfo: [.announcement: "\(u.nickname) started talking",
|
|
.priority: NSAccessibilityPriorityLevel.medium])
|
|
}
|
|
|
|
case .streamStarted:
|
|
guard let u = users[event.userId], u.channelId == currentChannelId else { break }
|
|
let streams = client.listUserStreams(event.userId)
|
|
let kind = streams.first(where: { $0.id == event.streamId })?.kind
|
|
let kindStr = kind == .screenAudio ? "screen audio" : kind == .auxDevice ? "aux device" : "microphone"
|
|
addActivity("\(u.nickname) started \(kindStr) stream")
|
|
|
|
case .streamStopped:
|
|
if let u = users[event.userId], u.channelId == currentChannelId {
|
|
addActivity("\(u.nickname) stopped a stream")
|
|
}
|
|
|
|
case .disconnected:
|
|
handleDisconnected(event)
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleLevel(_ streamId: UInt32, _ rms: Float) {
|
|
guard streamId == micStreamId else { return }
|
|
levelMeter.doubleValue = min(100, Double(rms * 400))
|
|
levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent")
|
|
}
|
|
|
|
// MARK: - Chat
|
|
|
|
private func appendChatMessage(_ event: VoiceCatEvent) {
|
|
let time: String
|
|
if event.timestampUnixMs > 0 {
|
|
let date = Date(timeIntervalSince1970: Double(event.timestampUnixMs) / 1000.0)
|
|
time = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short)
|
|
} else {
|
|
time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
|
|
}
|
|
let sender = nickname(for: event.userId)
|
|
let prefix: String
|
|
if event.textScope == .private {
|
|
prefix = event.userId == selfUserId
|
|
? "(private to \(nickname(for: event.channelId))) "
|
|
: "(private) "
|
|
} else { prefix = "" }
|
|
let line = "[\(time)] \(prefix)\(sender): \(event.text ?? "")\n"
|
|
chatTextView.textStorage?.append(NSAttributedString(string: line))
|
|
chatTextView.scrollToEndOfDocument(nil)
|
|
if event.textScope == .private && event.userId != selfUserId {
|
|
addActivity("Private message from \(sender)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Activity log
|
|
|
|
private func addActivity(_ text: String) {
|
|
let entry = "[\(DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short))] \(text)"
|
|
activityLog.append(entry)
|
|
if activityLog.count > 200 { activityLog.removeFirst() }
|
|
activityTableView.reloadData()
|
|
activityTableView.scrollRowToVisible(activityLog.count - 1)
|
|
}
|
|
|
|
// MARK: - UI refresh
|
|
|
|
private func refreshChannelTree() {
|
|
channelTree = buildChannelTree(channels)
|
|
channelOutlineView.reloadData()
|
|
channelOutlineView.expandItem(nil, expandChildren: true)
|
|
}
|
|
|
|
private func buildChannelTree(_ channels: [Channel]) -> [ChannelNode] {
|
|
var nodeMap: [UInt32: ChannelNode] = [:]
|
|
for ch in channels { nodeMap[ch.id] = ChannelNode(ch) }
|
|
var roots: [ChannelNode] = []
|
|
for ch in channels.sorted(by: { $0.name < $1.name }) {
|
|
if ch.parentId == 0 {
|
|
roots.append(nodeMap[ch.id]!)
|
|
} else if let parent = nodeMap[ch.parentId] {
|
|
parent.children.append(nodeMap[ch.id]!)
|
|
}
|
|
}
|
|
return roots.sorted { $0.channel.name < $1.channel.name }
|
|
}
|
|
|
|
private func refreshUserList() {
|
|
displayedUsers = users.values
|
|
.filter { $0.channelId == currentChannelId }
|
|
.sorted { $0.nickname < $1.nickname }
|
|
userTableView.reloadData()
|
|
updateStatusLabel()
|
|
}
|
|
|
|
private func rebuildScopePicker() {
|
|
let prev = scopePicker.selectedItem?.representedObject as? UInt32
|
|
scopePicker.removeAllItems()
|
|
let chItem = NSMenuItem(title: "Channel", action: nil, keyEquivalent: "")
|
|
chItem.representedObject = nil as UInt32?
|
|
scopePicker.menu?.addItem(chItem)
|
|
for u in users.values.sorted(by: { $0.nickname < $1.nickname }) where u.id != selfUserId {
|
|
let item = NSMenuItem(title: "Private: \(u.nickname)", action: nil, keyEquivalent: "")
|
|
item.representedObject = u.id
|
|
scopePicker.menu?.addItem(item)
|
|
}
|
|
if let prev {
|
|
for item in scopePicker.itemArray where (item.representedObject as? UInt32) == prev {
|
|
scopePicker.select(item); break
|
|
}
|
|
} else {
|
|
scopePicker.selectItem(at: 0)
|
|
}
|
|
}
|
|
|
|
private func updateStatusLabel() {
|
|
var suffix = ""
|
|
if serverMuted { suffix += " [server muted]" }
|
|
if serverDeafened { suffix += " [server deafened]" }
|
|
if currentChannelId == 0 {
|
|
statusLabel.stringValue = "Connected as \(nickname)\(suffix) — not in a channel."
|
|
return
|
|
}
|
|
let chanName = channels.first(where: { $0.id == currentChannelId })?.name ?? "Channel #\(currentChannelId)"
|
|
let count = users.values.filter { $0.channelId == currentChannelId }.count
|
|
statusLabel.stringValue = "Connected as \(nickname)\(suffix) — \(chanName) (\(count) user\(count == 1 ? "" : "s"))"
|
|
}
|
|
|
|
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
|
if muted && !serverMuted { addActivity("You have been server-muted") }
|
|
if deafened && !serverDeafened { addActivity("You have been server-deafened") }
|
|
if !muted && serverMuted { addActivity("Server mute cleared") }
|
|
if !deafened && serverDeafened { addActivity("Server deafen cleared") }
|
|
serverMuted = muted; serverDeafened = deafened
|
|
updateStatusLabel()
|
|
}
|
|
|
|
private func handleDisconnected(_ event: VoiceCatEvent) {
|
|
let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
|
|
statusLabel.stringValue = msg
|
|
addActivity(msg)
|
|
channelTree = []; channelOutlineView.reloadData()
|
|
displayedUsers = []; userTableView.reloadData()
|
|
users.removeAll(); talkingUsers.removeAll()
|
|
currentChannelId = 0; micStreamId = 0; screenStreamId = 0
|
|
composeField.isEnabled = false; sendButton.isEnabled = false
|
|
micToggleButton.isEnabled = false; screenAudioButton.isEnabled = false
|
|
}
|
|
|
|
// MARK: - Admin menu
|
|
|
|
private func buildAdminMenu() {
|
|
guard ownPermissions.canAdminAccounts || ownPermissions.isAdmin else { return }
|
|
let adminMenu = NSMenu(title: "Admin")
|
|
let accountsItem = NSMenuItem(title: "Server accounts…", action: #selector(openAccounts), keyEquivalent: "")
|
|
accountsItem.target = self
|
|
adminMenu.addItem(accountsItem)
|
|
let topItem = NSMenuItem(title: "Admin", action: nil, keyEquivalent: "")
|
|
topItem.submenu = adminMenu
|
|
adminMenuItem = topItem
|
|
NSApp.mainMenu?.addItem(topItem)
|
|
}
|
|
|
|
@objc private func openAccounts() {
|
|
presentSheet(AccountsSheet(client: client))
|
|
}
|
|
|
|
// MARK: - Channel actions
|
|
|
|
@objc private func channelDoubleClicked() {
|
|
guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return }
|
|
joinChannelRequest(node.channel.id)
|
|
}
|
|
|
|
private func joinChannelRequest(_ channelId: UInt32) {
|
|
guard channelId != currentChannelId else { return }
|
|
let channel = channels.first(where: { $0.id == channelId })
|
|
if channel?.passwordProtected == true {
|
|
let sheet = PasswordPromptSheet(prompt: "Password for channel \"\(channel!.name)\":")
|
|
sheet.onComplete = { [weak self] pw in
|
|
self?.client.joinChannel(channelId, password: pw)
|
|
}
|
|
presentSheet(sheet)
|
|
} else {
|
|
client.joinChannel(channelId, password: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - User actions
|
|
|
|
@objc private func userDoubleClicked() {
|
|
openUserTuning(row: userTableView.clickedRow)
|
|
}
|
|
|
|
private func openUserTuning(row: Int) {
|
|
guard row >= 0, row < displayedUsers.count else { return }
|
|
let user = displayedUsers[row]
|
|
presentSheet(PerUserTuningSheet(client: client, userId: user.id, nickname: user.nickname))
|
|
}
|
|
|
|
// MARK: - Voice controls
|
|
|
|
@objc private func micToggleClicked() {
|
|
if micStreamId == 0 {
|
|
let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone"))
|
|
if result == .ok {
|
|
micStreamId = streamId
|
|
if let devId = selectedDeviceId(), !isDefaultDevice(devId) {
|
|
client.setInputDevice(streamId: streamId, deviceId: devId)
|
|
}
|
|
client.setInputMode(currentInputMode())
|
|
if inputModeControl.selectedSegment == 0 {
|
|
client.setVadThreshold(vadThresholdFromSlider())
|
|
}
|
|
micToggleButton.title = "Leave Voice"
|
|
micToggleButton.setAccessibilityLabel("Leave Voice — stop sending microphone audio")
|
|
muteCheckbox.isEnabled = true; deafenCheckbox.isEnabled = true
|
|
addActivity("Joined voice — microphone active")
|
|
NSAccessibility.post(element: activityTableView, notification: .announcementRequested,
|
|
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
|
|
} else {
|
|
addActivity("Failed to start microphone: \(result)")
|
|
}
|
|
} else {
|
|
client.setPushToTalk(false)
|
|
client.stopStream(micStreamId)
|
|
micStreamId = 0
|
|
levelMeter.doubleValue = 0
|
|
micToggleButton.title = "Join Voice"
|
|
micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio")
|
|
muteCheckbox.isEnabled = false; deafenCheckbox.isEnabled = false
|
|
addActivity("Left voice")
|
|
}
|
|
}
|
|
|
|
@objc private func screenAudioClicked() {
|
|
if screenStreamId == 0 {
|
|
let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
|
|
if result == .ok {
|
|
screenStreamId = streamId
|
|
screenAudioButton.title = "Stop Screen Audio"
|
|
addActivity("Started sharing screen audio")
|
|
} else {
|
|
addActivity("Failed to start screen audio: \(result)")
|
|
}
|
|
} else {
|
|
client.stopStream(screenStreamId)
|
|
screenStreamId = 0
|
|
screenAudioButton.title = "Share Screen Audio"
|
|
addActivity("Stopped sharing screen audio")
|
|
}
|
|
}
|
|
|
|
@objc private func muteChanged() {
|
|
client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on)
|
|
}
|
|
|
|
@objc private func deafenChanged() {
|
|
client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on)
|
|
}
|
|
|
|
@objc private func inputModeChanged() {
|
|
let seg = inputModeControl.selectedSegment
|
|
vadLabel.isHidden = seg != 0; vadSlider.isHidden = seg != 0
|
|
pttKeyLabel.isHidden = seg != 1; changePttButton.isHidden = seg != 1
|
|
if micStreamId != 0 {
|
|
client.setInputMode(currentInputMode())
|
|
if seg == 0 { client.setVadThreshold(vadThresholdFromSlider()) }
|
|
if seg == 1 { client.setPushToTalk(false) }
|
|
}
|
|
}
|
|
|
|
@objc private func vadSliderChanged() {
|
|
if micStreamId != 0 && inputModeControl.selectedSegment == 0 {
|
|
client.setVadThreshold(vadThresholdFromSlider())
|
|
}
|
|
}
|
|
|
|
@objc private func changePttClicked() {
|
|
let sheet = PttKeyCaptureSheet(currentKeyCode: pttKeyCode)
|
|
sheet.onComplete = { [weak self] keyCode in
|
|
guard let self, let keyCode else { return }
|
|
self.pttKeyCode = keyCode
|
|
self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))"
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
@objc private func refreshDevicesClicked() { loadInputDevices() }
|
|
|
|
private func loadInputDevices() {
|
|
let devices = client.listDevices(.input)
|
|
devicePicker.removeAllItems()
|
|
for d in devices {
|
|
let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "")
|
|
item.representedObject = d.id
|
|
devicePicker.menu?.addItem(item)
|
|
}
|
|
if let def = devices.first(where: { $0.isDefault }) {
|
|
devicePicker.select(devicePicker.item(withTitle: def.name))
|
|
} else if devicePicker.numberOfItems > 0 {
|
|
devicePicker.selectItem(at: 0)
|
|
}
|
|
}
|
|
|
|
private func selectedDeviceId() -> String? { devicePicker.selectedItem?.representedObject as? String }
|
|
private func isDefaultDevice(_ id: String) -> Bool {
|
|
client.listDevices(.input).first(where: { $0.id == id })?.isDefault == true
|
|
}
|
|
|
|
private func currentInputMode() -> VoiceCatInputMode {
|
|
switch inputModeControl.selectedSegment {
|
|
case 1: return .pushToTalk
|
|
case 2: return .alwaysOn
|
|
default: return .voiceActivation
|
|
}
|
|
}
|
|
|
|
private func vadThresholdFromSlider() -> Float {
|
|
0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0)
|
|
}
|
|
|
|
// MARK: - Text send
|
|
|
|
@objc private func sendClicked() {
|
|
let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces)
|
|
guard !msg.isEmpty else { return }
|
|
if let targetId = scopePicker.selectedItem?.representedObject as? UInt32 {
|
|
client.sendText(scope: .private, targetId: targetId, text: msg)
|
|
} else {
|
|
guard currentChannelId != 0 else { return }
|
|
client.sendText(scope: .channel, targetId: currentChannelId, text: msg)
|
|
}
|
|
composeField.stringValue = ""
|
|
}
|
|
|
|
// MARK: - M5: Moderation helpers
|
|
|
|
private func moveUser(_ user: User) {
|
|
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
|
|
sheet.onComplete = { [weak self] channelId in
|
|
guard let channelId else { return }
|
|
self?.client.moveUser(user.id, toChannel: channelId)
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
private func kickUser(_ user: User) {
|
|
let sheet = InputSheet(title: "Kick user", prompt: "Reason:", defaultValue: "Kicked by admin")
|
|
sheet.onComplete = { [weak self] reason in
|
|
self?.client.kickUser(user.id, reason: reason)
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
private func banUser(_ user: User) {
|
|
let sheet = BanUserSheet(nickname: user.nickname)
|
|
sheet.onComplete = { [weak self] (reason, expiresMs) in
|
|
self?.client.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
private func setPermissions(_ user: User) {
|
|
let sheet = PermissionsSheet(nickname: user.nickname, current: Permissions(
|
|
canCreateTempChannel: false, canKick: false, canBan: false,
|
|
canMoveUsers: false, canAdminAccounts: false, isAdmin: false))
|
|
sheet.onComplete = { [weak self] perms in
|
|
guard let perms else { return }
|
|
self?.client.setPermission(user.id, perms: perms)
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
private func toggleServerMute(_ user: User) {
|
|
client.setServerMute(user.id, muted: !user.serverMuted, deafened: user.serverDeafened)
|
|
}
|
|
|
|
private func toggleServerDeafen(_ user: User) {
|
|
client.setServerMute(user.id, muted: user.serverMuted, deafened: !user.serverDeafened)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func nickname(for userId: UInt32) -> String {
|
|
if userId == selfUserId { return nickname }
|
|
return users[userId]?.nickname ?? "User#\(userId)"
|
|
}
|
|
|
|
private func presentSheet(_ vc: NSViewController) {
|
|
if let cvc = window?.contentViewController {
|
|
cvc.presentAsSheet(vc)
|
|
} else {
|
|
let cvc = NSViewController()
|
|
cvc.view = window!.contentView!
|
|
window?.contentViewController = cvc
|
|
cvc.presentAsSheet(vc)
|
|
}
|
|
}
|
|
|
|
private func selectedUserInTable() -> User? {
|
|
let row = userTableView.selectedRow
|
|
guard row >= 0, row < displayedUsers.count else { return nil }
|
|
return displayedUsers[row]
|
|
}
|
|
|
|
// MARK: - NSWindowDelegate
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
|
|
if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) }
|
|
client.setPushToTalk(false)
|
|
if screenStreamId != 0 { client.stopStream(screenStreamId) }
|
|
if micStreamId != 0 { client.stopStream(micStreamId) }
|
|
client.onLevel = nil
|
|
client.onEvent = nil
|
|
client.disconnect()
|
|
}
|
|
}
|
|
|
|
// MARK: - NSOutlineView DataSource / Delegate
|
|
|
|
extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate {
|
|
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
|
|
if item == nil { return channelTree.count }
|
|
return (item as! ChannelNode).children.count
|
|
}
|
|
|
|
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
|
|
if item == nil { return channelTree[index] }
|
|
return (item as! ChannelNode).children[index]
|
|
}
|
|
|
|
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
|
|
!(item as! ChannelNode).children.isEmpty
|
|
}
|
|
|
|
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
|
|
let node = item as! ChannelNode
|
|
let ch = node.channel
|
|
var label = ch.name
|
|
if ch.passwordProtected { label += " [pw]" }
|
|
if ch.id == currentChannelId { label += " ►" }
|
|
let id = NSUserInterfaceItemIdentifier("channelCell")
|
|
let cell = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
|
?? makeTextCellView(identifier: id)
|
|
cell.textField?.stringValue = label
|
|
cell.setAccessibilityLabel(label)
|
|
return cell
|
|
}
|
|
|
|
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { true }
|
|
}
|
|
|
|
// MARK: - NSTableView DataSource / Delegate
|
|
|
|
extension MainWindowController: NSTableViewDataSource, NSTableViewDelegate {
|
|
func numberOfRows(in tableView: NSTableView) -> Int {
|
|
tableView === userTableView ? displayedUsers.count : activityLog.count
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
|
let id = NSUserInterfaceItemIdentifier("cell")
|
|
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
|
?? makeTextCellView(identifier: id)
|
|
if tableView === userTableView {
|
|
let user = displayedUsers[row]
|
|
var label = user.nickname
|
|
if user.id == selfUserId { label += " (you)" }
|
|
if talkingUsers.contains(user.id) { label += " (talking)" }
|
|
if user.selfMicMuted || user.serverMuted { label += " (muted)" }
|
|
if user.selfDeafened || user.serverDeafened { label += " (deafened)" }
|
|
cell.textField?.stringValue = label
|
|
cell.setAccessibilityLabel(label)
|
|
} else {
|
|
cell.textField?.stringValue = activityLog[row]
|
|
}
|
|
return cell
|
|
}
|
|
|
|
private func makeTextCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
|
let cell = NSTableCellView()
|
|
cell.identifier = identifier
|
|
let tf = NSTextField(labelWithString: "")
|
|
tf.translatesAutoresizingMaskIntoConstraints = false
|
|
cell.addSubview(tf); cell.textField = tf
|
|
NSLayoutConstraint.activate([
|
|
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
|
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
|
|
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
|
])
|
|
return cell
|
|
}
|
|
}
|
|
|
|
// MARK: - NSMenuDelegate (context menus)
|
|
|
|
extension MainWindowController: NSMenuDelegate {
|
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
|
menu.removeAllItems()
|
|
if menu === channelOutlineView.menu {
|
|
buildChannelContextMenu(menu)
|
|
} else if menu === userTableView.menu {
|
|
buildUserContextMenu(menu)
|
|
}
|
|
}
|
|
|
|
private func buildChannelContextMenu(_ menu: NSMenu) {
|
|
let row = channelOutlineView.clickedRow
|
|
if row >= 0, let node = channelOutlineView.item(atRow: row) as? ChannelNode {
|
|
menu.addItem(withTitle: "Join", action: #selector(channelContextJoin), keyEquivalent: "").target = self
|
|
menu.addItem(.separator())
|
|
if ownPermissions.isAdmin {
|
|
let edit = menu.addItem(withTitle: "Edit channel…", action: #selector(channelContextEdit), keyEquivalent: "")
|
|
edit.target = self; edit.representedObject = node
|
|
let del = menu.addItem(withTitle: "Delete channel…", action: #selector(channelContextDelete), keyEquivalent: "")
|
|
del.target = self; del.representedObject = node
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
if ownPermissions.canCreateTempChannel || ownPermissions.isAdmin {
|
|
menu.addItem(withTitle: "Create channel…", action: #selector(channelContextCreate), keyEquivalent: "").target = self
|
|
}
|
|
}
|
|
|
|
private func buildUserContextMenu(_ menu: NSMenu) {
|
|
let row = userTableView.clickedRow
|
|
guard row >= 0, row < displayedUsers.count else { return }
|
|
let user = displayedUsers[row]
|
|
let isSelf = user.id == selfUserId
|
|
|
|
menu.addItem(withTitle: "Adjust volume and noise settings…",
|
|
action: #selector(userContextTune), keyEquivalent: "").target = self
|
|
|
|
if !isSelf {
|
|
menu.addItem(.separator())
|
|
if ownPermissions.canMoveUsers || ownPermissions.isAdmin {
|
|
menu.addItem(withTitle: "Move to channel…", action: #selector(userContextMove), keyEquivalent: "").target = self
|
|
}
|
|
if ownPermissions.canKick || ownPermissions.isAdmin {
|
|
menu.addItem(withTitle: "Kick…", action: #selector(userContextKick), keyEquivalent: "").target = self
|
|
}
|
|
if ownPermissions.canBan || ownPermissions.isAdmin {
|
|
menu.addItem(withTitle: "Ban…", action: #selector(userContextBan), keyEquivalent: "").target = self
|
|
}
|
|
if ownPermissions.isAdmin {
|
|
menu.addItem(.separator())
|
|
menu.addItem(withTitle: user.serverMuted ? "Server unmute" : "Server mute",
|
|
action: #selector(userContextServerMute), keyEquivalent: "").target = self
|
|
menu.addItem(withTitle: user.serverDeafened ? "Server undeafen" : "Server deafen",
|
|
action: #selector(userContextServerDeafen), keyEquivalent: "").target = self
|
|
menu.addItem(withTitle: "Set permissions…", action: #selector(userContextPermissions), keyEquivalent: "").target = self
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func channelContextJoin() {
|
|
guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return }
|
|
joinChannelRequest(node.channel.id)
|
|
}
|
|
|
|
@objc private func channelContextCreate() {
|
|
let sheet = ChannelEditSheet(channels: channels, editing: nil)
|
|
sheet.onComplete = { [weak self] info in
|
|
guard let info else { return }
|
|
self?.client.createChannel(info)
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
@objc private func channelContextEdit(_ sender: NSMenuItem) {
|
|
guard let node = sender.representedObject as? ChannelNode else { return }
|
|
let ch = node.channel
|
|
let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name,
|
|
topic: ch.topic, passwordProtected: ch.passwordProtected,
|
|
password: nil, maxUsers: ch.maxUsers,
|
|
sortOrder: 0, audio: AudioConfig())
|
|
let sheet = ChannelEditSheet(channels: channels, editing: info)
|
|
sheet.onComplete = { [weak self] edited in
|
|
guard let edited else { return }
|
|
self?.client.editChannel(edited)
|
|
}
|
|
presentSheet(sheet)
|
|
}
|
|
|
|
@objc private func channelContextDelete(_ sender: NSMenuItem) {
|
|
guard let node = sender.representedObject as? ChannelNode else { return }
|
|
let alert = NSAlert()
|
|
alert.messageText = "Delete channel?"
|
|
alert.informativeText = "Delete \"\(node.channel.name)\"? This cannot be undone."
|
|
alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel")
|
|
alert.alertStyle = .warning
|
|
guard let window else { return }
|
|
alert.beginSheetModal(for: window) { [weak self] response in
|
|
if response == .alertFirstButtonReturn {
|
|
self?.client.deleteChannel(node.channel.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func userContextTune() { openUserTuning(row: userTableView.clickedRow) }
|
|
@objc private func userContextMove() {
|
|
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
|
|
moveUser(u)
|
|
}
|
|
@objc private func userContextKick() {
|
|
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
|
|
kickUser(u)
|
|
}
|
|
@objc private func userContextBan() {
|
|
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
|
|
banUser(u)
|
|
}
|
|
@objc private func userContextServerMute() {
|
|
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
|
|
toggleServerMute(u)
|
|
}
|
|
@objc private func userContextServerDeafen() {
|
|
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
|
|
toggleServerDeafen(u)
|
|
}
|
|
@objc private func userContextPermissions() {
|
|
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
|
|
setPermissions(u)
|
|
}
|
|
|
|
private func selectedUserAt(_ row: Int) -> User? {
|
|
guard row >= 0, row < displayedUsers.count else { return nil }
|
|
return displayedUsers[row]
|
|
}
|
|
}
|
|
|
|
// MARK: - PTT key name helper
|
|
|
|
private func keyCodeName(_ keyCode: UInt16) -> String {
|
|
let map: [UInt16: String] = [
|
|
0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9",
|
|
0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12",
|
|
0x71: "F15", 0x72: "Help", 0x73: "Home", 0x74: "PgUp", 0x75: "Del",
|
|
0x76: "F4", 0x77: "End", 0x78: "F2", 0x79: "PgDn", 0x7A: "F1",
|
|
]
|
|
return map[keyCode] ?? "Key\(keyCode)"
|
|
}
|