Develop #19

Merged
wt merged 107 commits from develop into main 2026-10-13 09:42:23 +00:00
11 changed files with 539 additions and 332 deletions
Showing only changes of commit efe3986ba5 - Show all commits

No files matched your search

+4 -1
View File
@@ -29,13 +29,16 @@ jobs:
- name: Resolve Swift Packages
run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma
# Step 4: Build and run tests using xcodebuild
# Step 4: Build and run tests using xcodebuild. CI runs the
# deterministic unit tests only; the UI tests (LumaUITests) are
# exercised manually on a simulator.
- name: Build and Test
run: |
xcodebuild test \
-project Luma.xcodeproj \
-scheme Luma \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
-only-testing:LumaTests \
-resultBundlePath TestResults.xcresult
- name: Upload Test Results
+4 -3
View File
@@ -11,9 +11,10 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from
- `Models/` — value types and pure policy enums (`ArchiveSyncPagination`,
`ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
(`ChatMessage`, `Conversation`).
- `UI/` — SwiftUI views; chat list, timeline and forward picker read
SwiftData through `@Query` (`MainChatView`, `ChatView`,
`ForwardMessageView`).
- `UI/` — SwiftUI views; chat/contact lists read SwiftData through
`@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main
screen is a Telegram-style bottom tab bar (Контакты/Звонки/Чаты/
Настройки).
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
`SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure
observer/messages.
+6 -6
View File
@@ -16,7 +16,6 @@
03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; };
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; };
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; };
087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; };
088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; };
097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; };
@@ -101,6 +100,7 @@
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; };
6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; };
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; };
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; };
@@ -133,7 +133,6 @@
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; };
7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; };
7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; };
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; };
@@ -203,6 +202,7 @@
CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; };
CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; };
D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; };
D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; };
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; };
D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; };
@@ -383,6 +383,7 @@
96720381802141EBA1DA9772 /* LumaUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = "<group>"; };
99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = "<group>"; };
99F26F5B024C5B4BF74CF242 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
9B11FED2D58621C071C3AD14 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = "<group>"; };
9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicyTests.swift; sourceTree = "<group>"; };
@@ -420,7 +421,6 @@
CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = "<group>"; };
D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNotePreview.swift; sourceTree = "<group>"; };
D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpointTests.swift; sourceTree = "<group>"; };
D3AA66547A2AA2D0979DA76E /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = "<group>"; };
D414B62940A9234C51C3D14E /* MessageReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReaction.swift; sourceTree = "<group>"; };
D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageProcessor.swift; sourceTree = "<group>"; };
D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = "<group>"; };
@@ -647,7 +647,7 @@
BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */,
2786A15415F65305F78D892C /* LocationPickerView.swift */,
05EEEE8A125347F7C5014A09 /* LoginView.swift */,
D3AA66547A2AA2D0979DA76E /* MainChatView.swift */,
99F26F5B024C5B4BF74CF242 /* MainTabView.swift */,
43A4CE3E678096BA76F2988C /* NewChatView.swift */,
99BE34DC082D19007241AA62 /* NewGroupView.swift */,
515DD6F1F80A848C39EBAE84 /* RootView.swift */,
@@ -988,7 +988,7 @@
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */,
0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */,
E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */,
08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */,
D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */,
0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */,
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */,
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */,
@@ -1101,7 +1101,7 @@
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */,
C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */,
127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */,
7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */,
6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */,
EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */,
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */,
D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */,
@@ -64,6 +64,17 @@
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A086B6D25E7969CB643F1C8A"
BuildableName = "LumaUITests.xctest"
BlueprintName = "LumaUITests"
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
+11 -3
View File
@@ -282,7 +282,7 @@ if test -f Sources/Shared/Persistence/ChatArchive.swift; then
echo "The legacy JSON ChatArchive actor must stay removed"
exit 1
fi
grep -q '@Query' Sources/Shared/UI/MainChatView.swift || {
grep -q '@Query' Sources/Shared/UI/MainTabView.swift || {
echo "Chat list must be SwiftData @Query-driven"
exit 1
}
@@ -378,7 +378,7 @@ 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 || {
grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainTabView.swift || {
echo "Chat list must offer deleting group chats"
exit 1
}
@@ -410,6 +410,14 @@ grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || {
echo "Biometric availability must be cached off the view body"
exit 1
}
grep -q 'Звонки' Sources/Shared/UI/MainTabView.swift || {
echo "The main screen must offer the Calls tab"
exit 1
}
grep -q 'Настройки' Sources/Shared/UI/MainTabView.swift || {
echo "The main screen must offer the Settings tab"
exit 1
}
grep -q 'case .background:' Sources/App/LumaApp.swift || {
echo "The app lock must trigger on background only, not on inactive"
exit 1
@@ -770,7 +778,7 @@ grep -q 'failedDrafts' Sources/Shared/UI/AttachmentPreviewView.swift || {
echo "Failed media drafts must remain available for retry"
exit 1
}
grep -q 'rosterContactJIDs' Sources/Shared/UI/MainChatView.swift || {
grep -q 'rosterContactJIDs' Sources/Shared/UI/MainTabView.swift || {
echo "Prosody roster contacts screen is missing"
exit 1
}
+10
View File
@@ -251,6 +251,16 @@ final class AppModel: ObservableObject {
)
}
/// Aggregated call history for the «Звонки» tab, newest first.
var callHistoryMessages: [ChatMessage] {
messages
.filter { $0.callHistory != nil }
.sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.clientID < rhs.clientID
}
}
var selectedMessages: [ChatMessage] {
guard let selectedConversationID else { return [] }
if selectedMessagesCacheConversationID == selectedConversationID {
-314
View File
@@ -1,314 +0,0 @@
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("Выберите чат или начните новый защищённый диалог.")
}
}
}
}
+484
View File
@@ -0,0 +1,484 @@
import Foundation
import SwiftData
import SwiftUI
/// Telegram-style main screen: a custom bottom tab bar (Контакты / Звонки /
/// Чаты / Настройки) shared by iOS and macOS, with a search field above the
/// chat and contact lists.
struct MainTabView: View {
enum Tab: String, CaseIterable, Identifiable {
case contacts
case calls
case chats
case settings
var id: String { rawValue }
var title: String {
switch self {
case .contacts: return "Контакты"
case .calls: return "Звонки"
case .chats: return "Чаты"
case .settings: return "Настройки"
}
}
var icon: String {
switch self {
case .contacts: return "person.2"
case .calls: return "phone"
case .chats: return "bubble.left.and.bubble.right"
case .settings: return "gearshape"
}
}
var selectedIcon: String {
switch self {
case .contacts: return "person.2.fill"
case .calls: return "phone.fill"
case .chats: return "bubble.left.and.bubble.right.fill"
case .settings: return "gearshape.fill"
}
}
}
@ObservedObject var model: AppModel
@State private var selectedTab: Tab = .chats
var body: some View {
VStack(spacing: 0) {
Group {
switch selectedTab {
case .contacts:
ContactsTab(model: model)
case .calls:
CallsTab(model: model)
case .chats:
ChatsTab(model: model)
case .settings:
NavigationStack {
SettingsView(model: model, presentedAsTab: true)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
tabBar
}
.ignoresSafeArea(.keyboard, edges: .bottom)
}
private var tabBar: some View {
HStack(spacing: 0) {
ForEach(Tab.allCases) { tab in
Button {
selectedTab = tab
} label: {
VStack(spacing: 3) {
Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon)
.font(.system(size: 21, weight: .medium))
Text(tab.title)
.font(.system(size: 10, weight: selectedTab == tab ? .semibold : .regular))
}
.foregroundStyle(selectedTab == tab ? Color.accentColor : Color.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.title)
.help(tab.title)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 2)
.background(.bar)
}
}
// MARK: - Chats
private struct ChatsTab: View {
@ObservedObject var model: AppModel
@State private var searchText = ""
@State private var showingNewChat = false
@State private var showingNewGroup = 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]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ConnectionBanner(model: model)
#if os(macOS)
searchField
#endif
chatList
}
.navigationTitle("Чаты")
#if os(iOS)
.searchable(text: $searchText, prompt: "Поиск чатов")
#endif
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingNewGroup = true } label: {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Новый групповой чат")
.help("Новый групповой чат")
Button { showingNewChat = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Новый личный чат")
.help("Новый личный чат")
}
}
.sheet(isPresented: $showingNewChat) {
NewChatView(model: model)
}
.sheet(isPresented: $showingNewGroup) {
NewGroupView(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 filteredConversations: [Conversation] {
let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch)
return source.sorted(by: conversationSort)
}
private var chatList: some View {
List {
ForEach(filteredConversations) { conversation in
NavigationLink {
ChatView(model: model, conversation: conversation)
.id(conversation.jid)
.onAppear { model.selectConversation(id: conversation.jid) }
} label: {
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: 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 или имя."
)
)
}
}
}
#if os(macOS)
private var searchField: some View {
TextField("Поиск чатов", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
#endif
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 func matchesSearch(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
}
// MARK: - Contacts
private struct ContactsTab: View {
@ObservedObject var model: AppModel
@State private var searchText = ""
@Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse),
]
)
private var conversations: [Conversation]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ConnectionBanner(model: model)
#if os(macOS)
TextField("Поиск контактов", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 12)
.padding(.vertical, 8)
#endif
contactsList
}
.navigationTitle("Контакты")
#if os(iOS)
.searchable(text: $searchText, prompt: "Поиск контактов")
#endif
}
}
private var contactsList: some View {
List {
if !filteredRosterContacts.isEmpty {
Section {
ForEach(filteredRosterContacts) { conversation in
contactLink(conversation)
.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
contactLink(conversation)
.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),
)
}
}
}
@ViewBuilder
private func contactLink(_ conversation: Conversation) -> some View {
NavigationLink {
ChatView(model: model, conversation: conversation)
.id(conversation.jid)
.onAppear { model.selectConversation(id: conversation.jid) }
} label: {
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
}
}
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 }
}
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
}
}
// MARK: - Calls
private struct CallsTab: View {
@ObservedObject var model: AppModel
var body: some View {
NavigationStack {
Group {
if model.callHistoryMessages.isEmpty {
ContentUnavailableView(
"Нет звонков",
systemImage: "phone",
description: Text("История звонков появится здесь после первого вызова.")
)
} else {
List(model.callHistoryMessages) { message in
if let conversation = model.conversations.first(where: {
$0.jid == message.conversationID
}) {
NavigationLink {
ChatView(model: model, conversation: conversation)
.id(conversation.jid)
.onAppear { model.selectConversation(id: conversation.jid) }
} label: {
CallHistoryRow(model: model, message: message, conversation: conversation)
}
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
} else {
CallHistoryRow(model: model, message: message, conversation: nil)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
}
.listStyle(.plain)
}
}
.navigationTitle("Звонки")
}
}
}
private struct CallHistoryRow: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let conversation: Conversation?
var body: some View {
HStack(spacing: 12) {
Image(systemName: message.callHistory?.isVideo == true ? "video.fill" : "phone.fill")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(message.direction == .incoming && message.callHistory?.outcome == .missed
? Color.red : Color.accentColor)
.frame(width: 34, height: 34)
.background(Color.secondary.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(conversation?.displayName ?? message.conversationID)
.font(.headline)
.lineLimit(1)
Text(message.callTitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(message.timestamp, format: .dateTime.hour().minute())
.font(.caption)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
}
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)"
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ struct RootView: View {
if model.account == nil {
LoginView(model: model)
} else {
MainChatView(model: model)
MainTabView(model: model)
.environment(\.modelContext, model.modelContext)
}
}
+7 -2
View File
@@ -4,6 +4,9 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var model: AppModel
/// When embedded as the «Настройки» tab there is no sheet to dismiss,
/// so the confirmation toolbar button is hidden.
var presentedAsTab: Bool = false
@Environment(\.dismiss) private var dismiss
@State private var showingSignOutConfirmation = false
@State private var forgetHistory = false
@@ -152,8 +155,10 @@ struct SettingsView: View {
}
.navigationTitle("Настройки")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
if !presentedAsTab {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
}
.confirmationDialog(
+1 -2
View File
@@ -213,8 +213,7 @@ schemes:
config: Debug
targets:
- LumaTests
# LumaUITests are opt-in (flaky under parallel CI load): run them with
# xcodebuild test -only-testing:LumaUITests
- LumaUITests
archive:
config: Release
LumaMac: