Develop #19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("Выберите чат или начните новый защищённый диалог.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user