diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index df37e52..44646e8 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -97,6 +97,11 @@ stanza отдельно сохраняет назначенный комнато декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются межклиентно совместимыми. +Выход из комнаты оставляет чат в списке как «Не подключено». Удаление чата +убирает его и локальную историю с устройства (и предварительно выходит из +комнаты на сервере, если участник в ней); подавление на время сессии не даёт +событиям комнаты создать удалённый чат заново. + XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное сообщение. Перед применением `AppModel` проверяет conversation и JID автора, после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 9403bcd..ad5e7c1 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -312,6 +312,22 @@ grep -q 'Сервер отклонил имя пользователя или п echo "SASL failures must produce an actionable Russian message" exit 1 } +grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || { + echo "Group chats must support local deletion" + exit 1 +} +grep -q 'deletedGroupChatJIDs' Sources/Shared/Models/AppModel.swift || { + echo "Deleted group chats must stay suppressed for the session" + exit 1 +} +grep -q 'Удалить чат' Sources/Shared/UI/GroupInfoView.swift || { + echo "Group info must offer deleting the chat" + exit 1 +} +grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainChatView.swift || { + echo "Chat list must offer deleting group chats" + exit 1 +} grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || { echo "MAM reconnects must prefer the durable archive UID over timestamps" exit 1 diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index dcbf1ff..0190fd7 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -104,6 +104,7 @@ final class AppModel: ObservableObject { private var hasMoreOlderHistoryByConversation: [String: Bool] = [:] private var pendingRoomPasswords: [String: String] = [:] private var joiningRoomJIDs: Set = [] + private var deletedGroupChatJIDs: Set = [] private var mediaSendActivity = MediaSendActivityTracker() private var mediaPreparationTokens: Set = [] private var videoNoteCaptureIsActive = false @@ -353,6 +354,7 @@ final class AppModel: ObservableObject { locallyDeletedMessageIDs = [] pendingRoomPasswords = [:] joiningRoomJIDs = [] + deletedGroupChatJIDs = [] rosterContactJIDs = [] resetMediaSendActivity() resetMediaPreviews() @@ -404,6 +406,7 @@ final class AppModel: ObservableObject { conversations = [] rosterContactJIDs = [] messages = [] + deletedGroupChatJIDs = [] rebuildMessageIndex() selectedConversationID = nil isOMEMOReady = false @@ -623,6 +626,7 @@ final class AppModel: ObservableObject { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter(Self.isValidJID) + deletedGroupChatJIDs.remove(roomJID) upsertGroupConversation( jid: roomJID, name: name, @@ -692,6 +696,64 @@ final class AppModel: ObservableObject { schedulePersist() } + /// Leaves the room on the server (when joined) and removes the chat + /// together with its local history from this device. + func deleteGroupChat(jid rawJID: String) { + let jid = rawJID.lowercased() + // Suppress re-creation first: leaveRoom fires a synchronous + // roomState event, and the server-side state transition would + // otherwise resurrect the conversation later in this session. + deletedGroupChatJIDs.insert(jid) + let wasJoined = + conversations.first(where: { $0.jid == jid && $0.isGroup })?.isGroupJoined + == true + if wasJoined { + xmpp.leaveRoom(roomJID: jid) + } + removeGroupChatLocally(jid: jid) + informationalMessage = + wasJoined + ? "Вы вышли из комнаты, чат удалён с этого устройства." + : "Групповой чат удалён с этого устройства." + } + + private func removeGroupChatLocally(jid: String) { + let normalized = jid.lowercased() + if let conversation = conversations.first(where: { $0.jid == normalized }) { + modelContext.delete(conversation) + } + conversations.removeAll { $0.jid == normalized } + + let removedMessages = messages.filter { $0.conversationID == normalized } + for message in removedMessages { + modelContext.delete(message) + removeCachedMedia(for: message.clientID) + } + messages.removeAll { $0.conversationID == normalized } + rebuildMessageIndex() + + let deletionKeyPrefix = normalized + "\u{1F}" + locallyDeletedMessageIDs = locallyDeletedMessageIDs.filter { + !$0.hasPrefix(deletionKeyPrefix) + } + pendingCorrections = pendingCorrections.filter { !$0.key.hasPrefix(deletionKeyPrefix) } + pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) } + pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") } + hasMoreOlderHistoryByConversation.removeValue(forKey: normalized) + pendingRoomPasswords.removeValue(forKey: normalized) + joiningRoomJIDs.remove(normalized) + localChatStateByConversation.removeValue(forKey: normalized) + localTypingPauseTasks.removeValue(forKey: normalized)?.cancel() + remoteTypingExpiryTasks.removeValue(forKey: normalized)?.cancel() + typingParticipantsByConversation.removeValue(forKey: normalized) + if selectedConversationID == normalized { + selectedConversationID = nil + } + sortConversations() + schedulePersist() + syncWatch() + } + func inviteMembers(_ rawJIDs: [String], to roomJID: String) async { let jids = rawJIDs @@ -1879,6 +1941,7 @@ final class AppModel: ObservableObject { } case .roomInvitation(let invitation): let jid = invitation.roomJID.lowercased() + deletedGroupChatJIDs.remove(jid) upsertGroupConversation( jid: jid, name: nil, @@ -2175,6 +2238,7 @@ final class AppModel: ObservableObject { invitedBy: String? = nil ) { let normalized = jid.lowercased() + guard !deletedGroupChatJIDs.contains(normalized) else { return } let fallbackName = normalized.split(separator: "@").first.map(String.init) ?? normalized let resolvedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) let initialDisplayName: String diff --git a/Sources/Shared/UI/GroupInfoView.swift b/Sources/Shared/UI/GroupInfoView.swift index b3af7dd..c1d0441 100644 --- a/Sources/Shared/UI/GroupInfoView.swift +++ b/Sources/Shared/UI/GroupInfoView.swift @@ -11,6 +11,7 @@ struct GroupInfoView: View { @State private var invitees = "" @State private var isJoining = false @State private var isInviting = false + @State private var confirmingDeletion = false init(model: AppModel, conversation: Conversation) { self.model = model @@ -68,6 +69,16 @@ struct GroupInfoView: View { } } + Section { + Button(role: .destructive) { + confirmingDeletion = true + } label: { + Label("Удалить чат", systemImage: "trash") + } + } footer: { + Text("Чат и его история будут удалены с этого устройства.") + } + Section { Label( model.encryptionEnabled(for: conversation.jid) @@ -85,6 +96,15 @@ struct GroupInfoView: View { Button("Готово") { dismiss() } } } + .alert("Удалить групповой чат?", isPresented: $confirmingDeletion) { + Button("Удалить", role: .destructive) { + model.deleteGroupChat(jid: conversation.jid) + dismiss() + } + Button("Отмена", role: .cancel) {} + } message: { + Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.") + } } #if os(macOS) .frame(minWidth: 480, minHeight: 500) diff --git a/Sources/Shared/UI/MainChatView.swift b/Sources/Shared/UI/MainChatView.swift index 4c0bc8f..be341de 100644 --- a/Sources/Shared/UI/MainChatView.swift +++ b/Sources/Shared/UI/MainChatView.swift @@ -21,6 +21,7 @@ struct MainChatView: View { @State private var showingNewChat = false @State private var showingNewGroup = false @State private var showingSettings = false + @State private var pendingGroupDeletion: Conversation? /// SwiftData-backed chat list. The query orders by last activity; /// `filteredConversations` applies the pinned-first ordering that matches @@ -129,6 +130,23 @@ struct MainChatView: View { .sheet(isPresented: $showingSettings) { SettingsView(model: model) } + .alert( + "Удалить групповой чат?", + isPresented: Binding( + get: { pendingGroupDeletion != nil }, + set: { if !$0 { pendingGroupDeletion = nil } } + ) + ) { + Button("Удалить", role: .destructive) { + if let pendingGroupDeletion { + model.deleteGroupChat(jid: pendingGroupDeletion.jid) + } + pendingGroupDeletion = nil + } + Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } + } message: { + Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.") + } } private var chatList: some View { @@ -141,6 +159,15 @@ struct MainChatView: View { ) .tag(conversation.jid) .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if conversation.isGroup { + Button(role: .destructive) { + pendingGroupDeletion = conversation + } label: { + Label("Удалить", systemImage: "trash") + } + } + } } } .listStyle(.plain)