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.
This commit is contained in:
1 parent
0317277d46
commit
cbb820a86e
30 files changed
+726
-665
No files matched your search
@@ -72,7 +72,7 @@ final class AppModel: ObservableObject {
|
||||
private let avatarCache: AvatarCache
|
||||
private let mediaPreviewProcessor = MediaPreviewProcessor()
|
||||
private let mediaFileIO = MediaFileIO()
|
||||
private var archive: ChatArchive?
|
||||
private var store: ArchiveStore?
|
||||
private var bootstrapTask: Task<Void, Never>?
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var watchSyncTask: Task<Void, Never>?
|
||||
@@ -131,7 +131,7 @@ final class AppModel: ObservableObject {
|
||||
watchBridge.onReply = { [weak self] jid, text in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if !self.conversations.contains(where: { $0.id == jid.lowercased() }) {
|
||||
if !self.conversations.contains(where: { $0.jid == jid.lowercased() }) {
|
||||
self.upsertConversation(jid: jid, name: nil)
|
||||
}
|
||||
await self.sendText(text, to: jid)
|
||||
@@ -171,7 +171,7 @@ final class AppModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) {
|
||||
if !self.conversations.contains(where: { $0.jid == voice.jid.lowercased() }) {
|
||||
self.upsertConversation(jid: voice.jid, name: nil)
|
||||
}
|
||||
let messageID = await self.sendMedia(
|
||||
@@ -218,7 +218,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
var selectedConversation: Conversation? {
|
||||
guard let selectedConversationID else { return nil }
|
||||
return conversations.first { $0.id == selectedConversationID }
|
||||
return conversations.first { $0.jid == selectedConversationID }
|
||||
}
|
||||
|
||||
private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? {
|
||||
@@ -238,7 +238,7 @@ final class AppModel: ObservableObject {
|
||||
messages
|
||||
.filter { $0.conversationID == selectedConversationID }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
selectedMessagesCacheConversationID = selectedConversationID
|
||||
@@ -269,7 +269,7 @@ final class AppModel: ObservableObject {
|
||||
do {
|
||||
guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
|
||||
account = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -280,7 +280,7 @@ final class AppModel: ObservableObject {
|
||||
password = storedPassword
|
||||
} catch {
|
||||
account = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -340,7 +340,7 @@ final class AppModel: ObservableObject {
|
||||
} catch {
|
||||
await xmpp.disconnect()
|
||||
self.account = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -356,14 +356,14 @@ final class AppModel: ObservableObject {
|
||||
if let oldAccount {
|
||||
try? credentials.deletePassword(for: oldAccount.normalizedJID)
|
||||
if forgetHistory {
|
||||
try? await archive?.erase()
|
||||
try? store?.erase()
|
||||
}
|
||||
}
|
||||
persistTask?.cancel()
|
||||
resetArchiveBatchState()
|
||||
watchSyncTask?.cancel()
|
||||
watchSyncTask = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
account = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
@@ -429,7 +429,7 @@ final class AppModel: ObservableObject {
|
||||
func startCall(to rawJID: String, withVideo: Bool) async {
|
||||
let jid = rawJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !jid.isEmpty else { return }
|
||||
guard conversations.first(where: { $0.id == jid })?.isGroup != true else {
|
||||
guard conversations.first(where: { $0.jid == jid })?.isGroup != true else {
|
||||
errorMessage = "Звонки в текущем MVP доступны только в личных чатах."
|
||||
return
|
||||
}
|
||||
@@ -493,7 +493,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
upsertConversation(jid: normalized, name: name)
|
||||
selectedConversationID = normalized
|
||||
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
conversations[index].unreadCount = 0
|
||||
}
|
||||
if addToRoster {
|
||||
@@ -508,7 +508,7 @@ final class AppModel: ObservableObject {
|
||||
func selectConversation(id: String) {
|
||||
let normalized = id.lowercased()
|
||||
selectedConversationID = normalized
|
||||
// if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
// if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
// conversations[index].unreadCount = 0
|
||||
// }
|
||||
// schedulePersist()
|
||||
@@ -532,7 +532,7 @@ final class AppModel: ObservableObject {
|
||||
// let oldestServerID = selectedMessages
|
||||
// .sorted { lhs, rhs in
|
||||
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
// return lhs.id < rhs.id
|
||||
// return lhs.clientID < rhs.clientID
|
||||
// }
|
||||
// .compactMap(\.stanzaID)
|
||||
// .first
|
||||
@@ -540,12 +540,12 @@ final class AppModel: ObservableObject {
|
||||
.filter { $0.stanzaID != nil }
|
||||
.min { lhs, rhs in
|
||||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
return lhs.id < rhs.id
|
||||
return lhs.clientID < rhs.clientID
|
||||
}?
|
||||
.stanzaID
|
||||
|
||||
isLoadingOlderHistory = true
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
xmpp.loadOlderHistory(
|
||||
conversationJID: conversation.jid,
|
||||
isGroup: conversation.isGroup,
|
||||
@@ -603,7 +603,7 @@ final class AppModel: ObservableObject {
|
||||
informationalMessage = "Приглашения отправлены: \(validInvitees.count)."
|
||||
}
|
||||
} catch {
|
||||
if let index = conversations.firstIndex(where: { $0.id == roomJID }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == roomJID }) {
|
||||
conversations[index].isGroupJoined = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
@@ -613,7 +613,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func joinGroup(jid rawJID: String, nickname preferredNickname: String? = nil) async {
|
||||
let jid = rawJID.lowercased()
|
||||
guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
|
||||
return
|
||||
}
|
||||
guard !conversations[index].isGroupJoined,
|
||||
@@ -647,7 +647,7 @@ final class AppModel: ObservableObject {
|
||||
func leaveGroup(jid rawJID: String) {
|
||||
let jid = rawJID.lowercased()
|
||||
xmpp.leaveRoom(roomJID: jid)
|
||||
guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
|
||||
return
|
||||
}
|
||||
conversations[index].isGroupJoined = false
|
||||
@@ -685,7 +685,7 @@ final class AppModel: ObservableObject {
|
||||
let account,
|
||||
let peer = explicitJID ?? selectedConversation?.jid
|
||||
else { return }
|
||||
let conversation = conversations.first { $0.id == peer.lowercased() }
|
||||
let conversation = conversations.first { $0.jid == peer.lowercased() }
|
||||
let isGroup = conversation?.isGroup == true
|
||||
let encrypted = encryptionEnabled(for: peer)
|
||||
let replyTarget = replyingTo.flatMap { message -> ChatMessage? in
|
||||
@@ -750,7 +750,7 @@ final class AppModel: ObservableObject {
|
||||
guard let account,
|
||||
let peer = explicitJID ?? selectedConversation?.jid
|
||||
else { return }
|
||||
let conversation = conversations.first { $0.id == peer.lowercased() }
|
||||
let conversation = conversations.first { $0.jid == peer.lowercased() }
|
||||
let isGroup = conversation?.isGroup == true
|
||||
let encrypted = encryptionEnabled(for: peer)
|
||||
let id = UUID().uuidString
|
||||
@@ -791,7 +791,7 @@ final class AppModel: ObservableObject {
|
||||
func editMessage(id: String, newBody rawText: String) async {
|
||||
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
let index = messages.firstIndex(where: { $0.id == id }),
|
||||
let index = messages.firstIndex(where: { $0.clientID == id }),
|
||||
messages[index].canBeEdited
|
||||
else { return }
|
||||
|
||||
@@ -818,7 +818,7 @@ final class AppModel: ObservableObject {
|
||||
to: previous.conversationID,
|
||||
messageID: correctionID,
|
||||
encrypted: encrypted,
|
||||
replacingMessageID: previous.id
|
||||
replacingMessageID: previous.clientID
|
||||
)
|
||||
updateMessage(id: id) {
|
||||
$0.delivery = .sent
|
||||
@@ -826,7 +826,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
} catch {
|
||||
correctionReceiptTargets.removeValue(forKey: correctionID)
|
||||
if let currentIndex = messages.firstIndex(where: { $0.id == id }),
|
||||
if let currentIndex = messages.firstIndex(where: { $0.clientID == id }),
|
||||
messages[currentIndex].delivery == .sending
|
||||
{
|
||||
messages[currentIndex] = previous
|
||||
@@ -840,7 +840,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func retractMessage(id: String) async {
|
||||
guard let account,
|
||||
let message = messages.first(where: { $0.id == id }),
|
||||
let message = messages.first(where: { $0.clientID == id }),
|
||||
message.canBeRetracted
|
||||
else { return }
|
||||
|
||||
@@ -848,7 +848,7 @@ final class AppModel: ObservableObject {
|
||||
do {
|
||||
try await xmpp.sendRetraction(
|
||||
to: message.conversationID,
|
||||
targetID: message.id,
|
||||
targetID: message.clientID,
|
||||
retractionID: retractionID,
|
||||
encrypted: message.security == .omemo
|
||||
)
|
||||
@@ -856,7 +856,7 @@ final class AppModel: ObservableObject {
|
||||
XMPPService.RetractionEnvelope(
|
||||
peerJID: message.conversationID,
|
||||
senderJID: account.normalizedJID,
|
||||
targetID: message.id,
|
||||
targetID: message.clientID,
|
||||
retractionID: retractionID,
|
||||
timestamp: Date(),
|
||||
isOutgoing: true
|
||||
@@ -876,7 +876,7 @@ final class AppModel: ObservableObject {
|
||||
guard !ids.isEmpty else { return }
|
||||
let normalizedConversationID = conversationID?.lowercased()
|
||||
let selected = messages.filter { message in
|
||||
ids.contains(message.id)
|
||||
ids.contains(message.clientID)
|
||||
&& (normalizedConversationID == nil
|
||||
|| message.conversationID == normalizedConversationID)
|
||||
}
|
||||
@@ -886,11 +886,11 @@ final class AppModel: ObservableObject {
|
||||
for message in selected {
|
||||
locallyDeletedMessageIDs.insert(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
))
|
||||
let pendingKey = Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
pendingCorrections.removeValue(forKey: pendingKey)
|
||||
@@ -899,7 +899,7 @@ final class AppModel: ObservableObject {
|
||||
guard envelope.peerJID.lowercased() == message.conversationID else {
|
||||
return true
|
||||
}
|
||||
if envelope.targetID == message.id { return false }
|
||||
if envelope.targetID == message.clientID { return false }
|
||||
if let stanzaID = message.stanzaID,
|
||||
envelope.targetID == stanzaID
|
||||
{
|
||||
@@ -907,19 +907,19 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
return true
|
||||
}
|
||||
removeCachedMedia(for: message.id)
|
||||
removeCachedMedia(for: message.clientID)
|
||||
}
|
||||
let selectedKeys = Set(
|
||||
selected.map {
|
||||
Self.localDeletionKey(
|
||||
messageID: $0.id,
|
||||
messageID: $0.clientID,
|
||||
conversationID: $0.conversationID
|
||||
)
|
||||
})
|
||||
messages.removeAll { message in
|
||||
selectedKeys.contains(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
))
|
||||
}
|
||||
@@ -946,7 +946,7 @@ final class AppModel: ObservableObject {
|
||||
selectedMessages
|
||||
.filter(\.canBeForwarded)
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
guard !forwardableMessages.isEmpty else { return false }
|
||||
@@ -995,7 +995,7 @@ final class AppModel: ObservableObject {
|
||||
case .attachment, .photo, .video, .audio, .voice, .videoNote:
|
||||
do {
|
||||
let url: URL
|
||||
if let cached = mediaPreviewURLs[message.id] {
|
||||
if let cached = mediaPreviewURLs[message.clientID] {
|
||||
url = cached
|
||||
} else {
|
||||
url = try await localAttachmentURL(
|
||||
@@ -1031,7 +1031,7 @@ final class AppModel: ObservableObject {
|
||||
guard let conversationID else {
|
||||
if let index = firstMessageIndexByID[id],
|
||||
messages.indices.contains(index),
|
||||
messages[index].id == id
|
||||
messages[index].clientID == id
|
||||
{
|
||||
return messages[index]
|
||||
}
|
||||
@@ -1115,7 +1115,7 @@ final class AppModel: ObservableObject {
|
||||
let id = explicitMessageID ?? UUID().uuidString
|
||||
let filename = Self.safeFilename(rawFilename)
|
||||
let kind = rawKind.isMedia ? rawKind : .attachment
|
||||
let conversation = conversations.first { $0.id == peer.lowercased() }
|
||||
let conversation = conversations.first { $0.jid == peer.lowercased() }
|
||||
let isGroup = conversation?.isGroup == true
|
||||
let encrypted = encryptionEnabled(for: peer)
|
||||
var duration = suppliedDuration
|
||||
@@ -1191,12 +1191,12 @@ final class AppModel: ObservableObject {
|
||||
&& message.delivery == .failed
|
||||
&& message.kind.isMedia
|
||||
&& message.remoteAttachmentURL == nil
|
||||
&& mediaPreviewURLs[message.id] != nil
|
||||
&& mediaPreviewURLs[message.clientID] != nil
|
||||
}
|
||||
|
||||
func retryMediaMessage(_ message: ChatMessage) async {
|
||||
guard canRetryMediaMessage(message),
|
||||
let localURL = mediaPreviewURLs[message.id]
|
||||
let localURL = mediaPreviewURLs[message.clientID]
|
||||
else { return }
|
||||
let activityToken = beginMediaSendActivity()
|
||||
defer { endMediaSendActivity(activityToken) }
|
||||
@@ -1211,7 +1211,7 @@ final class AppModel: ObservableObject {
|
||||
from: localURL,
|
||||
preferredKind: message.kind
|
||||
).data
|
||||
updateMessage(id: message.id) { $0.delivery = .sending }
|
||||
updateMessage(id: message.clientID) { $0.delivery = .sending }
|
||||
let result = try await uploadAndSendMediaWithRetry(
|
||||
data: data,
|
||||
filename: message.localFilename ?? localURL.lastPathComponent,
|
||||
@@ -1219,17 +1219,17 @@ final class AppModel: ObservableObject {
|
||||
kind: message.kind,
|
||||
duration: message.duration,
|
||||
to: message.conversationID,
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
encrypted: message.security == .omemo,
|
||||
isGroup: message.isGroupMessage
|
||||
)
|
||||
updateMessage(id: message.id) {
|
||||
updateMessage(id: message.clientID) {
|
||||
$0.delivery = .sent
|
||||
$0.remoteAttachmentURL = result.remoteURL
|
||||
$0.encryptionFingerprint = result.fingerprint
|
||||
}
|
||||
} catch {
|
||||
updateMessage(id: message.id) { $0.delivery = .failed }
|
||||
updateMessage(id: message.clientID) { $0.delivery = .failed }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
schedulePersist()
|
||||
@@ -1364,7 +1364,7 @@ final class AppModel: ObservableObject {
|
||||
func previewAttachment(_ message: ChatMessage) async {
|
||||
do {
|
||||
let output: URL
|
||||
if let cached = mediaPreviewURLs[message.id] {
|
||||
if let cached = mediaPreviewURLs[message.clientID] {
|
||||
output = cached
|
||||
} else {
|
||||
guard message.remoteAttachmentURL != nil else { return }
|
||||
@@ -1377,36 +1377,36 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func mediaPreviewURL(for message: ChatMessage) -> URL? {
|
||||
mediaPreviewURLs[message.id]
|
||||
mediaPreviewURLs[message.clientID]
|
||||
}
|
||||
|
||||
func mediaThumbnail(for message: ChatMessage) -> Data? {
|
||||
mediaThumbnailData[message.id]
|
||||
mediaThumbnailData[message.clientID]
|
||||
}
|
||||
|
||||
func audioWaveform(for message: ChatMessage) -> [Float] {
|
||||
audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform
|
||||
audioWaveformSamples[message.clientID] ?? MediaPreviewProcessor.placeholderWaveform
|
||||
}
|
||||
|
||||
func isMediaPreviewLoading(_ message: ChatMessage) -> Bool {
|
||||
loadingMediaIDs.contains(message.id)
|
||||
loadingMediaIDs.contains(message.clientID)
|
||||
}
|
||||
|
||||
func prepareMediaPreview(_ message: ChatMessage) async {
|
||||
guard Self.supportsInlinePreview(message.kind),
|
||||
message.remoteAttachmentURL != nil,
|
||||
mediaPreviewURLs[message.id] == nil,
|
||||
loadingMediaIDs.insert(message.id).inserted
|
||||
mediaPreviewURLs[message.clientID] == nil,
|
||||
loadingMediaIDs.insert(message.clientID).inserted
|
||||
else { return }
|
||||
defer { loadingMediaIDs.remove(message.id) }
|
||||
defer { loadingMediaIDs.remove(message.clientID) }
|
||||
|
||||
do {
|
||||
let output = try await localAttachmentURL(
|
||||
for: message, directoryName: "LumaMediaPreviews")
|
||||
mediaPreviewURLs[message.id] = output
|
||||
mediaPreviewURLs[message.clientID] = output
|
||||
await analyzeMediaPreview(
|
||||
at: output,
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
kind: message.kind,
|
||||
mimeType: message.mimeType
|
||||
)
|
||||
@@ -1421,20 +1421,20 @@ final class AppModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let output: URL
|
||||
if let cached = mediaPreviewURLs[message.id] {
|
||||
if let cached = mediaPreviewURLs[message.clientID] {
|
||||
output = cached
|
||||
} else {
|
||||
guard message.remoteAttachmentURL != nil else { return }
|
||||
loadingMediaIDs.insert(message.id)
|
||||
defer { loadingMediaIDs.remove(message.id) }
|
||||
loadingMediaIDs.insert(message.clientID)
|
||||
defer { loadingMediaIDs.remove(message.clientID) }
|
||||
output = try await localAttachmentURL(
|
||||
for: message,
|
||||
directoryName: "LumaMediaPreviews"
|
||||
)
|
||||
mediaPreviewURLs[message.id] = output
|
||||
mediaPreviewURLs[message.clientID] = output
|
||||
await analyzeMediaPreview(
|
||||
at: output,
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
kind: message.kind,
|
||||
mimeType: message.mimeType
|
||||
)
|
||||
@@ -1443,10 +1443,10 @@ final class AppModel: ObservableObject {
|
||||
audioPlayback.stop()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: message.id
|
||||
object: message.clientID
|
||||
)
|
||||
mediaViewerItem = MediaViewerItem(
|
||||
id: message.id,
|
||||
id: message.clientID,
|
||||
url: output,
|
||||
kind: message.kind,
|
||||
title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео")
|
||||
@@ -1466,7 +1466,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func encryptionPreference(for jid: String) -> EncryptionPreference {
|
||||
conversations
|
||||
.first(where: { $0.id == jid.lowercased() })?
|
||||
.first(where: { $0.jid == jid.lowercased() })?
|
||||
.encryptionPreference ?? .inheritGlobal
|
||||
}
|
||||
|
||||
@@ -1490,13 +1490,13 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func typingText(for conversation: Conversation) -> String? {
|
||||
let names =
|
||||
typingParticipantsByConversation[conversation.id.lowercased()]
|
||||
typingParticipantsByConversation[conversation.jid.lowercased()]
|
||||
.map { Array($0.values) } ?? []
|
||||
return ChatTypingPolicy.displayText(names: names, isGroup: conversation.isGroup)
|
||||
}
|
||||
|
||||
func updateComposerActivity(_ text: String, in conversation: Conversation) {
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
localTypingPauseTasks[conversationID]?.cancel()
|
||||
localTypingPauseTasks[conversationID] = nil
|
||||
guard typingIndicatorsEnabled, appIsActive else { return }
|
||||
@@ -1523,7 +1523,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func endComposerActivity(in conversation: Conversation) {
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
localTypingPauseTasks[conversationID]?.cancel()
|
||||
localTypingPauseTasks[conversationID] = nil
|
||||
transitionLocalChatState(.inactive, in: conversation)
|
||||
@@ -1532,7 +1532,7 @@ final class AppModel: ObservableObject {
|
||||
func toggleReaction(_ emoji: String, on sourceMessage: ChatMessage) async {
|
||||
guard let account,
|
||||
let message = message(
|
||||
withID: sourceMessage.id,
|
||||
withID: sourceMessage.clientID,
|
||||
in: sourceMessage.conversationID
|
||||
),
|
||||
message.canBeReactedTo,
|
||||
@@ -1586,7 +1586,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) {
|
||||
guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) else {
|
||||
return
|
||||
}
|
||||
conversations[index].encryptionPreference = preference
|
||||
@@ -1797,7 +1797,7 @@ final class AppModel: ObservableObject {
|
||||
// contacts screen as soon as Prosody confirms the roster push.
|
||||
rosterContactJIDs.remove(jid.lowercased())
|
||||
case .presence(let jid, let online):
|
||||
if let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) {
|
||||
conversations[index].isOnline = online
|
||||
}
|
||||
case .message(let envelope):
|
||||
@@ -1835,7 +1835,7 @@ final class AppModel: ObservableObject {
|
||||
shouldAutojoin: envelope.joined
|
||||
)
|
||||
if let index = conversations.firstIndex(where: {
|
||||
$0.id == envelope.roomJID.lowercased()
|
||||
$0.jid == envelope.roomJID.lowercased()
|
||||
}) {
|
||||
conversations[index].isGroupJoined = envelope.joined
|
||||
conversations[index].occupantCount = envelope.occupantCount
|
||||
@@ -1885,17 +1885,14 @@ final class AppModel: ObservableObject {
|
||||
persistTask?.cancel()
|
||||
persistTask = nil
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, let archive = self.archive else { return }
|
||||
let snapshot = ChatArchive.Snapshot(
|
||||
conversations: self.conversations,
|
||||
messages: self.messages,
|
||||
guard let self, let store = self.store else { return }
|
||||
try? store.save(
|
||||
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
|
||||
rosterContactJIDs: self.rosterContactJIDs,
|
||||
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: self.mamCheckpoints
|
||||
)
|
||||
try? await archive.save(snapshot)
|
||||
}
|
||||
case .mucArchiveSyncCompleted(let archiveKey, let checkpoint):
|
||||
mamCheckpoints[archiveKey] = checkpoint
|
||||
@@ -2030,7 +2027,7 @@ final class AppModel: ObservableObject {
|
||||
incrementUnread: envelope.isArchived ? false : nil
|
||||
)
|
||||
let pendingKey = Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
if let correction = pendingCorrections.removeValue(forKey: pendingKey) {
|
||||
@@ -2113,14 +2110,16 @@ final class AppModel: ObservableObject {
|
||||
|
||||
private func upsertConversation(jid: String, name: String?) {
|
||||
let normalized = jid.lowercased()
|
||||
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
conversations[index].displayName = name
|
||||
}
|
||||
} else {
|
||||
conversations.append(Conversation(jid: normalized, displayName: name))
|
||||
let conversation = Conversation(jid: normalized, displayName: name)
|
||||
store?.context.insert(conversation)
|
||||
conversations.append(conversation)
|
||||
}
|
||||
if conversations.first(where: { $0.id == normalized })?.isGroup != true {
|
||||
if conversations.first(where: { $0.jid == normalized })?.isGroup != true {
|
||||
if !isApplyingArchiveBatch {
|
||||
xmpp.prepareDirectChat(with: normalized)
|
||||
}
|
||||
@@ -2147,7 +2146,7 @@ final class AppModel: ObservableObject {
|
||||
} else {
|
||||
initialDisplayName = fallbackName
|
||||
}
|
||||
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
conversations[index].kind = .group
|
||||
if let resolvedName, !resolvedName.isEmpty {
|
||||
conversations[index].displayName = resolvedName
|
||||
@@ -2161,16 +2160,17 @@ final class AppModel: ObservableObject {
|
||||
conversations[index].shouldAutojoin || shouldAutojoin
|
||||
if let invitedBy { conversations[index].invitedBy = invitedBy }
|
||||
} else {
|
||||
conversations.append(
|
||||
Conversation(
|
||||
jid: normalized,
|
||||
displayName: initialDisplayName,
|
||||
encryptionPreference: .inheritGlobal,
|
||||
kind: .group,
|
||||
groupNickname: nickname,
|
||||
shouldAutojoin: shouldAutojoin,
|
||||
invitedBy: invitedBy
|
||||
))
|
||||
let conversation = Conversation(
|
||||
jid: normalized,
|
||||
displayName: initialDisplayName,
|
||||
encryptionPreference: .inheritGlobal,
|
||||
kind: .group,
|
||||
groupNickname: nickname,
|
||||
shouldAutojoin: shouldAutojoin,
|
||||
invitedBy: invitedBy
|
||||
)
|
||||
store?.context.insert(conversation)
|
||||
conversations.append(conversation)
|
||||
}
|
||||
if !isApplyingArchiveBatch {
|
||||
sortConversations()
|
||||
@@ -2310,7 +2310,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func applyPendingReactions(for message: ChatMessage) {
|
||||
let identifiers = Set([message.id, message.stanzaID].compactMap { $0 })
|
||||
let identifiers = Set([message.clientID, message.stanzaID].compactMap { $0 })
|
||||
guard !identifiers.isEmpty else { return }
|
||||
let matches = pendingReactions.filter { _, envelope in
|
||||
envelope.peerJID.lowercased() == message.conversationID
|
||||
@@ -2394,7 +2394,7 @@ final class AppModel: ObservableObject {
|
||||
in conversation: Conversation
|
||||
) {
|
||||
guard typingIndicatorsEnabled, connectionStatus == .connected else { return }
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
let previous = localChatStateByConversation[conversationID]
|
||||
if state == .paused, previous != .composing { return }
|
||||
if state == .inactive, previous == nil { return }
|
||||
@@ -2450,7 +2450,7 @@ final class AppModel: ObservableObject {
|
||||
original.retractedAt == nil || envelope.timestamp >= original.retractedAt!
|
||||
else { return }
|
||||
|
||||
removeCachedMedia(for: original.id)
|
||||
removeCachedMedia(for: original.clientID)
|
||||
messages[index].body = "Сообщение удалено"
|
||||
messages[index].kind = .system
|
||||
messages[index].remoteAttachmentURL = nil
|
||||
@@ -2471,7 +2471,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
pendingCorrections.removeValue(
|
||||
forKey: Self.localDeletionKey(
|
||||
messageID: original.id,
|
||||
messageID: original.clientID,
|
||||
conversationID: original.conversationID
|
||||
))
|
||||
updateConversationPreview(for: messages[index], incrementUnread: false)
|
||||
@@ -2485,13 +2485,13 @@ final class AppModel: ObservableObject {
|
||||
guard
|
||||
!locallyDeletedMessageIDs.contains(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
))
|
||||
else { return false }
|
||||
upsertConversation(jid: message.conversationID, name: nil)
|
||||
if let index = messageIndex(
|
||||
id: message.id,
|
||||
id: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
) {
|
||||
let previous = messages[index]
|
||||
@@ -2551,6 +2551,13 @@ final class AppModel: ObservableObject {
|
||||
merged.byteCount = previous.byteCount
|
||||
}
|
||||
messages[index] = merged
|
||||
if previous !== merged {
|
||||
// `merged` is the freshly-created incoming message; replace the
|
||||
// previously managed object with it so SwiftData does not keep
|
||||
// an orphaned duplicate of the same clientID.
|
||||
store?.context.delete(previous)
|
||||
store?.context.insert(merged)
|
||||
}
|
||||
if let stanzaID = merged.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
Self.localDeletionKey(
|
||||
@@ -2568,15 +2575,16 @@ final class AppModel: ObservableObject {
|
||||
updateConversationPreview(for: merged, incrementUnread: false)
|
||||
return false
|
||||
}
|
||||
store?.context.insert(message)
|
||||
messages.append(message)
|
||||
let insertedIndex = messages.index(before: messages.endIndex)
|
||||
messageIndexByStorageKey[
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)] = insertedIndex
|
||||
if firstMessageIndexByID[message.id] == nil {
|
||||
firstMessageIndexByID[message.id] = insertedIndex
|
||||
if firstMessageIndexByID[message.clientID] == nil {
|
||||
firstMessageIndexByID[message.clientID] = insertedIndex
|
||||
}
|
||||
if let stanzaID = message.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
@@ -2602,14 +2610,14 @@ final class AppModel: ObservableObject {
|
||||
private func updateMessage(id: String, mutation: (inout ChatMessage) -> Void) {
|
||||
guard let index = firstMessageIndexByID[id],
|
||||
messages.indices.contains(index),
|
||||
messages[index].id == id
|
||||
messages[index].clientID == id
|
||||
else { return }
|
||||
mutation(&messages[index])
|
||||
updateConversationPreview(for: messages[index], incrementUnread: false)
|
||||
}
|
||||
|
||||
private func updateConversationPreview(for message: ChatMessage, incrementUnread: Bool) {
|
||||
guard let index = conversations.firstIndex(where: { $0.id == message.conversationID })
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == message.conversationID })
|
||||
else { return }
|
||||
if message.timestamp >= conversations[index].lastActivity {
|
||||
conversations[index].lastActivity = message.timestamp
|
||||
@@ -2624,14 +2632,14 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func rebuildConversationPreview(for conversationID: String) {
|
||||
guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == conversationID }) else {
|
||||
return
|
||||
}
|
||||
let latest =
|
||||
messages
|
||||
.filter { $0.conversationID == conversationID }
|
||||
.max { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
conversations[index].lastActivity = latest?.timestamp ?? .distantPast
|
||||
@@ -2640,7 +2648,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func conversationName(for jid: String) -> String {
|
||||
conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid
|
||||
conversations.first(where: { $0.jid == jid.lowercased() })?.displayName ?? jid
|
||||
}
|
||||
|
||||
private func sortConversations() {
|
||||
@@ -2660,7 +2668,7 @@ final class AppModel: ObservableObject {
|
||||
)
|
||||
guard let index = messageIndexByStorageKey[key] else { return nil }
|
||||
if messages.indices.contains(index),
|
||||
messages[index].id == id,
|
||||
messages[index].clientID == id,
|
||||
messages[index].conversationID == normalizedConversationID
|
||||
{
|
||||
return index
|
||||
@@ -2716,14 +2724,14 @@ final class AppModel: ObservableObject {
|
||||
firstMessageIndexByID.removeAll(keepingCapacity: true)
|
||||
for (index, message) in messages.enumerated() {
|
||||
let key = Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
if messageIndexByStorageKey[key] == nil {
|
||||
messageIndexByStorageKey[key] = index
|
||||
}
|
||||
if firstMessageIndexByID[message.id] == nil {
|
||||
firstMessageIndexByID[message.id] = index
|
||||
if firstMessageIndexByID[message.clientID] == nil {
|
||||
firstMessageIndexByID[message.clientID] = index
|
||||
}
|
||||
if let stanzaID = message.stanzaID {
|
||||
let stanzaKey = Self.localDeletionKey(
|
||||
@@ -2749,22 +2757,16 @@ final class AppModel: ObservableObject {
|
||||
private func loadArchive(for jid: String) async {
|
||||
resetArchiveBatchState()
|
||||
resetMediaPreviews()
|
||||
let archive = ChatArchive(accountJID: jid)
|
||||
self.archive = archive
|
||||
let snapshot = await archive.load()
|
||||
let migrateLegacyGroupEncryption = snapshot.schemaVersion < 2
|
||||
conversations = snapshot.conversations.map { stored in
|
||||
var conversation = stored
|
||||
guard let store = try? ArchiveStore(accountJID: jid) else {
|
||||
self.store = nil
|
||||
return
|
||||
}
|
||||
self.store = store
|
||||
let snapshot = store.load()
|
||||
conversations = snapshot.conversations.map { conversation in
|
||||
if conversation.isGroup {
|
||||
conversation.isGroupJoined = false
|
||||
conversation.occupantCount = 0
|
||||
if migrateLegacyGroupEncryption, conversation.encryptionPreference == .disabled {
|
||||
// Older releases forced every MUC to disabled and had no
|
||||
// group encryption control, so this value was not a user
|
||||
// choice. Migrate it once, while preserving later explicit
|
||||
// per-room opt-outs in schema v2 snapshots.
|
||||
conversation.encryptionPreference = .inheritGlobal
|
||||
}
|
||||
}
|
||||
return conversation
|
||||
}
|
||||
@@ -2782,7 +2784,7 @@ final class AppModel: ObservableObject {
|
||||
messages = snapshot.messages.filter {
|
||||
!locallyDeletedMessageIDs.contains(
|
||||
Self.localDeletionKey(
|
||||
messageID: $0.id,
|
||||
messageID: $0.clientID,
|
||||
conversationID: $0.conversationID
|
||||
))
|
||||
}
|
||||
@@ -2798,21 +2800,18 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func schedulePersist() {
|
||||
guard let archive else { return }
|
||||
guard let store else { return }
|
||||
persistTask?.cancel()
|
||||
persistTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
let snapshot = ChatArchive.Snapshot(
|
||||
conversations: self.conversations,
|
||||
messages: self.messages,
|
||||
try? store.save(
|
||||
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
|
||||
rosterContactJIDs: self.rosterContactJIDs,
|
||||
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: self.mamCheckpoints
|
||||
)
|
||||
try? await archive.save(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2950,7 +2949,7 @@ final class AppModel: ObservableObject {
|
||||
if let duration = analysis.duration,
|
||||
let index = firstMessageIndexByID[messageID],
|
||||
messages.indices.contains(index),
|
||||
messages[index].id == messageID,
|
||||
messages[index].clientID == messageID,
|
||||
messages[index].duration == nil
|
||||
{
|
||||
messages[index].duration = duration
|
||||
@@ -2966,7 +2965,7 @@ final class AppModel: ObservableObject {
|
||||
throw AttachmentError.missingRemoteURL
|
||||
}
|
||||
let output = try Self.previewFileURL(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
filename: message.localFilename ?? "attachment",
|
||||
directoryName: directoryName
|
||||
)
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
enum Direction: String, Codable, Sendable {
|
||||
@Model
|
||||
final class ChatMessage {
|
||||
enum Direction: String, Codable {
|
||||
case incoming
|
||||
case outgoing
|
||||
}
|
||||
|
||||
enum Delivery: String, Codable, Sendable {
|
||||
enum Delivery: String, Codable {
|
||||
case sending
|
||||
case sent
|
||||
case delivered
|
||||
case failed
|
||||
}
|
||||
|
||||
enum Security: String, Codable, Sendable {
|
||||
enum Security: String, Codable {
|
||||
case omemo
|
||||
case plaintext
|
||||
case decryptionFailed
|
||||
}
|
||||
|
||||
enum Kind: String, Codable, Sendable {
|
||||
enum Kind: String, Codable {
|
||||
case text
|
||||
case attachment
|
||||
case photo
|
||||
@@ -40,7 +42,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
let id: String
|
||||
@Attribute(.unique) var clientID: String
|
||||
var conversationID: String
|
||||
var senderJID: String
|
||||
var body: String
|
||||
@@ -68,6 +70,8 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
var callHistory: CallHistoryMetadata?
|
||||
var reactions: [MessageReaction]
|
||||
|
||||
var conversation: Conversation?
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
conversationID: String,
|
||||
@@ -97,7 +101,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
callHistory: CallHistoryMetadata? = nil,
|
||||
reactions: [MessageReaction] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.clientID = id
|
||||
self.conversationID = conversationID.lowercased()
|
||||
self.senderJID = Self.normalizedSenderJID(senderJID)
|
||||
self.body = body
|
||||
@@ -126,67 +130,6 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
self.reactions = reactions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case conversationID
|
||||
case senderJID
|
||||
case body
|
||||
case timestamp
|
||||
case direction
|
||||
case delivery
|
||||
case security
|
||||
case kind
|
||||
case remoteAttachmentURL
|
||||
case localFilename
|
||||
case mimeType
|
||||
case duration
|
||||
case byteCount
|
||||
case encryptionFingerprint
|
||||
case editedAt
|
||||
case replyToID
|
||||
case replyToJID
|
||||
case replyPreview
|
||||
case forwardedFrom
|
||||
case retractedAt
|
||||
case originID
|
||||
case stanzaID
|
||||
case senderDisplayName
|
||||
case isGroupMessage
|
||||
case callHistory
|
||||
case reactions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
|
||||
senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
|
||||
body = try values.decode(String.self, forKey: .body)
|
||||
timestamp = try values.decode(Date.self, forKey: .timestamp)
|
||||
direction = try values.decode(Direction.self, forKey: .direction)
|
||||
delivery = try values.decode(Delivery.self, forKey: .delivery)
|
||||
security = try values.decode(Security.self, forKey: .security)
|
||||
kind = try values.decode(Kind.self, forKey: .kind)
|
||||
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
|
||||
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
|
||||
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
|
||||
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
|
||||
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
|
||||
encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
|
||||
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
|
||||
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
|
||||
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
|
||||
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
|
||||
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
|
||||
originID = try values.decodeIfPresent(String.self, forKey: .originID)
|
||||
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
|
||||
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
|
||||
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
|
||||
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
|
||||
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
|
||||
}
|
||||
|
||||
var canBeEdited: Bool {
|
||||
!isRetracted
|
||||
&& !isGroupMessage
|
||||
@@ -218,7 +161,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
var replyIdentifier: String? {
|
||||
isGroupMessage ? stanzaID : id
|
||||
isGroupMessage ? stanzaID : clientID
|
||||
}
|
||||
|
||||
var quotePreview: String {
|
||||
|
||||
@@ -3,11 +3,21 @@ import Foundation
|
||||
/// A cached presentation row for the selected chat. Building day boundaries
|
||||
/// once per message mutation avoids walking the full history during every
|
||||
/// scroll-position or composer-state update.
|
||||
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
|
||||
struct ChatTimelineEntry: Identifiable, Hashable {
|
||||
let message: ChatMessage
|
||||
let startsNewDay: Bool
|
||||
|
||||
var id: String { message.id }
|
||||
var id: String { message.clientID }
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.message.clientID == rhs.message.clientID
|
||||
&& lhs.startsNewDay == rhs.startsNewDay
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(message.clientID)
|
||||
hasher.combine(startsNewDay)
|
||||
}
|
||||
|
||||
static func make(
|
||||
from messages: [ChatMessage],
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
enum Kind: String, Codable, Sendable {
|
||||
@Model
|
||||
final class Conversation {
|
||||
enum Kind: String, Codable {
|
||||
case direct
|
||||
case group
|
||||
}
|
||||
|
||||
let id: String
|
||||
var jid: String
|
||||
@Attribute(.unique) var jid: String
|
||||
var displayName: String
|
||||
var lastMessage: String
|
||||
var lastActivity: Date
|
||||
@@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
var occupantCount: Int
|
||||
var invitedBy: String?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation)
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
init(
|
||||
jid: String,
|
||||
displayName: String? = nil,
|
||||
@@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
invitedBy: String? = nil
|
||||
) {
|
||||
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self.id = normalized
|
||||
self.jid = normalized
|
||||
self.displayName = displayName?.nilIfBlank ?? normalized
|
||||
self.lastMessage = lastMessage
|
||||
@@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
self.invitedBy = invitedBy?.nilIfBlank
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case jid
|
||||
case displayName
|
||||
case lastMessage
|
||||
case lastActivity
|
||||
case unreadCount
|
||||
case isOnline
|
||||
case isPinned
|
||||
case encryptionPreference
|
||||
case kind
|
||||
case groupNickname
|
||||
case isGroupJoined
|
||||
case shouldAutojoin
|
||||
case occupantCount
|
||||
case invitedBy
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
jid = try values.decode(String.self, forKey: .jid)
|
||||
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
|
||||
displayName = try values.decode(String.self, forKey: .displayName)
|
||||
lastMessage = try values.decode(String.self, forKey: .lastMessage)
|
||||
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
|
||||
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
|
||||
isOnline = try values.decode(Bool.self, forKey: .isOnline)
|
||||
isPinned = try values.decode(Bool.self, forKey: .isPinned)
|
||||
encryptionPreference = try values.decodeIfPresent(
|
||||
EncryptionPreference.self,
|
||||
forKey: .encryptionPreference
|
||||
) ?? .inheritGlobal
|
||||
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
|
||||
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
|
||||
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
|
||||
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
|
||||
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
|
||||
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
|
||||
}
|
||||
|
||||
var isGroup: Bool { kind == .group }
|
||||
|
||||
var initials: String {
|
||||
|
||||
@@ -80,7 +80,7 @@ enum MessageReactionPolicy {
|
||||
|
||||
extension ChatMessage {
|
||||
var reactionIdentifier: String? {
|
||||
isGroupMessage ? stanzaID : id
|
||||
isGroupMessage ? stanzaID : clientID
|
||||
}
|
||||
|
||||
var canBeReactedTo: Bool {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Codable representation of a single durable MAM checkpoint, keyed by the
|
||||
/// archive it belongs to (account or a specific MUC).
|
||||
struct MAMCheckpointEntry: Codable, Equatable, Sendable {
|
||||
let key: MAMArchiveKey
|
||||
let checkpoint: MAMArchiveCheckpoint
|
||||
}
|
||||
|
||||
/// Singleton per-account metadata that used to live in the JSON snapshot:
|
||||
/// locally deleted message IDs, roster contact JIDs and MAM checkpoints.
|
||||
@Model
|
||||
final class ArchiveMetadata {
|
||||
@Attribute(.unique) var accountJID: String
|
||||
var locallyDeletedMessageIDs: [String]
|
||||
var rosterContactJIDs: [String]
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMCheckpointEntry]
|
||||
|
||||
init(
|
||||
accountJID: String,
|
||||
locallyDeletedMessageIDs: [String] = [],
|
||||
rosterContactJIDs: [String] = [],
|
||||
lastSuccessfulMAMSync: Date? = nil,
|
||||
lastSuccessfulMAMCursor: String? = nil,
|
||||
mamCheckpoints: [MAMCheckpointEntry] = []
|
||||
) {
|
||||
self.accountJID = accountJID
|
||||
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
|
||||
self.rosterContactJIDs = rosterContactJIDs
|
||||
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
|
||||
self.mamCheckpoints = mamCheckpoints
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
|
||||
/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
|
||||
/// once, and exposes load/save/erase.
|
||||
@MainActor
|
||||
final class ArchiveStore {
|
||||
struct Loaded {
|
||||
var conversations: [Conversation]
|
||||
var messages: [ChatMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
}
|
||||
|
||||
let container: ModelContainer
|
||||
let context: ModelContext
|
||||
private let accountJID: String
|
||||
private let storeURL: URL
|
||||
private let legacyJSONURL: URL
|
||||
private(set) var metadata: ArchiveMetadata?
|
||||
|
||||
init(accountJID: String) throws {
|
||||
self.accountJID = accountJID
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let hash = Self.stableHash(accountJID)
|
||||
storeURL = directory.appendingPathComponent("\(hash).store")
|
||||
legacyJSONURL = directory.appendingPathComponent("\(hash).json")
|
||||
|
||||
let schema = Schema([
|
||||
Conversation.self,
|
||||
ChatMessage.self,
|
||||
ArchiveMetadata.self,
|
||||
])
|
||||
let configuration = ModelConfiguration(schema: schema, url: storeURL)
|
||||
container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
context = ModelContext(container)
|
||||
context.autosaveEnabled = false
|
||||
|
||||
migrateLegacyJSONIfNeeded()
|
||||
}
|
||||
|
||||
func load() -> Loaded {
|
||||
let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
|
||||
let conversations = (try? context.fetch(FetchDescriptor<Conversation>(sortBy: conversationSort))) ?? []
|
||||
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
|
||||
|
||||
let metadata = fetchMetadata()
|
||||
let mamCheckpoints = Dictionary(
|
||||
metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
return Loaded(
|
||||
conversations: conversations,
|
||||
messages: messages,
|
||||
locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
|
||||
rosterContactJIDs: Set(metadata.rosterContactJIDs),
|
||||
lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: mamCheckpoints
|
||||
)
|
||||
}
|
||||
|
||||
/// Persists the passed metadata plus all pending model mutations.
|
||||
func save(
|
||||
locallyDeletedMessageIDs: Set<String>,
|
||||
rosterContactJIDs: Set<String>,
|
||||
lastSuccessfulMAMSync: Date?,
|
||||
lastSuccessfulMAMCursor: String?,
|
||||
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
) throws {
|
||||
let metadata = fetchMetadata()
|
||||
metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
|
||||
metadata.rosterContactJIDs = Array(rosterContactJIDs)
|
||||
metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
|
||||
metadata.mamCheckpoints = mamCheckpoints.map {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
try context.delete(model: Conversation.self)
|
||||
try context.delete(model: ChatMessage.self)
|
||||
try context.delete(model: ArchiveMetadata.self)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
private func fetchMetadata() -> ArchiveMetadata {
|
||||
if let metadata { return metadata }
|
||||
let existing = try? context.fetch(FetchDescriptor<ArchiveMetadata>()).first
|
||||
let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
|
||||
context.insert(metadata)
|
||||
self.metadata = metadata
|
||||
return metadata
|
||||
}
|
||||
|
||||
private func migrateLegacyJSONIfNeeded() {
|
||||
guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
|
||||
guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
|
||||
return
|
||||
}
|
||||
for conversation in imported.conversations {
|
||||
context.insert(conversation)
|
||||
}
|
||||
for message in imported.messages {
|
||||
context.insert(message)
|
||||
}
|
||||
let metadata = fetchMetadata()
|
||||
metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
|
||||
metadata.rosterContactJIDs = imported.rosterContactJIDs
|
||||
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
|
||||
metadata.mamCheckpoints = imported.mamCheckpoints
|
||||
try? context.save()
|
||||
try? FileManager.default.removeItem(at: legacyJSONURL)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
|
||||
(partial ^ UInt64(byte)) &* 1_099_511_628_211
|
||||
}
|
||||
return String(hash, radix: 16, uppercase: false)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
actor ChatArchive {
|
||||
struct Snapshot: Codable, Sendable {
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
var schemaVersion: Int
|
||||
var conversations: [Conversation]
|
||||
var messages: [ChatMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
|
||||
init(
|
||||
conversations: [Conversation],
|
||||
messages: [ChatMessage],
|
||||
locallyDeletedMessageIDs: Set<String> = [],
|
||||
rosterContactJIDs: Set<String> = [],
|
||||
lastSuccessfulMAMSync: Date? = nil,
|
||||
lastSuccessfulMAMCursor: String? = nil,
|
||||
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
|
||||
) {
|
||||
self.schemaVersion = Self.currentSchemaVersion
|
||||
self.conversations = conversations
|
||||
self.messages = messages
|
||||
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
|
||||
self.rosterContactJIDs = rosterContactJIDs
|
||||
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
|
||||
lastSuccessfulMAMCursor
|
||||
)
|
||||
self.mamCheckpoints = mamCheckpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case conversations
|
||||
case messages
|
||||
case locallyDeletedMessageIDs
|
||||
case rosterContactJIDs
|
||||
case lastSuccessfulMAMSync
|
||||
case lastSuccessfulMAMCursor
|
||||
case mamCheckpoints
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
conversations = try values.decode([Conversation].self, forKey: .conversations)
|
||||
messages = try values.decode([ChatMessage].self, forKey: .messages)
|
||||
locallyDeletedMessageIDs = try values.decodeIfPresent(
|
||||
Set<String>.self,
|
||||
forKey: .locallyDeletedMessageIDs
|
||||
) ?? []
|
||||
rosterContactJIDs = try values.decodeIfPresent(
|
||||
Set<String>.self,
|
||||
forKey: .rosterContactJIDs
|
||||
) ?? []
|
||||
lastSuccessfulMAMSync = try values.decodeIfPresent(
|
||||
Date.self,
|
||||
forKey: .lastSuccessfulMAMSync
|
||||
)
|
||||
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
|
||||
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
|
||||
)
|
||||
mamCheckpoints = try values.decodeIfPresent(
|
||||
[MAMArchiveKey: MAMArchiveCheckpoint].self, forKey: .mamCheckpoints
|
||||
) ?? [:]
|
||||
}
|
||||
|
||||
static let empty = Snapshot(conversations: [], messages: [])
|
||||
}
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(accountJID: String) {
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
|
||||
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
}
|
||||
|
||||
func load() -> Snapshot {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
|
||||
return .empty
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func save(_ snapshot: Snapshot) throws {
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: [.atomic])
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: fileURL.path
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
|
||||
(partial ^ UInt64(byte)) &* 1_099_511_628_211
|
||||
}
|
||||
return String(hash, radix: 16, uppercase: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
|
||||
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
|
||||
/// objects once, so existing history survives the migration from the JSON file.
|
||||
enum LegacyArchiveImporter {
|
||||
struct LegacyConversation: Codable {
|
||||
enum Kind: String, Codable { case direct, group }
|
||||
var id: String
|
||||
var jid: String
|
||||
var displayName: String
|
||||
var lastMessage: String
|
||||
var lastActivity: Date
|
||||
var unreadCount: Int
|
||||
var isOnline: Bool
|
||||
var isPinned: Bool
|
||||
var encryptionPreference: EncryptionPreference
|
||||
var kind: Kind
|
||||
var groupNickname: String?
|
||||
var isGroupJoined: Bool
|
||||
var shouldAutojoin: Bool
|
||||
var occupantCount: Int
|
||||
var invitedBy: String?
|
||||
}
|
||||
|
||||
struct LegacyMessage: Codable {
|
||||
enum Direction: String, Codable { case incoming, outgoing }
|
||||
enum Delivery: String, Codable { case sending, sent, delivered, failed }
|
||||
enum Security: String, Codable { case omemo, plaintext, decryptionFailed }
|
||||
enum Kind: String, Codable {
|
||||
case text, attachment, photo, video, audio, voice, videoNote, location, system
|
||||
}
|
||||
var id: String
|
||||
var conversationID: String
|
||||
var senderJID: String
|
||||
var body: String
|
||||
var timestamp: Date
|
||||
var direction: Direction
|
||||
var delivery: Delivery
|
||||
var security: Security
|
||||
var kind: Kind
|
||||
var remoteAttachmentURL: String?
|
||||
var localFilename: String?
|
||||
var mimeType: String?
|
||||
var duration: TimeInterval?
|
||||
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 callHistory: CallHistoryMetadata?
|
||||
var reactions: [MessageReaction]
|
||||
}
|
||||
|
||||
struct Snapshot: Codable {
|
||||
var schemaVersion: Int
|
||||
var conversations: [LegacyConversation]
|
||||
var messages: [LegacyMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
}
|
||||
|
||||
struct Imported {
|
||||
let conversations: [Conversation]
|
||||
let messages: [ChatMessage]
|
||||
let locallyDeletedMessageIDs: [String]
|
||||
let rosterContactJIDs: [String]
|
||||
let lastSuccessfulMAMSync: Date?
|
||||
let lastSuccessfulMAMCursor: String?
|
||||
let mamCheckpoints: [MAMCheckpointEntry]
|
||||
}
|
||||
|
||||
static func importIfNeeded(from url: URL) throws -> Imported? {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
let snapshot = try decoder.decode(Snapshot.self, from: data)
|
||||
|
||||
let conversations = snapshot.conversations.map { legacy -> Conversation in
|
||||
let conversation = Conversation(
|
||||
jid: legacy.jid,
|
||||
displayName: legacy.displayName,
|
||||
lastMessage: legacy.lastMessage,
|
||||
lastActivity: legacy.lastActivity,
|
||||
unreadCount: legacy.unreadCount,
|
||||
isOnline: legacy.isOnline,
|
||||
isPinned: legacy.isPinned,
|
||||
encryptionPreference: legacy.encryptionPreference,
|
||||
kind: legacy.kind == .group ? .group : .direct,
|
||||
groupNickname: legacy.groupNickname,
|
||||
isGroupJoined: legacy.isGroupJoined,
|
||||
shouldAutojoin: legacy.shouldAutojoin,
|
||||
occupantCount: legacy.occupantCount,
|
||||
invitedBy: legacy.invitedBy
|
||||
)
|
||||
return conversation
|
||||
}
|
||||
|
||||
let conversationByJID = Dictionary(
|
||||
conversations.map { ($0.jid, $0) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
let messages = snapshot.messages.map { legacy -> ChatMessage in
|
||||
let message = ChatMessage(
|
||||
id: legacy.id,
|
||||
conversationID: legacy.conversationID,
|
||||
senderJID: legacy.senderJID,
|
||||
body: legacy.body,
|
||||
timestamp: legacy.timestamp,
|
||||
direction: legacy.direction == .outgoing ? .outgoing : .incoming,
|
||||
delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent,
|
||||
security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext,
|
||||
kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text,
|
||||
remoteAttachmentURL: legacy.remoteAttachmentURL,
|
||||
localFilename: legacy.localFilename,
|
||||
mimeType: legacy.mimeType,
|
||||
duration: legacy.duration,
|
||||
byteCount: legacy.byteCount,
|
||||
encryptionFingerprint: legacy.encryptionFingerprint,
|
||||
editedAt: legacy.editedAt,
|
||||
replyToID: legacy.replyToID,
|
||||
replyToJID: legacy.replyToJID,
|
||||
replyPreview: legacy.replyPreview,
|
||||
forwardedFrom: legacy.forwardedFrom,
|
||||
retractedAt: legacy.retractedAt,
|
||||
originID: legacy.originID,
|
||||
stanzaID: legacy.stanzaID,
|
||||
senderDisplayName: legacy.senderDisplayName,
|
||||
isGroupMessage: legacy.isGroupMessage,
|
||||
callHistory: legacy.callHistory,
|
||||
reactions: legacy.reactions
|
||||
)
|
||||
message.conversation = conversationByJID[legacy.conversationID.lowercased()]
|
||||
return message
|
||||
}
|
||||
|
||||
return Imported(
|
||||
conversations: conversations,
|
||||
messages: messages,
|
||||
locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs),
|
||||
rosterContactJIDs: Array(snapshot.rosterContactJIDs),
|
||||
lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: snapshot.mamCheckpoints.map {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ enum ChatMediaImageCache {
|
||||
for message: ChatMessage,
|
||||
data: Data
|
||||
) -> ChatMediaPlatformImage? {
|
||||
let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
|
||||
let cacheKey = "\(message.conversationID)\u{1F}\(message.clientID)" as NSString
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
messages: [ChatMessage]
|
||||
) -> Data? {
|
||||
let visibleConversations = Array(conversations.prefix(20))
|
||||
let visibleConversationIDs = Set(visibleConversations.map(\.id))
|
||||
let visibleConversationIDs = Set(visibleConversations.map(\.jid))
|
||||
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
|
||||
|
||||
// Keep a bounded, chronologically sorted window. MAM can append an old
|
||||
@@ -103,10 +103,10 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
jid: conversation.jid,
|
||||
name: conversation.displayName,
|
||||
unread: conversation.unreadCount,
|
||||
messages: (recentMessagesByConversation[conversation.id] ?? [])
|
||||
messages: (recentMessagesByConversation[conversation.jid] ?? [])
|
||||
.map {
|
||||
PhoneWatchSnapshot.Message(
|
||||
id: $0.id,
|
||||
id: $0.clientID,
|
||||
body: $0.previewText,
|
||||
timestamp: $0.timestamp,
|
||||
outgoing: $0.direction == .outgoing,
|
||||
@@ -128,7 +128,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
let middle = lowerBound + (upperBound - lowerBound) / 2
|
||||
let existing = sortedMessages[middle]
|
||||
let existingComesFirst = existing.timestamp < message.timestamp
|
||||
|| (existing.timestamp == message.timestamp && existing.id < message.id)
|
||||
|| (existing.timestamp == message.timestamp && existing.clientID < message.clientID)
|
||||
if existingComesFirst {
|
||||
lowerBound = middle + 1
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ struct CallView: View {
|
||||
let call: CallSnapshot
|
||||
|
||||
private var conversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
|
||||
model.conversations.first(where: { $0.jid == call.peerJID.lowercased() })
|
||||
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ private enum EmojiPickerPresentation: Identifiable {
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .reaction(let message): return "reaction-\(message.id)"
|
||||
case .reaction(let message): return "reaction-\(message.clientID)"
|
||||
case .composer: return "composer"
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ struct ChatView: View {
|
||||
.overlay {
|
||||
replyThreadOverlay
|
||||
}
|
||||
.onChange(of: conversation.id) { _, _ in
|
||||
.onChange(of: conversation.jid) { _, _ in
|
||||
hasCompletedInitialScroll = false
|
||||
isNearTimelineBottom = true
|
||||
historyLoadAnchorID = nil
|
||||
@@ -284,7 +284,7 @@ struct ChatView: View {
|
||||
@ViewBuilder
|
||||
private var replyThreadOverlay: some View {
|
||||
if let selection = replyThreadSelection,
|
||||
let root = model.message(withID: selection.rootID, in: conversation.id) {
|
||||
let root = model.message(withID: selection.rootID, in: conversation.jid) {
|
||||
ReplyThreadOverlay(
|
||||
model: model,
|
||||
rootMessage: root,
|
||||
@@ -332,7 +332,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var selectedTimelineMessages: [ChatMessage] {
|
||||
model.selectedMessages.filter { selectedMessageIDs.contains($0.id) }
|
||||
model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) }
|
||||
}
|
||||
|
||||
private var isSelectingMessages: Bool {
|
||||
@@ -396,7 +396,7 @@ struct ChatView: View {
|
||||
model: model,
|
||||
message: message,
|
||||
isSelectionMode: isSelectingMessages,
|
||||
isSelected: selectedMessageIDs.contains(message.id),
|
||||
isSelected: selectedMessageIDs.contains(message.clientID),
|
||||
onAttachmentTap: {
|
||||
Task { await model.previewAttachment(message) }
|
||||
},
|
||||
@@ -417,7 +417,7 @@ struct ChatView: View {
|
||||
onReplyTap: { targetID in
|
||||
presentReplyThread(
|
||||
rootID: targetID,
|
||||
selectedReplyID: message.id
|
||||
selectedReplyID: message.clientID
|
||||
)
|
||||
},
|
||||
onReact: { emoji in
|
||||
@@ -427,7 +427,7 @@ struct ChatView: View {
|
||||
emojiPickerPresentation = .reaction(message)
|
||||
}
|
||||
)
|
||||
.id(message.id)
|
||||
.id(message.clientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +435,7 @@ struct ChatView: View {
|
||||
|
||||
timelineBottomSentinel
|
||||
#if os(iOS)
|
||||
ChatScrollMetricsObserver(identity: conversation.id) { isNearBottom in
|
||||
ChatScrollMetricsObserver(identity: conversation.jid) { isNearBottom in
|
||||
guard isNearTimelineBottom != isNearBottom else { return }
|
||||
isNearTimelineBottom = isNearBottom
|
||||
}
|
||||
@@ -766,13 +766,13 @@ struct ChatView: View {
|
||||
|
||||
private var editingMessage: ChatMessage? {
|
||||
guard let editingMessageID else { return nil }
|
||||
return model.selectedMessages.first { $0.id == editingMessageID }
|
||||
return model.selectedMessages.first { $0.clientID == editingMessageID }
|
||||
}
|
||||
|
||||
private var replyingToMessage: ChatMessage? {
|
||||
guard let replyingToMessageID else { return nil }
|
||||
return model.selectedMessages.first {
|
||||
$0.id == replyingToMessageID && $0.canBeRepliedTo
|
||||
$0.clientID == replyingToMessageID && $0.canBeRepliedTo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +790,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var liveConversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
|
||||
model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation
|
||||
}
|
||||
|
||||
private var chatNavigationTitle: some View {
|
||||
@@ -1212,7 +1212,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func presentReplyThread(rootID: String, selectedReplyID: String) {
|
||||
guard model.message(withID: rootID, in: conversation.id) != nil else {
|
||||
guard model.message(withID: rootID, in: conversation.jid) != nil else {
|
||||
model.errorMessage = "Исходное сообщение ещё не загружено в локальную историю."
|
||||
return
|
||||
}
|
||||
@@ -1234,7 +1234,7 @@ struct ChatView: View {
|
||||
for root: ChatMessage,
|
||||
selectedReplyID: String
|
||||
) -> [ChatMessage] {
|
||||
var sourceIdentifiers: Set<String> = [root.id]
|
||||
var sourceIdentifiers: Set<String> = [root.clientID]
|
||||
if let stanzaID = root.stanzaID { sourceIdentifiers.insert(stanzaID) }
|
||||
if let replyIdentifier = root.replyIdentifier {
|
||||
sourceIdentifiers.insert(replyIdentifier)
|
||||
@@ -1244,12 +1244,12 @@ struct ChatView: View {
|
||||
guard let replyToID = candidate.replyToID else { return false }
|
||||
return sourceIdentifiers.contains(replyToID)
|
||||
}
|
||||
if !replies.contains(where: { $0.id == selectedReplyID }),
|
||||
let selected = model.message(withID: selectedReplyID, in: conversation.id) {
|
||||
if !replies.contains(where: { $0.clientID == selectedReplyID }),
|
||||
let selected = model.message(withID: selectedReplyID, in: conversation.jid) {
|
||||
replies.append(selected)
|
||||
}
|
||||
return replies.sorted { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
}
|
||||
@@ -1407,7 +1407,7 @@ struct ChatView: View {
|
||||
guard message.canBeEdited else { return }
|
||||
cancelComposerRecording(feedback: false)
|
||||
replyingToMessageID = nil
|
||||
editingMessageID = message.id
|
||||
editingMessageID = message.clientID
|
||||
draft = message.body
|
||||
isComposerFocused = true
|
||||
}
|
||||
@@ -1429,14 +1429,14 @@ struct ChatView: View {
|
||||
dismissKeyboard()
|
||||
if editingMessageID != nil { cancelEditing() }
|
||||
cancelReplying()
|
||||
selectedMessageIDs.insert(message.id)
|
||||
selectedMessageIDs.insert(message.clientID)
|
||||
}
|
||||
|
||||
private func toggleMessageSelection(_ message: ChatMessage) {
|
||||
if selectedMessageIDs.contains(message.id) {
|
||||
selectedMessageIDs.remove(message.id)
|
||||
if selectedMessageIDs.contains(message.clientID) {
|
||||
selectedMessageIDs.remove(message.clientID)
|
||||
} else {
|
||||
selectedMessageIDs.insert(message.id)
|
||||
selectedMessageIDs.insert(message.clientID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1457,7 +1457,7 @@ struct ChatView: View {
|
||||
editingMessageID = nil
|
||||
draft = ""
|
||||
}
|
||||
replyingToMessageID = message.id
|
||||
replyingToMessageID = message.clientID
|
||||
isComposerFocused = true
|
||||
}
|
||||
|
||||
@@ -1526,7 +1526,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func perform(_ action: MessageDestructiveAction) {
|
||||
let ids = Set(action.messages.map(\.id))
|
||||
let ids = Set(action.messages.map(\.clientID))
|
||||
if let editingMessageID, ids.contains(editingMessageID) {
|
||||
cancelEditing()
|
||||
}
|
||||
@@ -1536,11 +1536,11 @@ struct ChatView: View {
|
||||
selectedMessageIDs.subtract(ids)
|
||||
switch action.kind {
|
||||
case .localDelete:
|
||||
model.deleteMessagesLocally(ids: ids, in: conversation.id)
|
||||
model.deleteMessagesLocally(ids: ids, in: conversation.jid)
|
||||
case .retract:
|
||||
Task {
|
||||
for message in action.messages where message.canBeRetracted {
|
||||
await model.retractMessage(id: message.id)
|
||||
await model.retractMessage(id: message.clientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2161,7 +2161,7 @@ private struct MessageDestructiveAction: Identifiable {
|
||||
let messages: [ChatMessage]
|
||||
|
||||
var id: String {
|
||||
let messageIDs = messages.map(\.id).sorted().joined(separator: "-")
|
||||
let messageIDs = messages.map(\.clientID).sorted().joined(separator: "-")
|
||||
return "\(kind == .localDelete ? "local" : "retract")-\(messageIDs)"
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ struct AudioMessagePlayer: View {
|
||||
}
|
||||
|
||||
private var isCurrent: Bool {
|
||||
playback.currentMessageID == message.id
|
||||
playback.currentMessageID == message.clientID
|
||||
}
|
||||
|
||||
private var isPlaying: Bool {
|
||||
@@ -194,7 +194,7 @@ struct AudioMessagePlayer: View {
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
url: downloadedURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
@@ -203,7 +203,7 @@ struct AudioMessagePlayer: View {
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
@@ -213,7 +213,7 @@ struct AudioMessagePlayer: View {
|
||||
private func seek(_ fraction: Double) {
|
||||
guard let localURL else { return }
|
||||
playback.seek(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice,
|
||||
|
||||
@@ -124,7 +124,7 @@ struct PhotoAttachmentPreview: View {
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
if model.mediaViewerItem?.id != message.clientID,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
|
||||
@@ -112,10 +112,10 @@ struct ReplyThreadOverlay: View {
|
||||
model: model,
|
||||
message: reply,
|
||||
maximumBubbleWidth: maximumBubbleWidth,
|
||||
isSelected: reply.id == selectedReplyID,
|
||||
showsDeliveryStatus: reply.id == selectedReplyID
|
||||
isSelected: reply.clientID == selectedReplyID,
|
||||
showsDeliveryStatus: reply.clientID == selectedReplyID
|
||||
)
|
||||
.id(reply.id)
|
||||
.id(reply.clientID)
|
||||
}
|
||||
}
|
||||
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
|
||||
|
||||
@@ -142,7 +142,7 @@ struct VideoAttachmentPreview: View {
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
if model.mediaViewerItem?.id != message.clientID,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ struct VideoNotePreview: View {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
|
||||
guard let activeID = notification.object as? String,
|
||||
activeID != message.id else { return }
|
||||
activeID != message.clientID else { return }
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
@@ -146,7 +146,7 @@ struct VideoNotePreview: View {
|
||||
model.audioPlayback.stop()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: message.id
|
||||
object: message.clientID
|
||||
)
|
||||
player.play()
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ struct GroupInfoView: View {
|
||||
}
|
||||
|
||||
private var liveConversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
|
||||
model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation
|
||||
}
|
||||
|
||||
private var parsedInvitees: [String] {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct MainChatView: View {
|
||||
|
||||
private var filteredRosterContacts: [Conversation] {
|
||||
model.conversations
|
||||
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) }
|
||||
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) }
|
||||
.filter { searchText.isEmpty || matchesSearch($0) }
|
||||
.sorted(by: contactSort)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ struct MainChatView: View {
|
||||
} detail: {
|
||||
if let conversation = model.selectedConversation {
|
||||
ChatView(model: model, conversation: conversation)
|
||||
.id(conversation.id)
|
||||
.id(conversation.jid)
|
||||
} else {
|
||||
WelcomeDetailView()
|
||||
}
|
||||
@@ -120,7 +120,7 @@ struct MainChatView: View {
|
||||
imageData: model.avatarData(for: conversation.jid),
|
||||
isEncrypted: model.encryptionEnabled(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.tag(conversation.jid)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ struct MainChatView: View {
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.tag(conversation.jid)
|
||||
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
|
||||
}
|
||||
} header: {
|
||||
@@ -164,7 +164,7 @@ struct MainChatView: View {
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.tag(conversation.jid)
|
||||
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
|
||||
}
|
||||
} header: {
|
||||
|
||||
Reference in new issue
Block a user