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() } }