diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-25 16:46:52 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-25 16:46:52 -0700 |
| commit | 3b3b965842c47939ca54d0d1cbdf469346847f14 (patch) | |
| tree | 22b5ee17d5fa8196a2d6b1bd95d7749c71cd69bc /Hotline | |
| parent | 53686e30592fee566585e391738adee8b2ef2137 (diff) | |
Move chat input string to view model so its not lost when switching between tabs. Add chat search/filtering with suppport for live filtering.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/MacApp.swift | 10 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 64 | ||||
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 120 |
3 files changed, 146 insertions, 48 deletions
diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index a91df52..0682c1d 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -270,15 +270,15 @@ struct Application: App { } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show News") { - activeServerState?.selection = .news - } - .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) - .keyboardShortcut(.init("2"), modifiers: .command) Button("Show Message Board") { activeServerState?.selection = .board } .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("2"), modifiers: .command) + Button("Show News") { + activeServerState?.selection = .news + } + .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) .keyboardShortcut(.init("3"), modifiers: .command) Button("Show Files") { activeServerState?.selection = .files diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 3783ba0..3d1f309 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -163,6 +163,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var users: [User] = [] var accounts: [HotlineAccount] = [] var chat: [ChatMessage] = [] + var chatInput: String = "" var messageBoard: [String] = [] var messageBoardLoaded: Bool = false var files: [FileInfo] = [] @@ -1464,6 +1465,69 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.lastPersistedMessageType = nil } + @MainActor func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate (current chat includes restored history) + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages (includes both restored history and new messages) + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + // Skip consecutive disconnect messages + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + func updateServerTitle() { self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" } diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 2ad9816..65087c6 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -91,30 +91,42 @@ struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss - - @State var input: String = "" + @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 - + + @State private var searchQuery: String = "" + @State private var searchResults: [ChatMessage] = [] + @State private var isSearching: Bool = false + @FocusState private var focusedField: FocusedField? - + @Namespace var bottomID - - @State private var showingExporter: Bool = false - - @State private var chatDocument: TextFile = TextFile() - + + private var bindableModel: Bindable<Hotline> { + Bindable(model) + } + +// @State private var showingExporter: Bool = false +// +// @State private var chatDocument: TextFile = TextFile() + + var displayedMessages: [ChatMessage] { + searchQuery.isEmpty ? model.chat : searchResults + } + var body: some View { + @Bindable var bindModel = model + NavigationStack { ScrollViewReader { reader in VStack(alignment: .leading, spacing: 0) { - + // MARK: Scroll View - GeometryReader { gm in ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: 8) { - - ForEach(model.chat) { msg in + + ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { if let bannerImage = self.model.bannerImage { @@ -168,37 +180,46 @@ struct ChatView: View { VStack(spacing: 0) {}.id(bottomID) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .defaultScrollAnchor(.bottom) +// .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .defaultScrollAnchor(.bottom, for: .sizeChanges) .onChange(of: model.chat.count) { + // Re-run search when new messages arrive to keep filter active + if !searchQuery.isEmpty { + performSearch() + } reader.scrollTo(bottomID, anchor: .bottom) model.markPublicChatAsRead() } .onAppear { reader.scrollTo(bottomID, anchor: .bottom) + self.focusedField = .chatInput } - .onChange(of: gm.size) { + .onChange(of: self.model.bannerImage) { reader.scrollTo(bottomID, anchor: .bottom) } - .onChange(of: self.model.bannerImage) { + .onChange(of: searchQuery) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: isSearching) { reader.scrollTo(bottomID, anchor: .bottom) } - } // MARK: Input Divider Divider() // MARK: Input Bar HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $input, axis: .vertical) + TextField("", text: $bindModel.chatInput, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) .multilineTextAlignment(.leading) .onSubmit { - if !self.input.isEmpty { - model.sendChat(self.input, announce: NSEvent.modifierFlags.contains(.shift)) + if !model.chatInput.isEmpty { + model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) } - self.input = "" + model.chatInput = "" } .frame(maxWidth: .infinity) .padding() @@ -217,36 +238,49 @@ struct ChatView: View { break } } - .onTapGesture { + .onTapGesture(count: 1) { focusedField = .chatInput } } } + .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) } .background(Color(nsColor: .textBackgroundColor)) - // .toolbar { - // ToolbarItem(placement: .primaryAction) { - // Button { - // if prepareChatDocument() { - // showingExporter = true - // } - // } label: { - // Image(systemName: "square.and.arrow.up") - // }.help("Save Chat...") - // } - // } - .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in - switch result { - case .success(let url): - print("Saved to \(url)") - - case .failure(let error): - print(error.localizedDescription) - } - self.chatDocument.text = "" +// .navigationTitle(model.serverTitle) + .onChange(of: searchQuery) { + performSearch() + } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// showingExporter = true +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } +// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in +// switch result { +// case .success(let url): +// print("Saved to \(url)") +// +// case .failure(let error): +// print(error.localizedDescription) +// } +// self.chatDocument.text = "" +// } + } + + private func performSearch() { + guard !searchQuery.isEmpty else { + searchResults = [] + return } + + searchResults = model.searchChat(query: searchQuery) } - + // private func prepareChatDocument() -> Bool { // var text: String = String() // |