Models: - Convert Conversation and ChatMessage to SwiftData @Model classes with a one-to-many relationship and cascade delete. - Rename ChatMessage.id to clientID and drop Conversation.id in favor of jid (the string id would clash with PersistentIdentifier). - Keep MessageReaction as a Codable value stored in an array attribute. Persistence: - Add ArchiveStore, a per-account ModelContainer/ModelContext that replaces the JSON ChatArchive with load/save/erase. - Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor, locally deleted message IDs and roster contact JIDs. - Add LegacyArchiveImporter to import the old JSON snapshot once and delete the file afterwards, preserving existing history. AppModel: - Replace ChatArchive with ArchiveStore and persist via context.save(); insert new models and delete the replaced object on upsert merges so SwiftData never keeps an orphaned duplicate. Tests: - Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and legacy import) and drop the now-obsolete Codable round-trip assertions.
104 lines
3.1 KiB
Swift
104 lines
3.1 KiB
Swift
import Foundation
|
|
|
|
struct MessageReaction: Codable, Identifiable, Hashable, Sendable {
|
|
let senderJID: String
|
|
var emojis: [String]
|
|
var updatedAt: Date
|
|
|
|
var id: String { senderJID }
|
|
|
|
init(senderJID: String, emojis: [String], updatedAt: Date = Date()) {
|
|
self.senderJID = Self.normalizedJID(senderJID)
|
|
self.emojis = MessageReactionPolicy.sanitized(emojis)
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
private static func normalizedJID(_ value: String) -> String {
|
|
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
}
|
|
}
|
|
|
|
struct MessageReactionSummary: Identifiable, Hashable, Sendable {
|
|
let emoji: String
|
|
let count: Int
|
|
let includesOwnReaction: Bool
|
|
|
|
var id: String { emoji }
|
|
}
|
|
|
|
enum MessageReactionPolicy {
|
|
static let quickChoices = ["👍", "❤️", "😂", "😮", "😢", "🔥"]
|
|
|
|
static func sanitized(_ values: [String]) -> [String] {
|
|
var seen: Set<String> = []
|
|
return values.compactMap { rawValue in
|
|
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard value.count == 1,
|
|
value.utf8.count <= 64,
|
|
value.unicodeScalars.contains(where: { $0.properties.isEmoji }),
|
|
seen.insert(value).inserted else { return nil }
|
|
return value
|
|
}
|
|
}
|
|
|
|
static func summaries(
|
|
reactions: [MessageReaction],
|
|
ownJID: String?
|
|
) -> [MessageReactionSummary] {
|
|
let normalizedOwnJID = ownJID.map(bareJID)
|
|
var counts: [String: Int] = [:]
|
|
var ownValues: Set<String> = []
|
|
|
|
for reaction in reactions {
|
|
let values = sanitized(reaction.emojis)
|
|
for emoji in values {
|
|
counts[emoji, default: 0] += 1
|
|
}
|
|
if normalizedOwnJID == bareJID(reaction.senderJID) {
|
|
ownValues.formUnion(values)
|
|
}
|
|
}
|
|
|
|
return counts.keys.sorted().map { emoji in
|
|
MessageReactionSummary(
|
|
emoji: emoji,
|
|
count: counts[emoji, default: 0],
|
|
includesOwnReaction: ownValues.contains(emoji)
|
|
)
|
|
}
|
|
}
|
|
|
|
static func bareJID(_ value: String) -> String {
|
|
value
|
|
.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
|
|
.first
|
|
.map(String.init)?
|
|
.lowercased()
|
|
?? value.lowercased()
|
|
}
|
|
}
|
|
|
|
extension ChatMessage {
|
|
var reactionIdentifier: String? {
|
|
isGroupMessage ? stanzaID : clientID
|
|
}
|
|
|
|
var canBeReactedTo: Bool {
|
|
guard !isRetracted,
|
|
kind != .system,
|
|
direction == .incoming || delivery != .sending else { return false }
|
|
return !isGroupMessage || stanzaID != nil
|
|
}
|
|
|
|
func reactionSummaries(ownJID: String?) -> [MessageReactionSummary] {
|
|
MessageReactionPolicy.summaries(reactions: reactions, ownJID: ownJID)
|
|
}
|
|
|
|
func reactionEmojis(from senderJID: String) -> [String] {
|
|
let sender = MessageReactionPolicy.bareJID(senderJID)
|
|
return reactions.first {
|
|
MessageReactionPolicy.bareJID($0.senderJID) == sender
|
|
}?.emojis ?? []
|
|
}
|
|
}
|