aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-25 16:46:52 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-25 16:46:52 -0700
commit3b3b965842c47939ca54d0d1cbdf469346847f14 (patch)
tree22b5ee17d5fa8196a2d6b1bd95d7749c71cd69bc /Hotline
parent53686e30592fee566585e391738adee8b2ef2137 (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.swift10
-rw-r--r--Hotline/Models/Hotline.swift64
-rw-r--r--Hotline/macOS/ChatView.swift120
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()
//