Compare commits
3 commits
c9865cda15
...
25bb6d1b54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25bb6d1b54 | ||
|
|
0e36f8b5ff | ||
|
|
8a39e5939f |
14 changed files with 324 additions and 85 deletions
1
.claude/skills/swiftdata-pro
Symbolic link
1
.claude/skills/swiftdata-pro
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../.agents/skills/swiftdata-pro
|
||||||
1
.pi/skills/swiftdata-pro
Symbolic link
1
.pi/skills/swiftdata-pro
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../.agents/skills/swiftdata-pro
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ConnectionManager.self) private var manager
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
|
|
@ -17,5 +19,43 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.default, value: manager.screen)
|
.animation(.default, value: manager.screen)
|
||||||
|
.onAppear {
|
||||||
|
manager.modelContext = modelContext
|
||||||
|
purgeExpiredMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purgeExpiredMessages() {
|
||||||
|
// Purge all if save is off
|
||||||
|
if !UserDefaults.standard.bool(forKey: "save_messages", default: true) {
|
||||||
|
try? modelContext.delete(model: SavedMessage.self)
|
||||||
|
try? modelContext.save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge expired messages
|
||||||
|
let deleteAfterRaw = UserDefaults.standard.integer(forKey: "delete_after")
|
||||||
|
let days = deleteAfterRaw == 0 ? 0 : (DeleteAfter(rawValue: deleteAfterRaw) ?? .oneMonth).rawValue
|
||||||
|
if days > 0 {
|
||||||
|
let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: .now) ?? .now
|
||||||
|
let expired = FetchDescriptor<SavedMessage>(
|
||||||
|
predicate: #Predicate { $0.timestamp < cutoff }
|
||||||
|
)
|
||||||
|
if let old = try? modelContext.fetch(expired) {
|
||||||
|
for message in old { modelContext.delete(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge debug messages if not saving them
|
||||||
|
if !UserDefaults.standard.bool(forKey: "save_debug_buffer") {
|
||||||
|
let debugDesc = FetchDescriptor<SavedMessage>(
|
||||||
|
predicate: #Predicate { $0.buffer == "debug" }
|
||||||
|
)
|
||||||
|
if let debugMsgs = try? modelContext.fetch(debugDesc) {
|
||||||
|
for message in debugMsgs { modelContext.delete(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
|
|
@ -13,5 +14,6 @@ struct KDChatApp: App {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(connectionManager)
|
.environment(connectionManager)
|
||||||
}
|
}
|
||||||
|
.modelContainer(for: SavedMessage.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ struct ChatMessage: Identifiable, Sendable, Decodable {
|
||||||
_ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound
|
_ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound
|
||||||
senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7]
|
senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7]
|
||||||
isSystem = false
|
isSystem = false
|
||||||
timestamp = Date()
|
timestamp = .now
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a system message (from Say packets)
|
/// Create a system message (from Say packets)
|
||||||
|
|
@ -69,6 +69,6 @@ struct ChatMessage: Identifiable, Sendable, Decodable {
|
||||||
self.isInteractable = false
|
self.isInteractable = false
|
||||||
self.senderIsRecipient = false
|
self.senderIsRecipient = false
|
||||||
self.isSystem = true
|
self.isSystem = true
|
||||||
self.timestamp = Date()
|
self.timestamp = .now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
KDChat/Models/SavedMessage.swift
Normal file
38
KDChat/Models/SavedMessage.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model class SavedMessage {
|
||||||
|
#Index<SavedMessage>([\.buffer, \.timestamp])
|
||||||
|
|
||||||
|
var buffer: String
|
||||||
|
var senderId: String?
|
||||||
|
var senderName: String
|
||||||
|
var content: String
|
||||||
|
var timestamp: Date
|
||||||
|
var isSystem: Bool = false
|
||||||
|
var senderIsRecipient: Bool = false
|
||||||
|
var characterId: String?
|
||||||
|
var sessionId: UUID
|
||||||
|
|
||||||
|
init(
|
||||||
|
buffer: String,
|
||||||
|
senderId: String?,
|
||||||
|
senderName: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date = .now,
|
||||||
|
isSystem: Bool = false,
|
||||||
|
senderIsRecipient: Bool = false,
|
||||||
|
characterId: String? = nil,
|
||||||
|
sessionId: UUID
|
||||||
|
) {
|
||||||
|
self.buffer = buffer
|
||||||
|
self.senderId = senderId
|
||||||
|
self.senderName = senderName
|
||||||
|
self.content = content
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.isSystem = isSystem
|
||||||
|
self.senderIsRecipient = senderIsRecipient
|
||||||
|
self.characterId = characterId
|
||||||
|
self.sessionId = sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import MessagePack
|
import MessagePack
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
private extension UserDefaults {
|
extension UserDefaults {
|
||||||
func bool(forKey key: String, default defaultValue: Bool) -> Bool {
|
func bool(forKey key: String, default defaultValue: Bool) -> Bool {
|
||||||
object(forKey: key) == nil ? defaultValue : bool(forKey: key)
|
object(forKey: key) == nil ? defaultValue : bool(forKey: key)
|
||||||
}
|
}
|
||||||
|
|
@ -43,17 +44,14 @@ final class ConnectionManager {
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
var buffers: [ChatBuffer] = []
|
var buffers: [ChatBuffer] = []
|
||||||
var messages: [String: [ChatMessage]] = [:]
|
|
||||||
var currentCharacterId: String?
|
var currentCharacterId: String?
|
||||||
var localSenderId: String?
|
var localSenderId: String?
|
||||||
|
var sessionId = UUID()
|
||||||
// Feedback triggers (views observe these for sensoryFeedback / announcements)
|
var modelContext: ModelContext?
|
||||||
var messageFeedbackTrigger: Int = 0
|
private var lastDMRecipientId: String?
|
||||||
var systemFeedbackTrigger: Int = 0
|
|
||||||
|
|
||||||
// Tracks which buffer the user is viewing (set by ChatView)
|
// Tracks which buffer the user is viewing (set by ChatView)
|
||||||
var activeBuffer: String = "global"
|
var activeBuffer: String = "global"
|
||||||
var lastAnnouncement: String = ""
|
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
private(set) var isLoggedIn: Bool = false
|
private(set) var isLoggedIn: Bool = false
|
||||||
|
|
@ -105,6 +103,7 @@ final class ConnectionManager {
|
||||||
|
|
||||||
private func startClient(host: String, port: UInt16) {
|
private func startClient(host: String, port: UInt16) {
|
||||||
tearDownClient()
|
tearDownClient()
|
||||||
|
sessionId = UUID()
|
||||||
|
|
||||||
let client = LNLClient()
|
let client = LNLClient()
|
||||||
self.client = client
|
self.client = client
|
||||||
|
|
@ -138,6 +137,7 @@ final class ConnectionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDirectMessage(recipientId: String, message: String) {
|
func sendDirectMessage(recipientId: String, message: String) {
|
||||||
|
lastDMRecipientId = recipientId
|
||||||
let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message)
|
let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message)
|
||||||
Task { await client?.send(data: packet) }
|
Task { await client?.send(data: packet) }
|
||||||
}
|
}
|
||||||
|
|
@ -213,9 +213,9 @@ final class ConnectionManager {
|
||||||
|
|
||||||
private func resetState() {
|
private func resetState() {
|
||||||
buffers.removeAll()
|
buffers.removeAll()
|
||||||
messages.removeAll()
|
|
||||||
remoteForms.removeAll()
|
remoteForms.removeAll()
|
||||||
currentCharacterId = nil
|
currentCharacterId = nil
|
||||||
|
localSenderId = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,58 +343,64 @@ final class ConnectionManager {
|
||||||
log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)")
|
log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)")
|
||||||
if !buffers.contains(where: { $0.name == buffer.name }) {
|
if !buffers.contains(where: { $0.name == buffer.name }) {
|
||||||
buffers.append(buffer)
|
buffers.append(buffer)
|
||||||
messages[buffer.name] = []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleAddMessage(_ message: ChatMessage) {
|
private func handleAddMessage(_ message: ChatMessage) {
|
||||||
messages[message.buffer, default: []].append(message)
|
|
||||||
|
|
||||||
// Learn our own entity ID from echoed messages
|
// Learn our own entity ID from echoed messages
|
||||||
if message.senderIsRecipient, let id = message.senderId {
|
if message.senderIsRecipient, let id = message.senderId {
|
||||||
if localSenderId == nil { localSenderId = id }
|
if localSenderId == nil { localSenderId = id }
|
||||||
if currentCharacterId == nil { currentCharacterId = id }
|
if currentCharacterId == nil { currentCharacterId = id }
|
||||||
}
|
}
|
||||||
|
|
||||||
messageFeedbackTrigger += 1
|
// For DMs, characterId stores the conversation partner's ID
|
||||||
lastAnnouncement = "\(message.senderName): \(message.content)"
|
let dmPartnerId: String? = if message.buffer == "direct-messages" {
|
||||||
playMessageSound(buffer: message.buffer)
|
message.senderIsRecipient ? lastDMRecipientId : message.senderId
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = SavedMessage(
|
||||||
|
buffer: message.buffer,
|
||||||
|
senderId: message.senderId,
|
||||||
|
senderName: message.senderName,
|
||||||
|
content: message.content,
|
||||||
|
senderIsRecipient: message.senderIsRecipient,
|
||||||
|
characterId: dmPartnerId,
|
||||||
|
sessionId: sessionId
|
||||||
|
)
|
||||||
|
modelContext?.insert(saved)
|
||||||
|
try? modelContext?.save()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSay(_ say: ServerSay) {
|
private func handleSay(_ say: ServerSay) {
|
||||||
let sysMessage = ChatMessage(systemMessage: say.message)
|
let saved = SavedMessage(
|
||||||
|
buffer: "debug",
|
||||||
|
senderId: nil,
|
||||||
|
senderName: "System",
|
||||||
|
content: say.message,
|
||||||
|
isSystem: true,
|
||||||
|
sessionId: sessionId
|
||||||
|
)
|
||||||
|
modelContext?.insert(saved)
|
||||||
|
try? modelContext?.save()
|
||||||
|
|
||||||
for buffer in buffers {
|
announce(say.message)
|
||||||
messages[buffer.name, default: []].append(sysMessage)
|
playStatusHaptic()
|
||||||
}
|
|
||||||
|
|
||||||
systemFeedbackTrigger += 1
|
|
||||||
lastAnnouncement = say.message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playMessageSound(buffer: String) {
|
private func announce(_ text: String) {
|
||||||
guard UserDefaults.standard.bool(forKey: "sounds_enabled", default: true) else { return }
|
AccessibilityNotification.Announcement(text).post()
|
||||||
|
|
||||||
let isActiveBuffer = buffer == activeBuffer
|
|
||||||
if !isActiveBuffer {
|
|
||||||
guard UserDefaults.standard.bool(forKey: "sounds_background_buffers", default: true) else { return }
|
|
||||||
}
|
|
||||||
|
|
||||||
switch buffer {
|
|
||||||
case "global":
|
|
||||||
guard UserDefaults.standard.bool(forKey: "sounds_global", default: true) else { return }
|
|
||||||
SoundPlayer.play("global")
|
|
||||||
case "map":
|
|
||||||
guard UserDefaults.standard.bool(forKey: "sounds_map", default: true) else { return }
|
|
||||||
SoundPlayer.play("map")
|
|
||||||
case "direct-messages":
|
|
||||||
guard UserDefaults.standard.bool(forKey: "sounds_dm", default: true) else { return }
|
|
||||||
SoundPlayer.play("direct-message")
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func playStatusHaptic() {
|
||||||
|
guard UserDefaults.standard.bool(forKey: "haptics_enabled", default: true),
|
||||||
|
UserDefaults.standard.bool(forKey: "haptics_status", default: true) else { return }
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Game State Handlers
|
// MARK: - Game State Handlers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,25 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ChatBufferView: View {
|
struct ChatBufferView: View {
|
||||||
let bufferName: String
|
let bufferName: String
|
||||||
let canSend: Bool
|
let canSend: Bool
|
||||||
var onDM: ((ChatMessage) -> Void)?
|
var onDM: ((SavedMessage) -> Void)?
|
||||||
@Environment(ConnectionManager.self) private var manager
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query private var messages: [SavedMessage]
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
|
|
||||||
private var messages: [ChatMessage] {
|
init(bufferName: String, canSend: Bool, onDM: ((SavedMessage) -> Void)? = nil) {
|
||||||
manager.messages[bufferName] ?? []
|
self.bufferName = bufferName
|
||||||
|
self.canSend = canSend
|
||||||
|
self.onDM = onDM
|
||||||
|
|
||||||
|
let buf = bufferName
|
||||||
|
_messages = Query(
|
||||||
|
filter: #Predicate<SavedMessage> { $0.buffer == buf },
|
||||||
|
sort: \.timestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -21,15 +32,34 @@ struct ChatBufferView: View {
|
||||||
message: message,
|
message: message,
|
||||||
onDM: onDM
|
onDM: onDM
|
||||||
)
|
)
|
||||||
.id(message.id)
|
.id(message.persistentModelID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.onChange(of: messages.count) {
|
.onChange(of: messages.count) {
|
||||||
if let lastId = messages.last?.id {
|
guard let last = messages.last, last.sessionId == manager.sessionId else { return }
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
proxy.scrollTo(last.persistentModelID, anchor: .bottom)
|
||||||
proxy.scrollTo(lastId, anchor: .bottom)
|
|
||||||
|
let isActive = bufferName == manager.activeBuffer
|
||||||
|
let playBackground = UserDefaults.standard.bool(forKey: "sounds_background_buffers", default: true)
|
||||||
|
|
||||||
|
// Sound — plays for active buffer, or background if enabled
|
||||||
|
if isActive || playBackground {
|
||||||
|
playBufferSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce + haptic
|
||||||
|
let announceOther = UserDefaults.standard.bool(forKey: "announce_other_buffers")
|
||||||
|
if isActive || announceOther {
|
||||||
|
if !last.isSystem {
|
||||||
|
AccessibilityNotification.Announcement(
|
||||||
|
"\(last.senderName): \(last.content)"
|
||||||
|
).post()
|
||||||
|
}
|
||||||
|
if UserDefaults.standard.bool(forKey: "haptics_enabled", default: true),
|
||||||
|
UserDefaults.standard.bool(forKey: "haptics_messages", default: true) {
|
||||||
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,4 +105,20 @@ struct ChatBufferView: View {
|
||||||
messageText = ""
|
messageText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func playBufferSound() {
|
||||||
|
guard UserDefaults.standard.bool(forKey: "sounds_enabled", default: true) else { return }
|
||||||
|
switch bufferName {
|
||||||
|
case "global":
|
||||||
|
guard UserDefaults.standard.bool(forKey: "sounds_global", default: true) else { return }
|
||||||
|
SoundPlayer.play("global")
|
||||||
|
case "map":
|
||||||
|
guard UserDefaults.standard.bool(forKey: "sounds_map", default: true) else { return }
|
||||||
|
SoundPlayer.play("map")
|
||||||
|
case "direct-messages":
|
||||||
|
guard UserDefaults.standard.bool(forKey: "sounds_dm", default: true) else { return }
|
||||||
|
SoundPlayer.play("direct-message")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
|
|
@ -5,9 +6,6 @@ struct ChatView: View {
|
||||||
@State private var selectedBuffer: String = "global"
|
@State private var selectedBuffer: String = "global"
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var selectedDMPartner: String?
|
@State private var selectedDMPartner: String?
|
||||||
@AppStorage("haptics_enabled") private var hapticsEnabled = true
|
|
||||||
@AppStorage("haptics_messages") private var messageHaptics = true
|
|
||||||
@AppStorage("haptics_status") private var statusHaptics = true
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -50,11 +48,6 @@ struct ChatView: View {
|
||||||
let name = partnerName(for: partnerId)
|
let name = partnerName(for: partnerId)
|
||||||
DMConversationView(partnerId: partnerId, partnerName: name)
|
DMConversationView(partnerId: partnerId, partnerName: name)
|
||||||
}
|
}
|
||||||
.sensoryFeedback(.impact(flexibility: .soft), trigger: hapticsEnabled && messageHaptics ? manager.messageFeedbackTrigger : 0)
|
|
||||||
.sensoryFeedback(.warning, trigger: hapticsEnabled && statusHaptics ? manager.systemFeedbackTrigger : 0)
|
|
||||||
.onChange(of: manager.lastAnnouncement) {
|
|
||||||
AccessibilityNotification.Announcement(manager.lastAnnouncement).post()
|
|
||||||
}
|
|
||||||
.onChange(of: selectedBuffer) {
|
.onChange(of: selectedBuffer) {
|
||||||
manager.activeBuffer = selectedBuffer
|
manager.activeBuffer = selectedBuffer
|
||||||
}
|
}
|
||||||
|
|
@ -86,12 +79,17 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
private func partnerName(for partnerId: String) -> String {
|
private func partnerName(for partnerId: String) -> String {
|
||||||
let dmMessages = manager.messages["direct-messages"] ?? []
|
let descriptor = FetchDescriptor<SavedMessage>(
|
||||||
return dmMessages.first { $0.senderId == partnerId }?.senderName ?? partnerId
|
predicate: #Predicate { $0.buffer == "direct-messages" && $0.senderId == partnerId }
|
||||||
|
)
|
||||||
|
let results = (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
return results.first?.senderName ?? partnerId
|
||||||
}
|
}
|
||||||
|
|
||||||
private func navigateToDM(_ message: ChatMessage) {
|
private func navigateToDM(_ message: SavedMessage) {
|
||||||
guard let senderId = message.senderId else { return }
|
guard let senderId = message.senderId else { return }
|
||||||
selectedBuffer = "direct-messages"
|
selectedBuffer = "direct-messages"
|
||||||
selectedDMPartner = senderId
|
selectedDMPartner = senderId
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DMConversationView: View {
|
struct DMConversationView: View {
|
||||||
let partnerId: String
|
let partnerId: String
|
||||||
let partnerName: String
|
let partnerName: String
|
||||||
@Environment(ConnectionManager.self) private var manager
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
@Query private var messages: [SavedMessage]
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
|
|
||||||
private var messages: [ChatMessage] {
|
init(partnerId: String, partnerName: String) {
|
||||||
(manager.messages["direct-messages"] ?? []).filter { message in
|
self.partnerId = partnerId
|
||||||
message.senderId == partnerId || (message.senderIsRecipient && !message.isSystem)
|
self.partnerName = partnerName
|
||||||
}
|
|
||||||
|
let pid = partnerId
|
||||||
|
_messages = Query(
|
||||||
|
filter: #Predicate<SavedMessage> {
|
||||||
|
$0.buffer == "direct-messages" && $0.characterId == pid
|
||||||
|
},
|
||||||
|
sort: \.timestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -19,16 +28,14 @@ struct DMConversationView: View {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(messages) { message in
|
ForEach(messages) { message in
|
||||||
MessageRow(message: message)
|
MessageRow(message: message)
|
||||||
.id(message.id)
|
.id(message.persistentModelID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.onChange(of: messages.count) {
|
.onChange(of: messages.count) {
|
||||||
if let lastId = messages.last?.id {
|
if let last = messages.last {
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
proxy.scrollTo(last.persistentModelID, anchor: .bottom)
|
||||||
proxy.scrollTo(lastId, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DMListView: View {
|
struct DMListView: View {
|
||||||
@Environment(ConnectionManager.self) private var manager
|
@Environment(ConnectionManager.self) private var manager
|
||||||
@Binding var selectedConversation: String?
|
@Binding var selectedConversation: String?
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<SavedMessage> { $0.buffer == "direct-messages" && !$0.isSystem },
|
||||||
|
sort: \.timestamp,
|
||||||
|
order: .reverse
|
||||||
|
) private var dmMessages: [SavedMessage]
|
||||||
|
|
||||||
private var conversations: [(id: String, name: String, lastMessage: ChatMessage)] {
|
private var conversations: [(id: String, name: String, lastMessage: SavedMessage)] {
|
||||||
let dmMessages = manager.messages["direct-messages"] ?? []
|
var latest: [String: (name: String, message: SavedMessage)] = [:]
|
||||||
var latest: [String: (name: String, message: ChatMessage)] = [:]
|
for message in dmMessages {
|
||||||
|
|
||||||
for message in dmMessages where !message.isSystem {
|
|
||||||
// The conversation partner is the other person
|
|
||||||
guard let senderId = message.senderId, !message.senderIsRecipient else { continue }
|
guard let senderId = message.senderId, !message.senderIsRecipient else { continue }
|
||||||
if latest[senderId].map({ message.timestamp > $0.message.timestamp }) ?? true {
|
if latest[senderId] == nil {
|
||||||
latest[senderId] = (message.senderName, message)
|
latest[senderId] = (message.senderName, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return latest
|
return latest
|
||||||
.map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) }
|
.map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) }
|
||||||
.sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp }
|
.sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp }
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MessageRow: View {
|
struct MessageRow: View {
|
||||||
let message: ChatMessage
|
let message: SavedMessage
|
||||||
var onDM: ((ChatMessage) -> Void)?
|
var onDM: ((SavedMessage) -> Void)?
|
||||||
var onProfile: ((ChatMessage) -> Void)?
|
var onProfile: ((SavedMessage) -> Void)?
|
||||||
@Environment(ConnectionManager.self) private var manager
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
private var isOwnMessage: Bool {
|
private var isOwnMessage: Bool {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum DeleteAfter: Int, CaseIterable {
|
||||||
|
case oneDay = 1
|
||||||
|
case oneWeek = 7
|
||||||
|
case oneMonth = 30
|
||||||
|
case oneYear = 365
|
||||||
|
case never = 0
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .oneDay: "1 Day"
|
||||||
|
case .oneWeek: "1 Week"
|
||||||
|
case .oneMonth: "1 Month"
|
||||||
|
case .oneYear: "1 Year"
|
||||||
|
case .never: "Never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
@AppStorage("save_messages") private var saveMessages = true
|
||||||
|
@AppStorage("save_debug_buffer") private var saveDebugBuffer = false
|
||||||
|
@AppStorage("delete_after") private var deleteAfterRaw = DeleteAfter.oneMonth.rawValue
|
||||||
@AppStorage("haptics_enabled") private var hapticsEnabled = true
|
@AppStorage("haptics_enabled") private var hapticsEnabled = true
|
||||||
@AppStorage("haptics_messages") private var messageHaptics = true
|
@AppStorage("haptics_messages") private var messageHaptics = true
|
||||||
@AppStorage("haptics_status") private var statusHaptics = true
|
@AppStorage("haptics_status") private var statusHaptics = true
|
||||||
|
|
@ -9,10 +31,50 @@ struct SettingsView: View {
|
||||||
@AppStorage("sounds_map") private var mapSound = true
|
@AppStorage("sounds_map") private var mapSound = true
|
||||||
@AppStorage("sounds_dm") private var dmSound = true
|
@AppStorage("sounds_dm") private var dmSound = true
|
||||||
@AppStorage("sounds_background_buffers") private var backgroundBufferSounds = true
|
@AppStorage("sounds_background_buffers") private var backgroundBufferSounds = true
|
||||||
|
@AppStorage("announce_other_buffers") private var announceOtherBuffers = false
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
private var deleteAfter: Binding<DeleteAfter> {
|
||||||
|
Binding(
|
||||||
|
get: { DeleteAfter(rawValue: deleteAfterRaw) ?? .oneMonth },
|
||||||
|
set: { deleteAfterRaw = $0.rawValue }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("Sounds") {
|
Section {
|
||||||
|
Toggle("Save Messages", isOn: $saveMessages)
|
||||||
|
Toggle("Save Debug Buffer", isOn: $saveDebugBuffer)
|
||||||
|
.disabled(!saveMessages)
|
||||||
|
Picker("Delete After", selection: deleteAfter) {
|
||||||
|
ForEach(DeleteAfter.allCases, id: \.self) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!saveMessages)
|
||||||
|
Button("Delete All Messages", role: .destructive) {
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete Messages",
|
||||||
|
isPresented: $showDeleteConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Delete All Messages", role: .destructive) {
|
||||||
|
deleteAllMessages()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to delete all messages from all buffers?")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Messages")
|
||||||
|
} footer: {
|
||||||
|
Text("Save Debug Buffer saves system and debug messages between sessions.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
Toggle("Sounds", isOn: $soundsEnabled)
|
Toggle("Sounds", isOn: $soundsEnabled)
|
||||||
Toggle("Global Chat", isOn: $globalSound)
|
Toggle("Global Chat", isOn: $globalSound)
|
||||||
.disabled(!soundsEnabled)
|
.disabled(!soundsEnabled)
|
||||||
|
|
@ -20,16 +82,32 @@ struct SettingsView: View {
|
||||||
.disabled(!soundsEnabled)
|
.disabled(!soundsEnabled)
|
||||||
Toggle("Direct Messages", isOn: $dmSound)
|
Toggle("Direct Messages", isOn: $dmSound)
|
||||||
.disabled(!soundsEnabled)
|
.disabled(!soundsEnabled)
|
||||||
Toggle("Other Buffers", isOn: $backgroundBufferSounds)
|
Toggle("Background Buffer Sounds", isOn: $backgroundBufferSounds)
|
||||||
.disabled(!soundsEnabled)
|
.disabled(!soundsEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Sounds")
|
||||||
|
} footer: {
|
||||||
|
Text("Background Buffer Sounds plays sounds for messages in buffers you're not currently viewing.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Haptics") {
|
Section {
|
||||||
Toggle("Haptics", isOn: $hapticsEnabled)
|
Toggle("Haptics", isOn: $hapticsEnabled)
|
||||||
Toggle("Message Haptics", isOn: $messageHaptics)
|
Toggle("Message Haptics", isOn: $messageHaptics)
|
||||||
.disabled(!hapticsEnabled)
|
.disabled(!hapticsEnabled)
|
||||||
Toggle("Status Haptics", isOn: $statusHaptics)
|
Toggle("Status Haptics", isOn: $statusHaptics)
|
||||||
.disabled(!hapticsEnabled)
|
.disabled(!hapticsEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Haptics")
|
||||||
|
} footer: {
|
||||||
|
Text("Status Haptics provides feedback for system events such as connect and disconnect.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Announce Background Messages", isOn: $announceOtherBuffers)
|
||||||
|
} header: {
|
||||||
|
Text("Accessibility")
|
||||||
|
} footer: {
|
||||||
|
Text("When on, VoiceOver announces new messages from buffers you're not currently viewing.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|
@ -43,5 +121,15 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.onChange(of: saveMessages) {
|
||||||
|
if !saveMessages {
|
||||||
|
deleteAllMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAllMessages() {
|
||||||
|
try? modelContext.delete(model: SavedMessage.self)
|
||||||
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"swiftdata-pro": {
|
||||||
|
"source": "twostraws/swiftdata-agent-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "2f979bad98ea3a6744084c5f93e27897f02e8d0ffe15dd03042e88aaae4da14c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue