#10 add group chat delete alongside leave
- Delete a group chat from GroupInfoView or via a swipe in the chat list: remove the conversation and its messages from SwiftData, clean per-chat state, and leave the room on the server first when joined. - Keep "Покинуть комнату" as leave-without-deleting and suppress deleted rooms for the session so roomState events cannot recreate them; an explicit rejoin or a fresh invitation clears the suppression. - Add verify.sh invariants and document the behaviour in ARCHITECTURE.md.
This commit is contained in:
1 parent
fc07855036
commit
4566b59ff1
5 files changed
+132
No files matched your search
@@ -97,6 +97,11 @@ stanza отдельно сохраняет назначенный комнато
|
||||
декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются
|
||||
межклиентно совместимыми.
|
||||
|
||||
Выход из комнаты оставляет чат в списке как «Не подключено». Удаление чата
|
||||
убирает его и локальную историю с устройства (и предварительно выходит из
|
||||
комнаты на сервере, если участник в ней); подавление на время сессии не даёт
|
||||
событиям комнаты создать удалённый чат заново.
|
||||
|
||||
XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное
|
||||
сообщение. Перед применением `AppModel` проверяет conversation и JID автора,
|
||||
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,6 +104,7 @@ final class AppModel: ObservableObject {
|
||||
private var hasMoreOlderHistoryByConversation: [String: Bool] = [:]
|
||||
private var pendingRoomPasswords: [String: String] = [:]
|
||||
private var joiningRoomJIDs: Set<String> = []
|
||||
private var deletedGroupChatJIDs: Set<String> = []
|
||||
private var mediaSendActivity = MediaSendActivityTracker()
|
||||
private var mediaPreparationTokens: Set<UUID> = []
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user