import AppKit import VoiceCatCore // PrivateMessageWindowController — a modeless window for a single private message // conversation, owned and routed-to by MainWindowController. Mirrors the Windows client's // `PrivateMessageForm` (clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs): each PM // conversation opens in its own window instead of sharing the main chat log via a scope // dropdown. MainWindowController routes incoming `.textMessage` events with `.private` scope // to the right window; outgoing PMs are echoed back by the server and arrive through the // same path (no optimistic local echo). // // The window is modeless (`NSWindow`) rather than a sheet because the user should be able to // keep chatting in the main channel while a PM window is open — the same Discord/Slack // pattern the Windows client follows. final class PrivateMessageWindowController: NSWindowController, NSWindowDelegate, NSTextViewDelegate { // MARK: - Owned state private let client: VoiceCatClient let otherUserId: UInt32 private let selfUserId: UInt32 private let nickname: String // MARK: - UI private let historyTextView: NSTextView = { let tv = NSTextView() tv.isEditable = false tv.isSelectable = true tv.isAutomaticQuoteSubstitutionEnabled = false tv.textContainerInset = NSSize(width: 4, height: 4) return tv }() private let composeField = NSTextField() private let sendButton = NSButton() // MARK: - Init init(client: VoiceCatClient, otherUserId: UInt32, nickname: String, selfUserId: UInt32) { self.client = client self.otherUserId = otherUserId self.selfUserId = selfUserId self.nickname = nickname let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) window.title = "Private Message — \(nickname)" window.minSize = NSSize(width: 300, height: 220) window.center() super.init(window: window) window.delegate = self buildUI() } required init?(coder: NSCoder) { fatalError() } // MARK: - UI construction private func buildUI() { guard let contentView = window?.contentView else { return } historyTextView.setAccessibilityLabel("Private message history with \(nickname)") let scroll = NSScrollView() scroll.documentView = historyTextView scroll.hasVerticalScroller = true scroll.borderType = .noBorder scroll.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(scroll) composeField.placeholderString = "Type a private message…" composeField.setAccessibilityLabel("Private message to \(nickname)") composeField.target = self composeField.action = #selector(sendClicked) sendButton.title = "Send" sendButton.bezelStyle = .rounded sendButton.target = self sendButton.action = #selector(sendClicked) sendButton.setAccessibilityLabel("Send private message") sendButton.keyEquivalent = "\r" let sep = NSBox() sep.boxType = .separator sep.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(sep) let composeBar = NSStackView(views: [composeField, sendButton]) composeBar.orientation = .horizontal composeBar.spacing = 6 composeBar.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(composeBar) NSLayoutConstraint.activate([ scroll.topAnchor.constraint(equalTo: contentView.topAnchor), scroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), scroll.bottomAnchor.constraint(equalTo: sep.topAnchor), sep.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), sep.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), sep.heightAnchor.constraint(equalToConstant: 1), sep.bottomAnchor.constraint(equalTo: composeBar.topAnchor), composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), composeBar.heightAnchor.constraint(equalToConstant: 28), sendButton.widthAnchor.constraint(equalToConstant: 70), ]) window?.makeFirstResponder(composeField) } // MARK: - Public — called by MainWindowController /// Append a message line. `isSelf=true` renders in gray (our own echoed outgoing message); /// `false` renders in default color (incoming from the other user). Mirrors the Windows /// `PrivateMessageForm.AppendMessage`. func appendMessage(time: String, isSelf: Bool, sender: String, text: String) { let line = "[\(time)] \(sender): \(text)\n" let attrs: [NSAttributedString.Key: Any] = isSelf ? [.foregroundColor: NSColor.secondaryLabelColor] : [:] let attributed = NSAttributedString(string: line, attributes: attrs) historyTextView.textStorage?.append(attributed) historyTextView.scrollToEndOfDocument(nil) } /// Append a gray status/activity line (e.g. the other user disconnected). Mirrors the /// Windows `PrivateMessageForm.AppendActivity`. func appendActivity(_ text: String) { let time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) let line = "[\(time)] \(text)\n" let attributed = NSAttributedString(string: line, attributes: [ .foregroundColor: NSColor.secondaryLabelColor, ]) historyTextView.textStorage?.append(attributed) historyTextView.scrollToEndOfDocument(nil) } // MARK: - Send @objc private func sendClicked() { let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces) guard !msg.isEmpty else { return } client.sendText(scope: .private, targetId: otherUserId, text: msg) composeField.stringValue = "" } // MARK: - NSWindowDelegate func windowWillClose(_ notification: Notification) { // Notify the owner so it can drop this controller from its pmWindows map. // MainWindowController observes NSWindow.willCloseNotification on this window. } }