Files
Luma/Sources/Shared/UI/MainChatView.swift
T
wt 4566b59ff1
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
#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.
2026-08-29 01:56:23 +07:00

315 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftData
import SwiftUI
struct MainChatView: View {
private enum SidebarMode: String, CaseIterable {
case chats
case contacts
var title: String {
switch self {
case .chats: return "Чаты"
case .contacts: return "Контакты"
}
}
}
@ObservedObject var model: AppModel
@State private var searchText = ""
@State private var sidebarMode: SidebarMode = .chats
@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
/// `AppModel.sortConversations` (`Bool` is not `Comparable`, so pinning
/// cannot be a `SortDescriptor`).
@Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse),
]
)
private var conversations: [Conversation]
private var filteredConversations: [Conversation] {
let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch)
return source.sorted(by: conversationSort)
}
private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
if lhs.isPinned != rhs.isPinned { return lhs.isPinned }
if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity }
return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
== .orderedAscending
}
private var filteredRosterContacts: [Conversation] {
conversations
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var filteredGroupContacts: [Conversation] {
conversations
.filter { $0.isGroup }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var hasStoredContacts: Bool {
!model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup }
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
ConnectionBanner(model: model)
Picker("Раздел", selection: $sidebarMode) {
Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill")
.tag(SidebarMode.chats)
Label("Контакты", systemImage: "person.2.fill")
.tag(SidebarMode.contacts)
}
.pickerStyle(.segmented)
.padding(.horizontal, 12)
.padding(.vertical, 8)
switch sidebarMode {
case .chats:
chatList
case .contacts:
contactsList
}
}
.navigationTitle(sidebarMode.title)
.searchable(
text: $searchText,
prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов"
)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
}
Button { showingNewGroup = true } label: {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Новый групповой чат")
.help("Новый групповой чат")
Button { showingNewChat = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Новый личный чат")
.help("Новый личный чат")
}
}
} detail: {
if let conversation = model.selectedConversation {
ChatView(model: model, conversation: conversation)
.id(conversation.jid)
} else {
WelcomeDetailView()
}
}
.navigationSplitViewStyle(.balanced)
.onChange(of: model.selectedConversationID) { _, id in
guard let id else { return }
model.selectConversation(id: id)
}
.sheet(isPresented: $showingNewChat) {
NewChatView(model: model)
}
.sheet(isPresented: $showingNewGroup) {
NewGroupView(model: model)
}
.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 {
List(selection: $model.selectedConversationID) {
ForEach(filteredConversations) { conversation in
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: conversation.jid)
)
.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)
.overlay {
if filteredConversations.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass",
description: Text(
searchText.isEmpty
? "Создайте личный или групповой чат."
: "Попробуйте другой JID или имя."
)
)
}
}
}
private var contactsList: some View {
List(selection: $model.selectedConversationID) {
if !filteredRosterContacts.isEmpty {
Section {
ForEach(filteredRosterContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.jid)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Люди из roster", systemImage: "person.2.fill")
}
}
if !filteredGroupContacts.isEmpty {
Section {
ForEach(filteredGroupContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.jid)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Групповые чаты", systemImage: "person.3.fill")
}
}
}
.listStyle(.plain)
.overlay {
if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет контактов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass",
description: Text(contactListDescription)
)
}
}
}
private var contactListDescription: String {
if !searchText.isEmpty {
return "Попробуйте другой JID или имя."
}
if !hasStoredContacts, model.connectionStatus != .connected {
return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты."
}
return "Добавьте человека в roster или создайте групповой чат."
}
private func matchesSearch(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
}
}
private struct ContactRow: View {
let conversation: Conversation
let imageData: Data?
var body: some View {
HStack(spacing: 12) {
AvatarView(conversation: conversation, imageData: imageData, size: 42)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
if !conversation.isGroup, conversation.isOnline {
Circle()
.fill(Color.green)
.frame(width: 7, height: 7)
.accessibilityLabel("В сети")
}
}
if conversation.isGroup {
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
Text(groupSubtitle)
}
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(conversation.jid)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.bold())
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
private var groupSubtitle: String {
guard conversation.occupantCount > 0 else { return conversation.jid }
return "\(conversation.occupantCount) участн. • \(conversation.jid)"
}
}
private struct WelcomeDetailView: View {
var body: some View {
ZStack {
Color.secondary.opacity(0.04).ignoresSafeArea()
ContentUnavailableView {
Label("Luma", systemImage: "bubble.left.and.bubble.right.fill")
} description: {
Text("Выберите чат или начните новый защищённый диалог.")
}
}
}
}