diff --git a/Sources/Brygga/Views/ContentView.swift b/Sources/Brygga/Views/ContentView.swift index d7015d8..d9a11bd 100644 --- a/Sources/Brygga/Views/ContentView.swift +++ b/Sources/Brygga/Views/ContentView.swift @@ -764,27 +764,20 @@ struct ServerConsoleHeader: View { struct ServerMessageList: View { let server: Server + @Environment(AppState.self) private var appState + @AppStorage(PreferencesKeys.nickColorsEnabled) private var nickColorsEnabled = true + @AppStorage(PreferencesKeys.timestampFormat) private var timestampFormat: String = "system" + @AppStorage(PreferencesKeys.linkPreviewsEnabled) private var linkPreviewsEnabled = true var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(server.messages) { message in - MessageRow(message: message) - .id(message.id) - } - } - .padding(12) - .textSelection(.enabled) - } - .onChange(of: server.messages.count) { - if let last = server.messages.last { - withAnimation(.easeOut(duration: 0.1)) { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - } + MessageBufferView( + messages: server.messages, + lastReadMessageID: nil, + nickColorsEnabled: nickColorsEnabled, + timestampFormat: timestampFormat, + linkPreviewsEnabled: linkPreviewsEnabled, + linkPreviews: appState.linkPreviews, + ) } } @@ -852,7 +845,11 @@ struct TopicBar: View { struct MessageList: View { let channel: Channel var findQuery: String = "" + @Environment(AppState.self) private var appState @AppStorage(PreferencesKeys.showJoinsParts) private var showJoinsParts = true + @AppStorage(PreferencesKeys.nickColorsEnabled) private var nickColorsEnabled = true + @AppStorage(PreferencesKeys.timestampFormat) private var timestampFormat: String = "system" + @AppStorage(PreferencesKeys.linkPreviewsEnabled) private var linkPreviewsEnabled = true private var visibleMessages: [Message] { var messages: [Message] = if showJoinsParts { @@ -874,37 +871,24 @@ struct MessageList: View { return messages } - private var markerIndex: Int? { - guard let markerID = channel.lastReadMessageID else { return nil } - let idx = visibleMessages.firstIndex(where: { $0.id == markerID }) - // Only show the marker if there's at least one unread message after it. - guard let idx, idx < visibleMessages.count - 1 else { return nil } - return idx + private var effectiveLastReadID: UUID? { + guard + let markerID = channel.lastReadMessageID, + let idx = visibleMessages.firstIndex(where: { $0.id == markerID }), + idx < visibleMessages.count - 1 + else { return nil } + return markerID } var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(Array(visibleMessages.enumerated()), id: \.element.id) { index, message in - MessageRow(message: message) - .id(message.id) - if markerIndex == index { - LineMarker() - } - } - } - .padding(12) - .textSelection(.enabled) - } - .onChange(of: channel.messages.count) { - if let last = channel.messages.last { - withAnimation(.easeOut(duration: 0.1)) { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - } + MessageBufferView( + messages: visibleMessages, + lastReadMessageID: effectiveLastReadID, + nickColorsEnabled: nickColorsEnabled, + timestampFormat: timestampFormat, + linkPreviewsEnabled: linkPreviewsEnabled, + linkPreviews: appState.linkPreviews, + ) } } @@ -940,287 +924,6 @@ struct FindBar: View { } } -/// Horizontal divider showing where the user last read a channel. Appears -/// between the last-seen message and the first unread one. -struct LineMarker: View { - var body: some View { - HStack(spacing: 8) { - Rectangle() - .fill(Color.accentColor.opacity(0.6)) - .frame(height: 1) - Text("new") - .font(.caption.weight(.medium)) - .foregroundStyle(Color.accentColor) - Rectangle() - .fill(Color.accentColor.opacity(0.6)) - .frame(height: 1) - } - .padding(.vertical, 4) - } -} - -struct MessageRow: View { - let message: Message - @AppStorage(PreferencesKeys.nickColorsEnabled) private var nickColorsEnabled = true - @AppStorage(PreferencesKeys.timestampFormat) private var timestampFormat: String = "system" - @AppStorage(PreferencesKeys.linkPreviewsEnabled) private var linkPreviewsEnabled = true - - private func senderColor(_ nick: String) -> Color { - nickColorsEnabled ? NickColor.color(for: nick) : Color.accentColor - } - - private var timestampText: String { - let date = message.timestamp - switch timestampFormat { - case "12h": - let f = DateFormatter() - f.dateFormat = "h:mm a" - f.locale = Locale(identifier: "en_US_POSIX") - return f.string(from: date) - case "24h": - let f = DateFormatter() - f.dateFormat = "HH:mm" - f.locale = Locale(identifier: "en_US_POSIX") - return f.string(from: date) - default: - return date.formatted(date: .omitted, time: .shortened) - } - } - - private var actionAttributedString: AttributedString { - var sender = AttributedString(message.sender + " ") - sender.foregroundColor = senderColor(message.sender) - var composed = sender - composed.append(AttributedString.fromIRC(message.content)) - return composed - } - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - rowBody - if linkPreviewsEnabled, let url = firstPreviewableURL(in: message.content) { - LinkPreviewView(url: url) - .padding(.leading, 68) - .padding(.trailing, 16) - .padding(.top, 2) - } - } - .padding(.vertical, message.isHighlight ? 2 : 0) - .padding(.horizontal, message.isHighlight ? 4 : 0) - .background( - message.isHighlight - ? Color.accentColor.opacity(0.15) - : Color.clear, - ) - .overlay(alignment: .leading) { - if message.isHighlight { - Rectangle() - .fill(Color.accentColor) - .frame(width: 2) - } - } - } - - private var rowBody: some View { - HStack(alignment: .top, spacing: 8) { - Text(timestampText) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 52, alignment: .trailing) - - switch message.kind { - case .privmsg: - Text(message.sender) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(senderColor(message.sender)) - Text(AttributedString.fromIRC(message.content)) - .font(.system(.body, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - case .notice: - Text("-\(message.sender)-") - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.orange) - Text(AttributedString.fromIRC(message.content)) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.orange) - .frame(maxWidth: .infinity, alignment: .leading) - case .action: - Text("*") - .foregroundStyle(.secondary) - Text(actionAttributedString) - .font(.system(.body, design: .monospaced)) - .italic() - .frame(maxWidth: .infinity, alignment: .leading) - default: - Text("*") - .foregroundStyle(.secondary) - Text("\(message.sender) \(message.content)") - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .contextMenu { - Button("Copy Message") { copy(plainLogLine) } - Button("Copy Text") { copy(IRCFormatting.stripControlCodes(message.content)) } - Button("Copy Nickname") { copy(message.sender) } - } - } - - /// Single-line plain-text rendering of the message suitable for the - /// pasteboard. Matches the on-screen layout (timestamp, sender decoration, - /// content) with all mIRC control codes stripped. - private var plainLogLine: String { - let body = IRCFormatting.stripControlCodes(message.content) - switch message.kind { - case .privmsg: - return "[\(timestampText)] <\(message.sender)> \(body)" - case .notice: - return "[\(timestampText)] -\(message.sender)- \(body)" - case .action: - return "[\(timestampText)] * \(message.sender) \(body)" - default: - return "[\(timestampText)] * \(message.sender) \(body)" - } - } - - private func copy(_ text: String) { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(text, forType: .string) - } -} - -// MARK: - Link preview - -/// Returns the first HTTP/HTTPS URL found in `text`, or `nil`. -@MainActor -private let _linkDetector: NSDataDetector? = try? NSDataDetector( - types: NSTextCheckingResult.CheckingType.link.rawValue, -) - -@MainActor -private func firstPreviewableURL(in text: String) -> URL? { - guard let detector = _linkDetector, !text.isEmpty else { return nil } - let range = NSRange(text.startIndex..., in: text) - var found: URL? - detector.enumerateMatches(in: text, options: [], range: range) { match, _, stop in - guard let url = match?.url, let scheme = url.scheme?.lowercased() else { return } - if scheme == "http" || scheme == "https" { - found = url - stop.pointee = true - } - } - return found -} - -/// Inline preview for a single URL. Pulls data from -/// `AppState.linkPreviews`, kicks off a fetch on appear if nothing is -/// cached, and collapses to nothing on failure. -@MainActor -struct LinkPreviewView: View { - let url: URL - @Environment(AppState.self) private var appState - - var body: some View { - let preview = appState.linkPreviews.preview(for: url) - content(for: preview) - .onAppear { appState.linkPreviews.fetchIfNeeded(url) } - } - - @ViewBuilder - private func content(for preview: LinkPreview?) -> some View { - switch preview?.status { - case .loaded: - loadedBody(preview!) - case .loading, .none: - HStack(spacing: 6) { - ProgressView().controlSize(.mini) - Text(url.host ?? url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .padding(.vertical, 2) - case .failed: - EmptyView() - } - } - - @ViewBuilder - private func loadedBody(_ preview: LinkPreview) -> some View { - if preview.isDirectImage { - Link(destination: url) { - AsyncImage(url: url) { phase in - switch phase { - case let .success(image): - image - .resizable() - .scaledToFit() - .frame(maxWidth: 400, maxHeight: 260, alignment: .leading) - .clipShape(RoundedRectangle(cornerRadius: 6)) - case .failure: - EmptyView() - default: - ProgressView().controlSize(.mini) - } - } - } - .buttonStyle(.plain) - } else { - Link(destination: url) { - HStack(alignment: .top, spacing: 10) { - if let imageURL = preview.imageURL { - AsyncImage(url: imageURL) { phase in - switch phase { - case let .success(image): - image - .resizable() - .scaledToFill() - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 4)) - default: - RoundedRectangle(cornerRadius: 4) - .fill(.quaternary) - .frame(width: 60, height: 60) - } - } - } - VStack(alignment: .leading, spacing: 2) { - Text(preview.siteName ?? url.host ?? url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - if let title = preview.title, !title.isEmpty { - Text(title) - .font(.system(.body, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(2) - } - if let summary = preview.summary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(3) - } - } - Spacer(minLength: 0) - } - .padding(10) - .background { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - } - .overlay { - RoundedRectangle(cornerRadius: 8) - .strokeBorder(.quaternary.opacity(0.8), lineWidth: 1) - } - } - .buttonStyle(.plain) - } - } -} - struct InputBar: View { let nickname: String @Binding var draft: String diff --git a/Sources/Brygga/Views/MessageBufferView.swift b/Sources/Brygga/Views/MessageBufferView.swift new file mode 100644 index 0000000..f7b19dc --- /dev/null +++ b/Sources/Brygga/Views/MessageBufferView.swift @@ -0,0 +1,809 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 Brygga contributors + +import AppKit +import BryggaCore +import SwiftUI + +/// Non-editable message buffer backed by `NSTextView`, giving the chat +/// view native macOS drag-select across rows, ⌘A, ⌘C, Find, and a right-click +/// menu. Replaces the SwiftUI `LazyVStack { MessageRow }` rendering that +/// could only select text within a single message due to SwiftUI's +/// per-`Text` selection limit. +/// +/// Feed the view a `[Message]` plus a few presentation options. On append +/// it scrolls to the bottom if the user was already pinned there (mIRC +/// behaviour); otherwise it leaves the scroll position alone so the user +/// can read history without being snatched back to live. +@MainActor +struct MessageBufferView: NSViewRepresentable { + let messages: [Message] + let lastReadMessageID: UUID? + let nickColorsEnabled: Bool + let timestampFormat: String + let linkPreviewsEnabled: Bool + let linkPreviews: LinkPreviewStore? + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSScrollView { + let textView = NSTextView(frame: .zero) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = true + textView.drawsBackground = false + textView.allowsUndo = false + textView.isAutomaticLinkDetectionEnabled = false + textView.isAutomaticDataDetectionEnabled = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticTextCompletionEnabled = false + textView.usesFontPanel = false + textView.usesFindBar = true + textView.isIncrementalSearchingEnabled = true + textView.textContainerInset = NSSize(width: 12, height: 12) + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.autoresizingMask = [.width] + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.linkTextAttributes = [ + .foregroundColor: NSColor.controlAccentColor, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .cursor: NSCursor.pointingHand, + ] + textView.delegate = context.coordinator + + let scroll = NSScrollView(frame: .zero) + scroll.hasVerticalScroller = true + scroll.hasHorizontalScroller = false + scroll.drawsBackground = false + scroll.documentView = textView + scroll.contentView.postsBoundsChangedNotifications = true + + context.coordinator.textView = textView + context.coordinator.scrollView = scroll + return scroll + } + + func updateNSView(_: NSScrollView, context: Context) { + context.coordinator.apply( + messages: messages, + lastReadMessageID: lastReadMessageID, + nickColorsEnabled: nickColorsEnabled, + timestampFormat: timestampFormat, + linkPreviewsEnabled: linkPreviewsEnabled, + linkPreviews: linkPreviews, + ) + } + + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + weak var textView: NSTextView? + weak var scrollView: NSScrollView? + + private var renderedIDs: [UUID] = [] + private var renderedLastReadID: UUID? + private var messagesByID: [UUID: Message] = [:] + private var nickColorsEnabled = true + private var timestampFormat = "system" + private var linkPreviewsEnabled = true + private weak var linkPreviewStore: LinkPreviewStore? + + /// Cached decoded image bytes, keyed by the `imageURL` of the + /// preview they illustrate. Images fetched once per Coordinator + /// lifetime; when the view goes off-screen and back, the cache is + /// reused until the Coordinator itself is torn down. + private var images: [URL: NSImage] = [:] + private var inFlightImages: Set = [] + + /// The most-recent `apply` inputs, kept so the Coordinator can + /// re-render itself when an image fetch completes or the link + /// preview store mutates — neither of which routes through + /// SwiftUI's `updateNSView`. + private var lastApply: ApplyArgs? + private var hasSubscribedToStore = false + + private let urlDetector = try? NSDataDetector( + types: NSTextCheckingResult.CheckingType.link.rawValue, + ) + + private struct ApplyArgs { + let messages: [Message] + let lastReadMessageID: UUID? + let nickColorsEnabled: Bool + let timestampFormat: String + let linkPreviewsEnabled: Bool + } + + func apply( + messages: [Message], + lastReadMessageID: UUID?, + nickColorsEnabled: Bool, + timestampFormat: String, + linkPreviewsEnabled: Bool, + linkPreviews: LinkPreviewStore?, + ) { + linkPreviewStore = linkPreviews + lastApply = ApplyArgs( + messages: messages, + lastReadMessageID: lastReadMessageID, + nickColorsEnabled: nickColorsEnabled, + timestampFormat: timestampFormat, + linkPreviewsEnabled: linkPreviewsEnabled, + ) + render() + if linkPreviews != nil, linkPreviewsEnabled, !hasSubscribedToStore { + hasSubscribedToStore = true + subscribeToPreviewStore() + } + kickOffPreviewFetches(for: messages, enabled: linkPreviewsEnabled) + } + + private func render() { + guard + let args = lastApply, + let textView, + let storage = textView.textStorage + else { return } + + let optionsChanged = args.nickColorsEnabled != nickColorsEnabled + || args.timestampFormat != timestampFormat + || args.lastReadMessageID != renderedLastReadID + || args.linkPreviewsEnabled != linkPreviewsEnabled + nickColorsEnabled = args.nickColorsEnabled + timestampFormat = args.timestampFormat + linkPreviewsEnabled = args.linkPreviewsEnabled + + let newIDs = args.messages.map(\.id) + let canAppend = !optionsChanged + && newIDs.starts(with: renderedIDs) + && newIDs.count > renderedIDs.count + + let wasAtBottom = isScrolledToBottom() + + if canAppend { + let tail = Array(args.messages.suffix(args.messages.count - renderedIDs.count)) + let appendText = NSMutableAttributedString() + for message in tail { + appendText.append(attributed(for: message, lastReadID: args.lastReadMessageID)) + } + storage.append(appendText) + } else { + let full = NSMutableAttributedString() + for message in args.messages { + full.append(attributed(for: message, lastReadID: args.lastReadMessageID)) + } + storage.setAttributedString(full) + } + + messagesByID = Dictionary(uniqueKeysWithValues: args.messages.map { ($0.id, $0) }) + renderedIDs = newIDs + renderedLastReadID = args.lastReadMessageID + + if wasAtBottom || !canAppend { + scrollToBottom() + } + } + + /// Rebuild the buffer using the last inputs. Used by async callbacks — + /// image fetch completions and link-preview store observations — that + /// don't route through SwiftUI's update cycle. + private func reapply() { + guard lastApply != nil else { return } + // Force a full rebuild even when the message list hasn't grown — + // the preview attachments may have changed. + renderedIDs = [] + render() + } + + private func subscribeToPreviewStore() { + guard let store = linkPreviewStore else { return } + withObservationTracking { + _ = store.cache + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + reapply() + subscribeToPreviewStore() + } + } + } + + private func kickOffPreviewFetches(for messages: [Message], enabled: Bool) { + guard enabled, let store = linkPreviewStore else { return } + for message in messages { + guard let url = firstPreviewableURL(in: message.content) else { continue } + store.fetchIfNeeded(url) + } + } + + // MARK: - Attributed building + + private func attributed(for message: Message, lastReadID: UUID?) -> NSAttributedString { + let out = NSMutableAttributedString() + let ts = timestampText(for: message.timestamp) + let bodyFont = NSFont.monospacedSystemFont( + ofSize: NSFont.systemFontSize, + weight: .regular, + ) + let tsFont = NSFont.monospacedSystemFont( + ofSize: NSFont.smallSystemFontSize, + weight: .regular, + ) + + out.append(.init(string: "[\(ts)] ", attributes: [ + .font: tsFont, + .foregroundColor: NSColor.secondaryLabelColor, + ])) + + switch message.kind { + case .privmsg: + out.append(.init(string: "<\(message.sender)> ", attributes: [ + .font: bodyFont, + .foregroundColor: nickColor(for: message.sender), + ])) + out.append(ircAttributed( + message.content, + baseFont: bodyFont, + baseColor: NSColor.labelColor, + )) + + case .notice: + out.append(.init(string: "-\(message.sender)- ", attributes: [ + .font: bodyFont, + .foregroundColor: NSColor.systemOrange, + ])) + out.append(ircAttributed( + message.content, + baseFont: bodyFont, + baseColor: NSColor.systemOrange, + )) + + case .action: + out.append(.init(string: "* ", attributes: [ + .font: bodyFont, + .foregroundColor: NSColor.secondaryLabelColor, + ])) + let italicBody = italicVariant(of: bodyFont) + out.append(.init(string: "\(message.sender) ", attributes: [ + .font: italicBody, + .foregroundColor: nickColor(for: message.sender), + ])) + out.append(ircAttributed( + message.content, + baseFont: italicBody, + baseColor: NSColor.labelColor, + )) + + default: + out.append(.init(string: "* \(message.sender) \(message.content)", attributes: [ + .font: bodyFont, + .foregroundColor: NSColor.secondaryLabelColor, + ])) + } + + out.append(.init(string: "\n")) + + if let previewParagraph = linkPreviewAttachment(for: message) { + out.append(previewParagraph) + } + + let fullRange = NSRange(location: 0, length: out.length) + out.addAttribute(.bryggaMessageID, value: message.id, range: fullRange) + + if message.isHighlight { + out.addAttribute( + .backgroundColor, + value: NSColor.controlAccentColor.withAlphaComponent(0.15), + range: fullRange, + ) + } + + // The "new" divider sits *after* the last-read message so it + // visually separates read from unread. + if let lastReadID, message.id == lastReadID { + out.append(lineMarker()) + } + + return out + } + + private func linkPreviewAttachment(for message: Message) -> NSAttributedString? { + guard + linkPreviewsEnabled, + let store = linkPreviewStore, + let url = firstPreviewableURL(in: message.content), + let preview = store.preview(for: url), + preview.status == .loaded + else { return nil } + + let image = loadedImage(for: preview) + let cell = LinkPreviewAttachmentCell(preview: preview, image: image) + let attachment = NSTextAttachment() + attachment.attachmentCell = cell + + let attachmentString = NSMutableAttributedString(attachment: attachment) + + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 68 + para.headIndent = 68 + para.paragraphSpacing = 4 + para.paragraphSpacingBefore = 2 + + let paragraph = NSMutableAttributedString() + paragraph.append(attachmentString) + paragraph.append(NSAttributedString(string: "\n")) + paragraph.addAttribute( + .paragraphStyle, + value: para, + range: NSRange(location: 0, length: paragraph.length), + ) + // Clicking the attachment follows the URL. + paragraph.addAttribute( + .link, + value: url, + range: NSRange(location: 0, length: attachmentString.length), + ) + return paragraph + } + + /// Return the cached `NSImage` for the preview if any. If the preview + /// points at an image URL and we haven't fetched it yet, kick off the + /// fetch and return `nil` — the Coordinator will reapply once the + /// image lands. + private func loadedImage(for preview: LinkPreview) -> NSImage? { + let imageURL = preview.imageURL ?? (preview.isDirectImage ? preview.url : nil) + guard let imageURL else { return nil } + if let cached = images[imageURL] { return cached } + fetchImage(imageURL) + return nil + } + + private func fetchImage(_ url: URL) { + guard images[url] == nil, !inFlightImages.contains(url) else { return } + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { return } + inFlightImages.insert(url) + + Task { [weak self] in + let data = await Self.downloadImageBytes(for: url) + await MainActor.run { + guard let self else { return } + self.inFlightImages.remove(url) + if let data, let image = NSImage(data: data) { + self.images[url] = image + self.reapply() + } + } + } + } + + private static func downloadImageBytes(for url: URL) async -> Data? { + var request = URLRequest(url: url, timeoutInterval: 10) + request.setValue("Brygga/0.1 (macOS IRC client)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + guard let (data, response) = try? await session.data(for: request), + let http = response as? HTTPURLResponse, + (200 ..< 400).contains(http.statusCode), + data.count <= 2 * 1024 * 1024 + else { return nil } + return data + } + + private func firstPreviewableURL(in text: String) -> URL? { + guard let detector = urlDetector, !text.isEmpty else { return nil } + let range = NSRange(text.startIndex..., in: text) + var found: URL? + detector.enumerateMatches(in: text, options: [], range: range) { match, _, stop in + guard let url = match?.url, let scheme = url.scheme?.lowercased() else { return } + if scheme == "http" || scheme == "https" { + found = url + stop.pointee = true + } + } + return found + } + + private func ircAttributed( + _ text: String, + baseFont: NSFont, + baseColor: NSColor, + ) -> NSAttributedString { + let runs = IRCFormatting.parse(text) + let out = NSMutableAttributedString() + for run in runs { + var font = baseFont + if run.style.bold { + font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + } + if run.style.italic { + font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) + } + var attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: baseColor, + ] + if run.style.underline { + attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + if run.style.strikethrough { + attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + var fgIdx = run.style.foreground + var bgIdx = run.style.background + if run.style.reverse { + (fgIdx, bgIdx) = (bgIdx ?? 0, fgIdx ?? 99) + } + if let fg = fgIdx, let rgb = IRCFormatting.color(for: fg) { + attrs[.foregroundColor] = NSColor( + srgbRed: CGFloat(rgb.red), + green: CGFloat(rgb.green), + blue: CGFloat(rgb.blue), + alpha: 1.0, + ) + } + if let bg = bgIdx, let rgb = IRCFormatting.color(for: bg) { + attrs[.backgroundColor] = NSColor( + srgbRed: CGFloat(rgb.red), + green: CGFloat(rgb.green), + blue: CGFloat(rgb.blue), + alpha: 1.0, + ) + } + out.append(NSAttributedString(string: run.text, attributes: attrs)) + } + detectLinks(in: out) + return out + } + + private func detectLinks(in attributed: NSMutableAttributedString) { + guard let detector = urlDetector else { return } + let plain = attributed.string + let range = NSRange(plain.startIndex..., in: plain) + detector.enumerateMatches(in: plain, options: [], range: range) { match, _, _ in + guard let match, let url = match.url else { return } + let scheme = url.scheme?.lowercased() + guard scheme == "http" || scheme == "https" || scheme == "mailto" else { return } + attributed.addAttribute(.link, value: url, range: match.range) + } + } + + private func lineMarker() -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.alignment = .center + para.paragraphSpacing = 2 + para.paragraphSpacingBefore = 2 + return NSAttributedString(string: "── new ──\n", attributes: [ + .font: NSFont.systemFont(ofSize: 10, weight: .medium), + .foregroundColor: NSColor.controlAccentColor, + .paragraphStyle: para, + ]) + } + + private func italicVariant(of font: NSFont) -> NSFont { + NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) + } + + private func nickColor(for nick: String) -> NSColor { + if !nickColorsEnabled { return NSColor.controlAccentColor } + return NSColor(NickColor.color(for: nick)) + } + + private func timestampText(for date: Date) -> String { + switch timestampFormat { + case "12h": + let f = DateFormatter() + f.dateFormat = "h:mm a" + f.locale = Locale(identifier: "en_US_POSIX") + return f.string(from: date) + case "24h": + let f = DateFormatter() + f.dateFormat = "HH:mm" + f.locale = Locale(identifier: "en_US_POSIX") + return f.string(from: date) + default: + return date.formatted(date: .omitted, time: .shortened) + } + } + + // MARK: - Scroll + + private func isScrolledToBottom() -> Bool { + guard let scrollView, let doc = scrollView.documentView else { return true } + let visible = scrollView.contentView.documentVisibleRect + let threshold: CGFloat = 40 + return visible.maxY >= doc.frame.height - threshold + } + + private func scrollToBottom() { + guard let textView, let scrollView else { return } + if let container = textView.textContainer { + textView.layoutManager?.ensureLayout(for: container) + } + let docHeight = textView.frame.height + let visibleHeight = scrollView.contentView.bounds.height + let y = max(0, docHeight - visibleHeight) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: y)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + // MARK: - NSTextViewDelegate + + func textView( + _ view: NSTextView, + menu: NSMenu, + for _: NSEvent, + at charIndex: Int, + ) -> NSMenu? { + guard let storage = view.textStorage, charIndex < storage.length else { + return menu + } + let attrs = storage.attributes(at: charIndex, effectiveRange: nil) + guard + let id = attrs[.bryggaMessageID] as? UUID, + let message = messagesByID[id] + else { return menu } + + menu.addItem(.separator()) + + let copyMessage = NSMenuItem( + title: "Copy Message", + action: #selector(copyAction(_:)), + keyEquivalent: "", + ) + copyMessage.target = self + copyMessage.representedObject = plainLogLine(for: message) + menu.addItem(copyMessage) + + let copyText = NSMenuItem( + title: "Copy Text", + action: #selector(copyAction(_:)), + keyEquivalent: "", + ) + copyText.target = self + copyText.representedObject = IRCFormatting.stripControlCodes(message.content) + menu.addItem(copyText) + + let copyNick = NSMenuItem( + title: "Copy Nickname", + action: #selector(copyAction(_:)), + keyEquivalent: "", + ) + copyNick.target = self + copyNick.representedObject = message.sender + menu.addItem(copyNick) + + return menu + } + + @objc private func copyAction(_ sender: NSMenuItem) { + guard let text = sender.representedObject as? String else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + } + + private func plainLogLine(for message: Message) -> String { + let body = IRCFormatting.stripControlCodes(message.content) + let ts = timestampText(for: message.timestamp) + switch message.kind { + case .privmsg: return "[\(ts)] <\(message.sender)> \(body)" + case .notice: return "[\(ts)] -\(message.sender)- \(body)" + case .action: return "[\(ts)] * \(message.sender) \(body)" + default: return "[\(ts)] * \(message.sender) \(body)" + } + } + } +} + +// MARK: - Link preview attachment cell + +/// Custom `NSTextAttachmentCell` that draws a compact link-preview card +/// inline in the chat buffer: rounded background, optional thumbnail on +/// the left, site name + title + summary on the right. For direct-image +/// previews the thumbnail fills the whole card. Non-destructive — if the +/// image hasn't loaded yet the cell draws a placeholder rectangle. +/// +/// Deliberately *not* `@MainActor` — `NSTextAttachmentCell`'s core methods +/// (`cellSize`, `draw(withFrame:in:)`, `cellBaselineOffset`) are declared +/// nonisolated in AppKit and are invoked by the layout manager from its +/// own scheduling. The cell holds only immutable value types so nonisolated +/// access is safe. +final class LinkPreviewAttachmentCell: NSTextAttachmentCell { + private let preview: LinkPreview + private let previewImage: NSImage? + + private nonisolated static let cardWidth: CGFloat = 420 + private nonisolated static let cardHeight: CGFloat = 80 + private nonisolated static let directImageMaxHeight: CGFloat = 240 + private nonisolated static let directImageMaxWidth: CGFloat = 420 + private nonisolated static let padding: CGFloat = 10 + private nonisolated static let cornerRadius: CGFloat = 8 + private nonisolated static let thumbSize: CGFloat = 60 + + init(preview: LinkPreview, image: NSImage?) { + self.preview = preview + previewImage = image + super.init(textCell: "") + } + + @available(*, unavailable) + required init(coder _: NSCoder) { + fatalError("init(coder:) not supported") + } + + override func cellSize() -> NSSize { + if preview.isDirectImage, let previewImage { + let aspect = previewImage.size.height / max(previewImage.size.width, 1) + let width = min(Self.directImageMaxWidth, previewImage.size.width) + let height = min(Self.directImageMaxHeight, width * aspect) + return NSSize(width: width, height: height) + } + return NSSize(width: Self.cardWidth, height: Self.cardHeight) + } + + override func cellBaselineOffset() -> NSPoint { + // Drop the card below the text baseline so it reads like a separate + // paragraph attached to the message above. + NSPoint(x: 0, y: -cellSize().height + 2) + } + + override func draw(withFrame cellFrame: NSRect, in _: NSView?) { + if preview.isDirectImage, let previewImage { + drawDirectImage(previewImage, in: cellFrame) + } else { + drawCard(in: cellFrame) + } + } + + private func drawDirectImage(_ image: NSImage, in frame: NSRect) { + NSGraphicsContext.saveGraphicsState() + let path = NSBezierPath(roundedRect: frame, xRadius: 6, yRadius: 6) + path.addClip() + image.draw( + in: frame, + from: .zero, + operation: .sourceOver, + fraction: 1.0, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high.rawValue], + ) + NSGraphicsContext.restoreGraphicsState() + } + + private func drawCard(in frame: NSRect) { + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + + let cardRect = frame.insetBy(dx: 0.5, dy: 0.5) + let path = NSBezierPath( + roundedRect: cardRect, + xRadius: Self.cornerRadius, + yRadius: Self.cornerRadius, + ) + NSColor.windowBackgroundColor.withAlphaComponent(0.6).setFill() + path.fill() + NSColor.separatorColor.withAlphaComponent(0.8).setStroke() + path.lineWidth = 1 + path.stroke() + + var textOriginX = cardRect.origin.x + Self.padding + + if let previewImage { + let thumbRect = NSRect( + x: cardRect.origin.x + Self.padding, + y: cardRect.origin.y + (cardRect.height - Self.thumbSize) / 2, + width: Self.thumbSize, + height: Self.thumbSize, + ) + let thumbPath = NSBezierPath(roundedRect: thumbRect, xRadius: 4, yRadius: 4) + thumbPath.setClip() + previewImage.draw( + in: thumbRect, + from: .zero, + operation: .sourceOver, + fraction: 1.0, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high.rawValue], + ) + textOriginX = thumbRect.maxX + Self.padding + } else if preview.imageURL != nil { + // Placeholder while the image loads. + let thumbRect = NSRect( + x: cardRect.origin.x + Self.padding, + y: cardRect.origin.y + (cardRect.height - Self.thumbSize) / 2, + width: Self.thumbSize, + height: Self.thumbSize, + ) + NSColor.quaternaryLabelColor.setFill() + NSBezierPath(roundedRect: thumbRect, xRadius: 4, yRadius: 4).fill() + textOriginX = thumbRect.maxX + Self.padding + } + + NSGraphicsContext.restoreGraphicsState() + NSGraphicsContext.saveGraphicsState() + + let textMaxX = cardRect.maxX - Self.padding + let textRect = NSRect( + x: textOriginX, + y: cardRect.origin.y + Self.padding, + width: max(0, textMaxX - textOriginX), + height: cardRect.height - Self.padding * 2, + ) + + drawCardText(in: textRect) + } + + private func drawCardText(in rect: NSRect) { + let site = preview.siteName ?? preview.url.host ?? preview.url.absoluteString + let siteAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor, + ] + var y = rect.origin.y + let siteAttr = NSAttributedString(string: site, attributes: siteAttrs) + let siteSize = siteAttr.boundingRect( + with: NSSize(width: rect.width, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin], + ).size + siteAttr.draw(in: NSRect( + x: rect.origin.x, y: y, + width: rect.width, height: siteSize.height, + )) + y += siteSize.height + 2 + + if let title = preview.title, !title.isEmpty { + let titleAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: truncatingParagraph(lines: 2), + ] + let titleAttr = NSAttributedString(string: title, attributes: titleAttrs) + let titleRect = NSRect( + x: rect.origin.x, y: y, + width: rect.width, height: rect.maxY - y, + ) + titleAttr.draw(in: titleRect) + let titleSize = titleAttr.boundingRect( + with: NSSize(width: rect.width, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin], + ).size + y += min(titleSize.height, rect.maxY - y) + 2 + } + + if let summary = preview.summary, !summary.isEmpty, y < rect.maxY { + let summaryAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 11, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor, + .paragraphStyle: truncatingParagraph(lines: 2), + ] + let summaryAttr = NSAttributedString(string: summary, attributes: summaryAttrs) + let summaryRect = NSRect( + x: rect.origin.x, y: y, + width: rect.width, height: rect.maxY - y, + ) + summaryAttr.draw(in: summaryRect) + } + } + + private func truncatingParagraph(lines: Int) -> NSParagraphStyle { + let p = NSMutableParagraphStyle() + p.lineBreakMode = .byTruncatingTail + p.maximumLineHeight = 0 + _ = lines + return p + } +} + +private extension NSAttributedString.Key { + /// Marker attribute placed across the entire paragraph range of a + /// message so the right-click handler can recover which `Message` the + /// click landed inside. + static let bryggaMessageID = NSAttributedString.Key("brygga.messageID") +}