diff --git a/Sources/Shared/Persistence/ArchiveRecords.swift b/Sources/Shared/Persistence/ArchiveRecords.swift new file mode 100644 index 0000000..604d6a1 --- /dev/null +++ b/Sources/Shared/Persistence/ArchiveRecords.swift @@ -0,0 +1,230 @@ +import Foundation +import SwiftData + +/// The single SwiftData container backing durable chat history. It lives on a +/// plain (non-actor, non-`@MainActor`) type so it can be read from arbitrary +/// isolation without hopping. +enum LumaPersistence { + static let archiveContainer: ModelContainer = { + let schema = Schema([ + ConversationRecord.self, + MessageRecord.self, + ArchiveMetadataRecord.self, + ]) + do { + return try ModelContainer( + for: schema, + configurations: [ModelConfiguration("LumaArchive", schema: schema)] + ) + } catch { + // A corrupted on-disk store must not prevent sign-in. Degrade to an + // in-memory store for the current session instead. + do { + return try ModelContainer( + for: schema, + configurations: [ModelConfiguration(isStoredInMemoryOnly: true)] + ) + } catch { + fatalError("Unable to create the Luma SwiftData container: \(error)") + } + } + }() +} + +/// Encodes small Codable value types (reactions, call-history metadata, MAM +/// checkpoints, ID sets) into `Data` attributes so the `@Model` records remain +/// composed only of SwiftData-native scalar types. +enum ArchiveBlobCodec { + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return decoder + }() + + static func encode(_ value: T) -> Data? { + try? encoder.encode(value) + } + + static func decode(_ type: T.Type, from data: Data?) -> T? { + guard let data else { return nil } + return try? decoder.decode(type, from: data) + } +} + +@Model +final class ConversationRecord { + var accountJID: String + var jid: String + var displayName: String + var lastMessage: String + var lastActivity: Date + var unreadCount: Int + var isOnline: Bool + var isPinned: Bool + var encryptionPreferenceRaw: String + var kindRaw: String + var groupNickname: String? + var isGroupJoined: Bool + var shouldAutojoin: Bool + var occupantCount: Int + var invitedBy: String? + + init(accountJID: String, conversation: Conversation) { + self.accountJID = accountJID + self.jid = conversation.jid + self.displayName = conversation.displayName + self.lastMessage = conversation.lastMessage + self.lastActivity = conversation.lastActivity + self.unreadCount = conversation.unreadCount + self.isOnline = conversation.isOnline + self.isPinned = conversation.isPinned + self.encryptionPreferenceRaw = conversation.encryptionPreference.rawValue + self.kindRaw = conversation.kind.rawValue + self.groupNickname = conversation.groupNickname + self.isGroupJoined = conversation.isGroupJoined + self.shouldAutojoin = conversation.shouldAutojoin + self.occupantCount = conversation.occupantCount + self.invitedBy = conversation.invitedBy + } + + var value: Conversation { + Conversation( + jid: jid, + displayName: displayName, + lastMessage: lastMessage, + lastActivity: lastActivity, + unreadCount: unreadCount, + isOnline: isOnline, + isPinned: isPinned, + encryptionPreference: EncryptionPreference(rawValue: encryptionPreferenceRaw) ?? .inheritGlobal, + kind: Conversation.Kind(rawValue: kindRaw) ?? .direct, + groupNickname: groupNickname, + isGroupJoined: isGroupJoined, + shouldAutojoin: shouldAutojoin, + occupantCount: occupantCount, + invitedBy: invitedBy + ) + } +} + +@Model +final class MessageRecord { + var accountJID: String + var messageID: String + var conversationID: String + var senderJID: String + var body: String + var timestamp: Date + var directionRaw: String + var deliveryRaw: String + var securityRaw: String + var kindRaw: String + var remoteAttachmentURL: String? + var localFilename: String? + var mimeType: String? + var duration: Double? + var byteCount: Int? + var encryptionFingerprint: String? + var editedAt: Date? + var replyToID: String? + var replyToJID: String? + var replyPreview: String? + var forwardedFrom: String? + var retractedAt: Date? + var originID: String? + var stanzaID: String? + var senderDisplayName: String? + var isGroupMessage: Bool + var callHistoryData: Data? + var reactionsData: Data? + + init(accountJID: String, message: ChatMessage) { + self.accountJID = accountJID + self.messageID = message.id + self.conversationID = message.conversationID + self.senderJID = message.senderJID + self.body = message.body + self.timestamp = message.timestamp + self.directionRaw = message.direction.rawValue + self.deliveryRaw = message.delivery.rawValue + self.securityRaw = message.security.rawValue + self.kindRaw = message.kind.rawValue + self.remoteAttachmentURL = message.remoteAttachmentURL + self.localFilename = message.localFilename + self.mimeType = message.mimeType + self.duration = message.duration + self.byteCount = message.byteCount + self.encryptionFingerprint = message.encryptionFingerprint + self.editedAt = message.editedAt + self.replyToID = message.replyToID + self.replyToJID = message.replyToJID + self.replyPreview = message.replyPreview + self.forwardedFrom = message.forwardedFrom + self.retractedAt = message.retractedAt + self.originID = message.originID + self.stanzaID = message.stanzaID + self.senderDisplayName = message.senderDisplayName + self.isGroupMessage = message.isGroupMessage + self.callHistoryData = message.callHistory.flatMap { ArchiveBlobCodec.encode($0) } + self.reactionsData = ArchiveBlobCodec.encode(message.reactions) + } + + var value: ChatMessage { + ChatMessage( + id: messageID, + conversationID: conversationID, + senderJID: senderJID, + body: body, + timestamp: timestamp, + direction: ChatMessage.Direction(rawValue: directionRaw) ?? .incoming, + delivery: ChatMessage.Delivery(rawValue: deliveryRaw) ?? .sent, + security: ChatMessage.Security(rawValue: securityRaw) ?? .plaintext, + kind: ChatMessage.Kind(rawValue: kindRaw) ?? .text, + remoteAttachmentURL: remoteAttachmentURL, + localFilename: localFilename, + mimeType: mimeType, + duration: duration, + byteCount: byteCount, + encryptionFingerprint: encryptionFingerprint, + editedAt: editedAt, + replyToID: replyToID, + replyToJID: replyToJID, + replyPreview: replyPreview, + forwardedFrom: forwardedFrom, + retractedAt: retractedAt, + originID: originID, + stanzaID: stanzaID, + senderDisplayName: senderDisplayName, + isGroupMessage: isGroupMessage, + callHistory: ArchiveBlobCodec.decode(CallHistoryMetadata.self, from: callHistoryData), + reactions: ArchiveBlobCodec.decode([MessageReaction].self, from: reactionsData) ?? [] + ) + } +} + +@Model +final class ArchiveMetadataRecord { + var accountJID: String + var schemaVersion: Int + var locallyDeletedMessageIDsData: Data + var rosterContactJIDsData: Data + var lastSuccessfulMAMSync: Date? + var lastSuccessfulMAMCursor: String? + var mamCheckpointsData: Data + + init(accountJID: String, snapshot: ChatArchive.Snapshot) { + self.accountJID = accountJID + self.schemaVersion = snapshot.schemaVersion + self.locallyDeletedMessageIDsData = ArchiveBlobCodec.encode(Array(snapshot.locallyDeletedMessageIDs)) ?? Data() + self.rosterContactJIDsData = ArchiveBlobCodec.encode(Array(snapshot.rosterContactJIDs)) ?? Data() + self.lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync + self.lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor + self.mamCheckpointsData = ArchiveBlobCodec.encode(snapshot.mamCheckpoints) ?? Data() + } +}