231 lines
8.3 KiB
Swift
231 lines
8.3 KiB
Swift
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<T: Encodable>(_ value: T) -> Data? {
|
|
try? encoder.encode(value)
|
|
}
|
|
|
|
static func decode<T: Decodable>(_ 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()
|
|
}
|
|
}
|