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
71 lines
2.4 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|