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.
122 lines
4.8 KiB
Swift
122 lines
4.8 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
struct GroupInfoView: View {
|
|
@ObservedObject var model: AppModel
|
|
let conversation: Conversation
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var nickname: String
|
|
@State private var invitees = ""
|
|
@State private var isJoining = false
|
|
@State private var isInviting = false
|
|
|
|
init(model: AppModel, conversation: Conversation) {
|
|
self.model = model
|
|
self.conversation = conversation
|
|
_nickname = State(initialValue: conversation.groupNickname ?? model.suggestedGroupNickname)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Групповой чат") {
|
|
LabeledContent("Комната", value: conversation.jid)
|
|
LabeledContent("Состояние", value: liveConversation.isGroupJoined ? "Подключено" : "Не подключено")
|
|
LabeledContent("Участников", value: "\(liveConversation.occupantCount)")
|
|
TextField("Ваш псевдоним", text: $nickname)
|
|
.disabled(liveConversation.isGroupJoined)
|
|
}
|
|
|
|
if !liveConversation.isGroupJoined {
|
|
Section {
|
|
Button {
|
|
join()
|
|
} label: {
|
|
Label(liveConversation.invitedBy == nil ? "Войти в комнату" : "Принять приглашение", systemImage: "person.2.fill")
|
|
}
|
|
.disabled(nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isJoining)
|
|
} footer: {
|
|
if let inviter = liveConversation.invitedBy {
|
|
Text("Приглашение от \(inviter)")
|
|
}
|
|
}
|
|
} else {
|
|
Section("Пригласить участников") {
|
|
TextField("user@example.org, ещё@example.org", text: $invitees, axis: .vertical)
|
|
.lineLimit(2...4)
|
|
Button {
|
|
invite()
|
|
} label: {
|
|
if isInviting {
|
|
ProgressView()
|
|
} else {
|
|
Label("Отправить приглашения", systemImage: "person.badge.plus")
|
|
}
|
|
}
|
|
.disabled(parsedInvitees.isEmpty || isInviting)
|
|
}
|
|
|
|
Section {
|
|
Button(role: .destructive) {
|
|
model.leaveGroup(jid: conversation.jid)
|
|
dismiss()
|
|
} label: {
|
|
Label("Покинуть комнату", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Label(
|
|
model.encryptionEnabled(for: conversation.jid)
|
|
? "Для комнаты включено OMEMO. Нужны неанонимный MUC и OMEMO-устройства у всех участников."
|
|
: "OMEMO для этой комнаты выключено: сообщения передаются открытым текстом внутри TLS.",
|
|
systemImage: model.encryptionEnabled(for: conversation.jid) ? "lock.fill" : "lock.open"
|
|
)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.navigationTitle(conversation.displayName)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Готово") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 480, minHeight: 500)
|
|
#endif
|
|
}
|
|
|
|
private var liveConversation: Conversation {
|
|
model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation
|
|
}
|
|
|
|
private var parsedInvitees: [String] {
|
|
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
|
|
private func join() {
|
|
isJoining = true
|
|
Task {
|
|
await model.joinGroup(jid: conversation.jid, nickname: nickname)
|
|
isJoining = false
|
|
}
|
|
}
|
|
|
|
private func invite() {
|
|
let outgoing = parsedInvitees
|
|
guard !outgoing.isEmpty else { return }
|
|
isInviting = true
|
|
Task {
|
|
await model.inviteMembers(outgoing, to: conversation.jid)
|
|
invitees = ""
|
|
isInviting = false
|
|
}
|
|
}
|
|
}
|