Files
Luma/Sources/Shared/Models/MessageReaction.swift
T
wt cbb820a86e
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
migrate persistence from JSON to SwiftData
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.
2026-08-18 22:58:18 +07:00

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 ?? []
}
}