2026-06-18 15:26:47 +02:00
|
|
|
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() }
|
|
|
|
|
|
2026-06-18 15:52:13 +02:00
|
|
|
deinit {
|
|
|
|
|
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 15:26:47 +02:00
|
|
|
// 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
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
innerSplit.translatesAutoresizingMaskIntoConstraints = false
|
2026-06-18 15:26:47 +02:00
|
|
|
|
|
|
|
|
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
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
rightSplit.translatesAutoresizingMaskIntoConstraints = false
|
2026-06-18 15:26:47 +02:00
|
|
|
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),
|
|
|
|
|
])
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
|
|
|
|
|
// Set initial divider positions so panels get reasonable space instead of
|
|
|
|
|
// collapsing to zero. These are applied after the window is on screen.
|
|
|
|
|
DispatchQueue.main.async { [weak self, weak outerSplit, weak innerSplit, weak rightSplit] in
|
|
|
|
|
// outerSplit is vertical (left|right): divide at 40% from left
|
|
|
|
|
outerSplit?.setPosition(360, ofDividerAt: 0)
|
|
|
|
|
// innerSplit is horizontal (channels on top, users below): divide at 60% from top
|
|
|
|
|
innerSplit?.setPosition(260, ofDividerAt: 0)
|
|
|
|
|
// rightSplit is horizontal (chat on top, activity below): divide at 70% from top
|
|
|
|
|
rightSplit?.setPosition(280, ofDividerAt: 0)
|
|
|
|
|
}
|
2026-06-18 15:26:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
let sv = NSScrollView()
|
|
|
|
|
sv.documentView = channelOutlineView
|
|
|
|
|
sv.hasVerticalScroller = true
|
|
|
|
|
sv.borderType = .noBorder
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
sv.translatesAutoresizingMaskIntoConstraints = false
|
2026-06-18 15:26:47 +02:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
let menu = NSMenu()
|
|
|
|
|
menu.delegate = self
|
|
|
|
|
userTableView.menu = menu
|
|
|
|
|
|
|
|
|
|
let sv = NSScrollView()
|
|
|
|
|
sv.documentView = userTableView
|
|
|
|
|
sv.hasVerticalScroller = true
|
|
|
|
|
sv.borderType = .noBorder
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
sv.translatesAutoresizingMaskIntoConstraints = false
|
2026-06-18 15:26:47 +02:00
|
|
|
return sv
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func buildChatPanel() -> NSView {
|
|
|
|
|
chatTextView.setAccessibilityLabel("Chat messages")
|
|
|
|
|
chatTextView.textContainerInset = NSSize(width: 4, height: 4)
|
|
|
|
|
chatTextView.isAutomaticQuoteSubstitutionEnabled = false
|
|
|
|
|
|
|
|
|
|
let sv = NSScrollView()
|
|
|
|
|
sv.documentView = chatTextView
|
|
|
|
|
sv.hasVerticalScroller = true
|
|
|
|
|
sv.borderType = .noBorder
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
sv.translatesAutoresizingMaskIntoConstraints = false
|
2026-06-18 15:26:47 +02:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
let sv = NSScrollView()
|
|
|
|
|
sv.documentView = activityTableView
|
|
|
|
|
sv.hasVerticalScroller = true
|
|
|
|
|
sv.borderType = .noBorder
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
sv.translatesAutoresizingMaskIntoConstraints = false
|
2026-06-18 15:26:47 +02:00
|
|
|
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),
|
fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:
1. The scroll views inside the split views were missing
translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
all available space (539px of the 600px window), starving the
outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
collapsed to zero even when the split view had space.
Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00
|
|
|
|
|
|
|
|
panel.heightAnchor.constraint(equalToConstant: 96),
|
2026-06-18 15:26:47 +02:00
|
|
|
])
|
|
|
|
|
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()
|
|
|
|
|
|
2026-06-18 15:52:13 +02:00
|
|
|
NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
|
|
|
|
|
channels.count, allUsers.count,
|
|
|
|
|
ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)
|
|
|
|
|
|
2026-06-18 15:26:47 +02:00
|
|
|
refreshChannelTree()
|
|
|
|
|
refreshUserList()
|
|
|
|
|
rebuildScopePicker()
|
|
|
|
|
updateStatusLabel()
|
|
|
|
|
loadInputDevices()
|
|
|
|
|
buildAdminMenu()
|
|
|
|
|
addActivity("Connected to server as \(nickname)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Event handling
|
|
|
|
|
|
|
|
|
|
private func handleEvent(_ event: VoiceCatEvent) {
|
2026-06-18 15:52:13 +02:00
|
|
|
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)")
|
2026-06-18 15:26:47 +02:00
|
|
|
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)"
|
|
|
|
|
}
|