KDChat/KDChat/Views/MessageRow.swift
Blake Oliver 8a39e5939f
Add swiftData message persistence and reactive side effects
Persistence:
- SavedMessage SwiftData model with index on [buffer, timestamp]
- @Query in ChatBufferView/DMListView/DMConversationView — SwiftData is the single source of truth, no in-memory dictionary
- Settings: Save Messages toggle, Save Debug Buffer toggle, Delete After picker (1d/1w/1m/1y/never), Delete All with confirmation dialog
- Expired and debug messages purged on launch based on settings
- Say messages go to debug buffer only (not all buffers)

Reactive side effects:
- Sound, haptic, and VoiceOver announcement all fire from ChatBufferView's onChange(of: messages.count)
- Sound plays for active buffer or background buffers if enabled
- Haptic and announcement fire on active buffer only, current session only (I'll add a toggle to announce other buffers next)
- Status haptic for Say messages stays imperative in model (global event, not per-buffer)
- Removed trigger counters and lastAnnouncement from ConnectionManager

DM conversation fix:
- Track DM recipient via lastDMRecipientId; tag outgoing echoes with partner ID in characterId
- Conversation view filters on characterId == partnerId (shows only that conversation)

Other:
- modelContext set in ContentView.onAppear (available for all screens)
- Instant scroll (no animation), respects Reduce Motion
2026-04-05 18:46:38 -06:00

71 lines
2.4 KiB
Swift

import SwiftUI
struct MessageRow: View {
let message: SavedMessage
var onDM: ((SavedMessage) -> Void)?
var onProfile: ((SavedMessage) -> Void)?
@Environment(ConnectionManager.self) private var manager
private var isOwnMessage: Bool {
message.senderIsRecipient || message.senderId == manager.localSenderId
}
var body: some View {
if message.isSystem {
Text(message.content)
.font(.body)
.foregroundStyle(.secondary)
.italic()
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 6)
} else {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(message.senderName)
.font(.headline)
.foregroundStyle(Color.accentColor)
if message.senderIsRecipient {
Text("(you)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(message.timestamp, style: .time)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(message.content)
.font(.title3)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(.rect(cornerRadius: 12))
.contextMenu {
if !isOwnMessage, message.senderId != nil {
if let onDM {
Button("DM \(message.senderName)", systemImage: "envelope") { onDM(message) }
}
if let onProfile {
Button("Show \(message.senderName)'s Profile", systemImage: "person.circle") { onProfile(message) }
}
}
}
.accessibilityElement(children: .combine)
.accessibilityActions {
if !isOwnMessage, message.senderId != nil {
if let onDM {
Button("DM \(message.senderName)") { onDM(message) }
}
if let onProfile {
Button("Show \(message.senderName)'s Profile") { onProfile(message) }
}
}
}
}
}
}