add a telegram-style bottom tab bar to the main screen
- Replace the sidebar-based MainChatView with MainTabView: a custom bottom bar with Контакты / Звонки / Чаты / Настройки that looks the same on iOS and macOS, and open chats via push navigation. - Put the search field above the chat and contact lists (searchable on iOS, an inline field on macOS) and add an aggregated call-history screen backed by model.callHistoryMessages. - Embed SettingsView as a tab, update verify.sh guards, and keep CI on deterministic unit tests while the UI tests stay runnable manually.
This commit is contained in:
1 parent
6ed5cb3ce9
commit
efe3986ba5
11 files changed
+539
-332
No files matched your search
@@ -29,13 +29,16 @@ jobs:
|
|||||||
- name: Resolve Swift Packages
|
- name: Resolve Swift Packages
|
||||||
run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma
|
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
|
- name: Build and Test
|
||||||
run: |
|
run: |
|
||||||
xcodebuild test \
|
xcodebuild test \
|
||||||
-project Luma.xcodeproj \
|
-project Luma.xcodeproj \
|
||||||
-scheme Luma \
|
-scheme Luma \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
|
||||||
|
-only-testing:LumaTests \
|
||||||
-resultBundlePath TestResults.xcresult
|
-resultBundlePath TestResults.xcresult
|
||||||
|
|
||||||
- name: Upload Test Results
|
- 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`,
|
- `Models/` — value types and pure policy enums (`ArchiveSyncPagination`,
|
||||||
`ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
|
`ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
|
||||||
(`ChatMessage`, `Conversation`).
|
(`ChatMessage`, `Conversation`).
|
||||||
- `UI/` — SwiftUI views; chat list, timeline and forward picker read
|
- `UI/` — SwiftUI views; chat/contact lists read SwiftData through
|
||||||
SwiftData through `@Query` (`MainChatView`, `ChatView`,
|
`@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main
|
||||||
`ForwardMessageView`).
|
screen is a Telegram-style bottom tab bar (Контакты/Звонки/Чаты/
|
||||||
|
Настройки).
|
||||||
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
|
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
|
||||||
`SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure
|
`SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure
|
||||||
observer/messages.
|
observer/messages.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; };
|
03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; };
|
||||||
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; };
|
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; };
|
||||||
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.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 */; };
|
087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; };
|
||||||
088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; };
|
088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; };
|
||||||
097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.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 */; };
|
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
|
||||||
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
|
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
|
||||||
5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.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 */; };
|
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; };
|
||||||
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
|
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
|
||||||
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.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 */; };
|
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
|
||||||
7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
|
7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
|
||||||
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.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 */; };
|
7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; };
|
||||||
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
|
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
|
||||||
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.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 */; };
|
CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; };
|
||||||
CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
|
CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
|
||||||
CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.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 */; };
|
D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; };
|
||||||
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; };
|
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; };
|
||||||
D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = "<group>"; };
|
||||||
@@ -647,7 +647,7 @@
|
|||||||
BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */,
|
BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */,
|
||||||
2786A15415F65305F78D892C /* LocationPickerView.swift */,
|
2786A15415F65305F78D892C /* LocationPickerView.swift */,
|
||||||
05EEEE8A125347F7C5014A09 /* LoginView.swift */,
|
05EEEE8A125347F7C5014A09 /* LoginView.swift */,
|
||||||
D3AA66547A2AA2D0979DA76E /* MainChatView.swift */,
|
99F26F5B024C5B4BF74CF242 /* MainTabView.swift */,
|
||||||
43A4CE3E678096BA76F2988C /* NewChatView.swift */,
|
43A4CE3E678096BA76F2988C /* NewChatView.swift */,
|
||||||
99BE34DC082D19007241AA62 /* NewGroupView.swift */,
|
99BE34DC082D19007241AA62 /* NewGroupView.swift */,
|
||||||
515DD6F1F80A848C39EBAE84 /* RootView.swift */,
|
515DD6F1F80A848C39EBAE84 /* RootView.swift */,
|
||||||
@@ -988,7 +988,7 @@
|
|||||||
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */,
|
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */,
|
||||||
0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */,
|
0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */,
|
||||||
E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */,
|
E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */,
|
||||||
08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */,
|
D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */,
|
||||||
0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */,
|
0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */,
|
||||||
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */,
|
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */,
|
||||||
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */,
|
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */,
|
||||||
@@ -1101,7 +1101,7 @@
|
|||||||
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */,
|
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */,
|
||||||
C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */,
|
C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */,
|
||||||
127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */,
|
127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */,
|
||||||
7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */,
|
6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */,
|
||||||
EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */,
|
EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */,
|
||||||
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */,
|
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */,
|
||||||
D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */,
|
D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */,
|
||||||
|
|||||||
@@ -64,6 +64,17 @@
|
|||||||
ReferencedContainer = "container:Luma.xcodeproj">
|
ReferencedContainer = "container:Luma.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A086B6D25E7969CB643F1C8A"
|
||||||
|
BuildableName = "LumaUITests.xctest"
|
||||||
|
BlueprintName = "LumaUITests"
|
||||||
|
ReferencedContainer = "container:Luma.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
<CommandLineArguments>
|
<CommandLineArguments>
|
||||||
</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"
|
echo "The legacy JSON ChatArchive actor must stay removed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
echo "Chat list must be SwiftData @Query-driven"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -378,7 +378,7 @@ grep -q 'Удалить чат' Sources/Shared/UI/GroupInfoView.swift || {
|
|||||||
echo "Group info must offer deleting the chat"
|
echo "Group info must offer deleting the chat"
|
||||||
exit 1
|
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"
|
echo "Chat list must offer deleting group chats"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -410,6 +410,14 @@ grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || {
|
|||||||
echo "Biometric availability must be cached off the view body"
|
echo "Biometric availability must be cached off the view body"
|
||||||
exit 1
|
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 || {
|
grep -q 'case .background:' Sources/App/LumaApp.swift || {
|
||||||
echo "The app lock must trigger on background only, not on inactive"
|
echo "The app lock must trigger on background only, not on inactive"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -770,7 +778,7 @@ grep -q 'failedDrafts' Sources/Shared/UI/AttachmentPreviewView.swift || {
|
|||||||
echo "Failed media drafts must remain available for retry"
|
echo "Failed media drafts must remain available for retry"
|
||||||
exit 1
|
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"
|
echo "Prosody roster contacts screen is missing"
|
||||||
exit 1
|
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] {
|
var selectedMessages: [ChatMessage] {
|
||||||
guard let selectedConversationID else { return [] }
|
guard let selectedConversationID else { return [] }
|
||||||
if selectedMessagesCacheConversationID == selectedConversationID {
|
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 {
|
if model.account == nil {
|
||||||
LoginView(model: model)
|
LoginView(model: model)
|
||||||
} else {
|
} else {
|
||||||
MainChatView(model: model)
|
MainTabView(model: model)
|
||||||
.environment(\.modelContext, model.modelContext)
|
.environment(\.modelContext, model.modelContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var model: AppModel
|
@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
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingSignOutConfirmation = false
|
@State private var showingSignOutConfirmation = false
|
||||||
@State private var forgetHistory = false
|
@State private var forgetHistory = false
|
||||||
@@ -152,8 +155,10 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Настройки")
|
.navigationTitle("Настройки")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
if !presentedAsTab {
|
||||||
Button("Готово") { dismiss() }
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Готово") { dismiss() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
|
|||||||
+1
-2
@@ -213,8 +213,7 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
targets:
|
targets:
|
||||||
- LumaTests
|
- LumaTests
|
||||||
# LumaUITests are opt-in (flaky under parallel CI load): run them with
|
- LumaUITests
|
||||||
# xcodebuild test -only-testing:LumaUITests
|
|
||||||
archive:
|
archive:
|
||||||
config: Release
|
config: Release
|
||||||
LumaMac:
|
LumaMac:
|
||||||
|
|||||||
Reference in new issue
Block a user