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 {
|
||||
|
||||
Reference in new issue
Block a user