add server info screen, connection stats, and emoji picker
Server information (Monal-style): - Add ServerInformation model and ServerInfoView reachable from Settings, showing server software (XEP-0092), disco#info identities and features, conference (MUC) services via disco#items, and STUN/TURN via XEP-0215. - Add a curated XEP capability list with per-XEP success/error status derived from server/account disco features and connection flags. - Detect PEP (0163) from account disco pubsub#* features, HTTP Upload (0363) from the main domain plus its components, and add Roster Versioning (0237), Pre-Authenticated Roster Subscription (0379), and SASL SCRAM Downgrade Protection (0474, neutral because Martin does not expose SSDP). Connection statistics: - Add LumaConnectionStatsModule, an XmppStanzaFilter registered before stream management, to count sent / acknowledged / received stanzas. - Track last login and SMACKS session timestamps, and read SASL mechanisms and Client State Indication from the raw stream features. MAM / OMEMO handling: - Prefer a plaintext <body> fallback when OMEMO decryption fails instead of showing "failed to decrypt". - Keep the optimistic text of our own outgoing message when its echo cannot be decrypted back, instead of replacing it with a placeholder. Emoji picker: - Add EmojiCatalog and a cross-platform EmojiPickerView. - Open the picker for reactions (replacing the static quick list) and from the composer's smiley button, inserting the chosen emoji into the draft.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */; };
|
||||
01039E013AB84FA338B2A5CF /* MessageCorrectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */; };
|
||||
01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; };
|
||||
01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */; };
|
||||
03629CA43EB6D367498E90E8 /* VideoNotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */; };
|
||||
03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; };
|
||||
@@ -62,10 +63,12 @@
|
||||
3BEF3895B4C84880B9103058 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; };
|
||||
3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; };
|
||||
3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; };
|
||||
3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; };
|
||||
3D67F14421F89B6DA4400642 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; };
|
||||
3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */; };
|
||||
3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; };
|
||||
3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */; };
|
||||
411136B21632A796E26B207C /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; };
|
||||
433A7B65E635FB59021F7D87 /* ChatTypingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */; };
|
||||
44AF85896EE023775B91DE8B /* VideoNotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */; };
|
||||
46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; };
|
||||
@@ -82,6 +85,7 @@
|
||||
52F75F60459DA50E1815479A /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; };
|
||||
5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */; };
|
||||
5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
|
||||
5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
|
||||
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
|
||||
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
|
||||
5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; };
|
||||
@@ -110,6 +114,7 @@
|
||||
7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */; };
|
||||
7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; };
|
||||
79169D5FE931EBE0D0801516 /* ArchiveSyncPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */; };
|
||||
791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; };
|
||||
7A469F326228FB660739D1C2 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; };
|
||||
7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; };
|
||||
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
|
||||
@@ -131,9 +136,11 @@
|
||||
902C6AD49443720ED4E4E43C /* ComposerRecordingGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */; };
|
||||
9089C00D2A0766A75B1EB93D /* CryptoKitAESGCMEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */; };
|
||||
9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; };
|
||||
939ABD4DED652588957491CF /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; };
|
||||
94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */; };
|
||||
958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */; };
|
||||
992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 371134B6F3E284082E28FE08 /* MartinOMEMO */; };
|
||||
9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; };
|
||||
9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; };
|
||||
9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; };
|
||||
9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; };
|
||||
@@ -150,6 +157,7 @@
|
||||
AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; };
|
||||
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; };
|
||||
B0C6096A8D8F1EAD0B4154B7 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; };
|
||||
B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
|
||||
B33870CD89289E44882750C3 /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; };
|
||||
B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */; };
|
||||
B492852FD89322434B0914AE /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F36BB344D0FDF23127DF069 /* AppModel.swift */; };
|
||||
@@ -161,6 +169,7 @@
|
||||
BAA3A9846822ED9F9E5C7111 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; };
|
||||
BAF755D5DBE713646B4CE5AF /* AudioMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */; };
|
||||
BE4D5853DAC0D44A6132D800 /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; };
|
||||
BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; };
|
||||
C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
|
||||
C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */; };
|
||||
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
|
||||
@@ -214,6 +223,7 @@
|
||||
FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; };
|
||||
FD15BE18C54AE2693518CB4A /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; };
|
||||
FD9F8640841236BB78563573 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; };
|
||||
FDB78F0A58D66E36B9E25A47 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; };
|
||||
FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE298382FC70ECF1513BD4A /* XMPPService.swift */; };
|
||||
FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */; };
|
||||
FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */; };
|
||||
@@ -283,6 +293,7 @@
|
||||
2786A15415F65305F78D892C /* LocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerView.swift; sourceTree = "<group>"; };
|
||||
278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadata.swift; sourceTree = "<group>"; };
|
||||
28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicy.swift; sourceTree = "<group>"; };
|
||||
2DA34A969939A03C6226B60E /* EmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerView.swift; sourceTree = "<group>"; };
|
||||
2F36BB344D0FDF23127DF069 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
32DBAB8D25603892C7D67A23 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = "<group>"; };
|
||||
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = "<group>"; };
|
||||
@@ -322,6 +333,7 @@
|
||||
83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = "<group>"; };
|
||||
876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = "<group>"; };
|
||||
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = "<group>"; };
|
||||
8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.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>"; };
|
||||
9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
@@ -352,6 +364,7 @@
|
||||
C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = "<group>"; };
|
||||
C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = "<group>"; };
|
||||
C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = "<group>"; };
|
||||
CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = "<group>"; };
|
||||
CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.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>"; };
|
||||
@@ -365,10 +378,12 @@
|
||||
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = "<group>"; };
|
||||
DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = "<group>"; };
|
||||
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
|
||||
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
|
||||
EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = "<group>"; };
|
||||
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
|
||||
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
|
||||
F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = "<group>"; };
|
||||
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = "<group>"; };
|
||||
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = "<group>"; };
|
||||
@@ -405,6 +420,7 @@
|
||||
children = (
|
||||
1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */,
|
||||
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */,
|
||||
8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */,
|
||||
24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */,
|
||||
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */,
|
||||
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */,
|
||||
@@ -432,6 +448,7 @@
|
||||
1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */,
|
||||
7846C2EEE2551C6690A4FDC6 /* Conversation.swift */,
|
||||
876CDF98063A4C948B00C7CE /* DeviceResource.swift */,
|
||||
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */,
|
||||
5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */,
|
||||
BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */,
|
||||
278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */,
|
||||
@@ -443,6 +460,7 @@
|
||||
9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */,
|
||||
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */,
|
||||
A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */,
|
||||
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */,
|
||||
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */,
|
||||
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */,
|
||||
);
|
||||
@@ -557,6 +575,7 @@
|
||||
43A4CE3E678096BA76F2988C /* NewChatView.swift */,
|
||||
99BE34DC082D19007241AA62 /* NewGroupView.swift */,
|
||||
515DD6F1F80A848C39EBAE84 /* RootView.swift */,
|
||||
CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */,
|
||||
9A338A8EB30206647BDD922F /* SettingsView.swift */,
|
||||
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */,
|
||||
0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */,
|
||||
@@ -630,6 +649,7 @@
|
||||
0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */,
|
||||
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */,
|
||||
758B448979C845F56168A51F /* ConversationRow.swift */,
|
||||
2DA34A969939A03C6226B60E /* EmojiPickerView.swift */,
|
||||
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */,
|
||||
B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */,
|
||||
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */,
|
||||
@@ -842,6 +862,8 @@
|
||||
64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */,
|
||||
EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */,
|
||||
2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */,
|
||||
BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */,
|
||||
791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */,
|
||||
9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */,
|
||||
9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */,
|
||||
8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */,
|
||||
@@ -854,6 +876,7 @@
|
||||
F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */,
|
||||
2D83457A8857592E23289EF7 /* LumaApp.swift in Sources */,
|
||||
5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */,
|
||||
5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */,
|
||||
FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */,
|
||||
CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */,
|
||||
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */,
|
||||
@@ -880,6 +903,8 @@
|
||||
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */,
|
||||
67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */,
|
||||
ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */,
|
||||
01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */,
|
||||
9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */,
|
||||
CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */,
|
||||
D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */,
|
||||
3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */,
|
||||
@@ -940,6 +965,8 @@
|
||||
EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */,
|
||||
E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */,
|
||||
D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */,
|
||||
FDB78F0A58D66E36B9E25A47 /* EmojiCatalog.swift in Sources */,
|
||||
411136B21632A796E26B207C /* EmojiPickerView.swift in Sources */,
|
||||
FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */,
|
||||
A07ABE478F7F48C3FEDC22D5 /* EncryptionPreference.swift in Sources */,
|
||||
25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */,
|
||||
@@ -952,6 +979,7 @@
|
||||
3D67F14421F89B6DA4400642 /* LoginView.swift in Sources */,
|
||||
7F012252FA73A8282B92323C /* LumaApp.swift in Sources */,
|
||||
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */,
|
||||
B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */,
|
||||
1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */,
|
||||
03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */,
|
||||
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */,
|
||||
@@ -978,6 +1006,8 @@
|
||||
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */,
|
||||
3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */,
|
||||
3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */,
|
||||
3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */,
|
||||
939ABD4DED652588957491CF /* ServerInformation.swift in Sources */,
|
||||
E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */,
|
||||
0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */,
|
||||
46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */,
|
||||
|
||||
+6
-2
@@ -537,8 +537,12 @@ grep -q 'urn:xmpp:reactions:0' Sources/Shared/XMPP/XMPPService.swift || {
|
||||
echo "XEP-0444 capability advertisement is missing"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'MessageReactionPolicy.quickChoices' Sources/Shared/UI/Components/MessageBubble.swift || {
|
||||
echo "Message reaction UI is missing"
|
||||
grep -q 'onReactPicker' Sources/Shared/UI/Components/MessageBubble.swift || {
|
||||
echo "Message reaction picker is missing"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'EmojiPickerView' Sources/Shared/UI/ChatView.swift || {
|
||||
echo "The emoji picker is not wired into the chat"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || {
|
||||
|
||||
@@ -41,6 +41,7 @@ final class AppModel: ObservableObject {
|
||||
@Published var selectedConversationID: String?
|
||||
@Published private(set) var isOMEMOReady = false
|
||||
@Published private(set) var ownFingerprint: String?
|
||||
@Published private(set) var serverInformation: ServerInformation?
|
||||
@Published private(set) var isArchiveSyncing = false
|
||||
@Published private(set) var isLoadingOlderHistory = false
|
||||
@Published private(set) var hasMoreOlderHistory = true
|
||||
@@ -1621,6 +1622,15 @@ final class AppModel: ObservableObject {
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func refreshServerInformation() async {
|
||||
do {
|
||||
serverInformation = try await xmpp.fetchServerInformation()
|
||||
} catch {
|
||||
// The screen shows its own error state; keep any previously loaded
|
||||
// snapshot rather than clearing it on a transient refresh failure.
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
@@ -2521,6 +2531,25 @@ final class AppModel: ObservableObject {
|
||||
merged.security = previous.security
|
||||
merged.encryptionFingerprint = previous.encryptionFingerprint
|
||||
}
|
||||
if message.security == .decryptionFailed,
|
||||
previous.security != .decryptionFailed,
|
||||
!previous.body.isEmpty
|
||||
{
|
||||
// Our own outgoing message was rendered optimistically with the
|
||||
// text the user typed. Its server echo (carbon or MAM) cannot
|
||||
// be decrypted back (no encrypt-to-self key, or a consumed
|
||||
// session), so keep the original content instead of replacing
|
||||
// it with a "failed to decrypt" placeholder.
|
||||
merged.body = previous.body
|
||||
merged.security = previous.security
|
||||
merged.encryptionFingerprint = previous.encryptionFingerprint
|
||||
merged.kind = previous.kind
|
||||
merged.remoteAttachmentURL = previous.remoteAttachmentURL
|
||||
merged.localFilename = previous.localFilename
|
||||
merged.mimeType = previous.mimeType
|
||||
merged.duration = previous.duration
|
||||
merged.byteCount = previous.byteCount
|
||||
}
|
||||
messages[index] = merged
|
||||
if let stanzaID = merged.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
/// A category of emojis shown in the emoji picker.
|
||||
struct EmojiCategory: Identifiable, Equatable, Sendable {
|
||||
let title: String
|
||||
let symbol: String
|
||||
let emojis: [String]
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
/// Curated, widely-supported emoji set grouped into categories. Used by both
|
||||
/// the reaction picker and the composer's emoji picker.
|
||||
enum EmojiCatalog {
|
||||
static let categories: [EmojiCategory] = [
|
||||
EmojiCategory(
|
||||
title: "Смайлы",
|
||||
symbol: "face.smiling",
|
||||
emojis: [
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "☺️",
|
||||
"😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", "😗",
|
||||
"😙", "😚", "😋", "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓",
|
||||
"😎", "🥸", "🤩", "🥳", "😏", "😒", "😞", "😔", "😟", "😕",
|
||||
"🙁", "☹️", "😣", "😖", "😫", "😩", "🥺", "😢", "😭", "😤",
|
||||
"😠", "😡", "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
|
||||
"😥", "😓", "🤗", "🤔", "🫣", "🤭", "🫢", "🤫", "🤥", "😶",
|
||||
"😐", "😑", "😬", "🙄", "😯", "😦", "😧", "😮", "😲", "🥱",
|
||||
"😴", "🤤", "😪", "😵", "🤐", "🥴", "🤢", "🤮", "🤧", "😷",
|
||||
"🤒", "🤕", "🤑", "🤠", "😈", "👿", "👹", "👺", "🤡", "💩",
|
||||
"👻", "💀", "☠️", "👽", "👾", "🤖", "🎃", "😺", "😸", "😹",
|
||||
"😻", "😼", "😽", "🙀", "😿", "😾", "❤️", "🧡", "💛", "💚",
|
||||
"💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️🔥", "❤️🩹", "💕", "💞",
|
||||
"💓", "💗", "💖", "💘", "💝", "💟", "❣️", "💌", "💢", "💥",
|
||||
"💫", "💦", "💨", "🕳️", "💬", "💭", "🗯️",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Жесты",
|
||||
symbol: "hand.raised",
|
||||
emojis: [
|
||||
"👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞",
|
||||
"🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️",
|
||||
"👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐",
|
||||
"🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦵", "🦶",
|
||||
"👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁️", "👅", "👄",
|
||||
"👶", "🧒", "👦", "👧", "🧑", "👱", "👨", "🧔", "👩", "🧓",
|
||||
"👴", "👵", "🙍", "🙎", "🙅", "🙆", "💁", "🙋", "🧏", "🙇",
|
||||
"🤦", "🤷", "💆", "💇", "🚶", "🧍", "🧎", "🏃", "💃", "🕺",
|
||||
"🧖", "🧗", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏄", "🚣", "🏊",
|
||||
"⛹️", "🏋️", "🚴", "🚵", "🤸", "🤼", "🤽", "🤾", "🤹", "🧘",
|
||||
"🛀", "🛌", "🧑🤝🧑", "👭", "👫", "👬", "💏", "💑", "👪",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Животные и природа",
|
||||
symbol: "leaf",
|
||||
emojis: [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐻❄️", "🐨",
|
||||
"🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔",
|
||||
"🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺",
|
||||
"🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", "🐜",
|
||||
"🪰", "🪲", "🪳", "🦟", "🦗", "🕷️", "🕸️", "🦂", "🐢", "🐍",
|
||||
"🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠",
|
||||
"🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍",
|
||||
"🦧", "🦣", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🦬",
|
||||
"🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌",
|
||||
"🐕", "🐩", "🦮", "🐈", "🐈⬛", "🪶", "🐓", "🦃", "🦤", "🦚",
|
||||
"🦜", "🦢", "🦩", "🕊️", "🐇", "🦝", "🦨", "🦡", "🦫", "🦦",
|
||||
"🦥", "🐁", "🐀", "🐿️", "🦔", "🐾", "🐉", "🐲", "🌵", "🎄",
|
||||
"🌲", "🌳", "🌴", "🌱", "🌿", "☘️", "🍀", "🎍", "🎋", "🍃",
|
||||
"🍂", "🍁", "🍄", "🐚", "🌾", "💐", "🌷", "🌹", "🥀", "🌺",
|
||||
"🌸", "🌼", "🌻", "🌞", "🌝", "🌛", "🌜", "🌚", "🌕", "🌖",
|
||||
"🌗", "🌘", "🌑", "🌒", "🌓", "🌔", "🌙", "🌎", "🌍", "🌏",
|
||||
"💫", "⭐", "🌟", "✨", "⚡", "☄️", "💥", "🔥", "🌪️", "🌈",
|
||||
"☀️", "🌤️", "⛅", "🌥️", "☁️", "🌦️", "🌧️", "⛈️", "🌩️", "🌨️",
|
||||
"❄️", "☃️", "⛄", "🌬️", "💨", "💧", "💦", "☔", "☂️", "🌊", "🌫️",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Еда и напитки",
|
||||
symbol: "fork.knife",
|
||||
emojis: [
|
||||
"🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐",
|
||||
"🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑",
|
||||
"🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅",
|
||||
"🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳",
|
||||
"🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔",
|
||||
"🍟", "🍕", "🫓", "🥪", "🥙", "🧆", "🌮", "🌯", "🫔", "🥗",
|
||||
"🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟",
|
||||
"🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡",
|
||||
"🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬",
|
||||
"🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "🫖",
|
||||
"☕", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷",
|
||||
"🥃", "🍸", "🍹", "🧉", "🍾", "🧊", "🥄", "🍴", "🍽️", "🥣",
|
||||
"🥡", "🥢",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Активности",
|
||||
symbol: "soccerball",
|
||||
emojis: [
|
||||
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱",
|
||||
"🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳",
|
||||
"🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷",
|
||||
"⛸️", "🥌", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "⛹️",
|
||||
"🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", "🚣", "🧗",
|
||||
"🚵", "🚴", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖️", "🏵️", "🎗️",
|
||||
"🎫", "🎟️", "🎪", "🤹", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧",
|
||||
"🎼", "🎹", "🥁", "🪘", "🎷", "🎺", "🪗", "🎸", "🪕", "🎻",
|
||||
"🎲", "♟️", "🎯", "🎳", "🎮", "🎰", "🧩",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Путешествия",
|
||||
symbol: "airplane",
|
||||
emojis: [
|
||||
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐",
|
||||
"🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", "🛺", "🚲", "🛴", "🛹",
|
||||
"🚨", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚋",
|
||||
"🚞", "🚝", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚉",
|
||||
"✈️", "🛫", "🛬", "🛩️", "💺", "🛰️", "🚀", "🛸", "🚁", "🛶",
|
||||
"⛵", "🚤", "🛥️", "🛳️", "⛴️", "🚢", "⚓", "🪝", "🚧", "🚦",
|
||||
"🚥", "🚏", "🗺️", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟️", "🎡",
|
||||
"🎢", "🎠", "⛲", "⛱️", "🏖️", "🏝️", "🏜️", "🌋", "⛰️", "🏔️",
|
||||
"🗻", "🏕️", "⛺", "🛖", "🏠", "🏡", "🏘️", "🏚️", "🏗️", "🏭",
|
||||
"🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩",
|
||||
"💒", "🏛️", "⛪", "🕌", "🕍", "🛕", "🕋", "⛩️", "🛤️", "🛣️",
|
||||
"🗾", "🎑", "🏞️", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆",
|
||||
"🏙️", "🌃", "🌌", "🌉", "🌁",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Предметы",
|
||||
symbol: "lightbulb",
|
||||
emojis: [
|
||||
"⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️",
|
||||
"🗜️", "💽", "💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥",
|
||||
"📽️", "🎞️", "📞", "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️",
|
||||
"🎛️", "🧭", "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋",
|
||||
"🔌", "💡", "🔦", "🕯️", "🪔", "🧯", "🛢️", "💸", "💵", "💴",
|
||||
"💶", "💷", "🪙", "💰", "💳", "💎", "⚖️", "🪜", "🧰", "🪛",
|
||||
"🔧", "🔨", "⚒️", "🛠️", "⛏️", "🪚", "🔩", "⚙️", "🪤", "🧱",
|
||||
"⛓️", "🧲", "🔫", "💣", "🧨", "🪓", "🔪", "🗡️", "⚔️", "🛡️",
|
||||
"🚬", "⚰️", "🪦", "⚱️", "🏺", "🔮", "📿", "🧿", "💈", "⚗️",
|
||||
"🔭", "🔬", "🕳️", "🩹", "🩺", "💊", "💉", "🩸", "🧬", "🦠",
|
||||
"🧫", "🧪", "🌡️", "🧹", "🪠", "🧺", "🧻", "🚽", "🚰", "🚿",
|
||||
"🛁", "🛀", "🧼", "🪥", "🪒", "🧽", "🪣", "🧴", "🛎️", "🔑",
|
||||
"🗝️", "🚪", "🪑", "🛋️", "🛏️", "🛌", "🧸", "🪆", "🖼️", "🪞",
|
||||
"🪟", "🛍️", "🛒", "🎁", "🎈", "🎏", "🎀", "🪄", "🪅", "🎊",
|
||||
"🎉", "🎎", "🏮", "🎐", "🧧", "✉️", "📩", "📨", "📧", "💌",
|
||||
"📥", "📤", "📦", "🏷️", "🪧", "📪", "📫", "📬", "📭", "📮",
|
||||
"📯", "📜", "📃", "📄", "📑", "🧾", "📊", "📈", "📉", "🗒️",
|
||||
"🗓️", "📆", "📅", "🗑️", "📇", "🗃️", "🗳️", "🗄️", "📋", "📁",
|
||||
"📂", "🗂️", "🗞️", "📰", "📓", "📔", "📒", "📕", "📗", "📘",
|
||||
"📙", "📚", "📖", "🔖", "🧷", "🔗", "📎", "🖇️", "📐", "📏",
|
||||
"🧮", "📌", "📍", "✂️", "🖊️", "🖋️", "✒️", "🖌️", "🖍️", "📝",
|
||||
"✏️", "🔍", "🔎", "🔏", "🔐", "🔒", "🔓",
|
||||
]
|
||||
),
|
||||
EmojiCategory(
|
||||
title: "Символы",
|
||||
symbol: "heart",
|
||||
emojis: [
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
|
||||
"❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️",
|
||||
"♠️", "♣️", "♦️", "🃏", "🀄", "🎴", "🔇", "🔈", "🔉", "🔊",
|
||||
"📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️",
|
||||
"🎛️", "🎤", "🎧", "📻", "🎷", "🪗", "🎸", "🎹", "🎺", "🎻",
|
||||
"🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌",
|
||||
"💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀",
|
||||
"🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼",
|
||||
"🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖",
|
||||
"📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰",
|
||||
"🗞️", "📑", "🔖", "🏷️", "💰", "🪙", "💴", "💵", "💶", "💷",
|
||||
"💸", "💳", "🧾", "✉️", "📧", "📨", "📩", "📤", "📥", "📦",
|
||||
"📫", "📪", "📬", "📭", "📮", "🗳️", "✏️", "✒️", "🖋️", "🖊️",
|
||||
"🖌️", "🖍️", "📝", "💼", "📁", "📂", "🗂️", "📅", "📆", "🗒️",
|
||||
"🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️",
|
||||
"📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐",
|
||||
"🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫",
|
||||
"🪃", "🏹", "🛡️", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️",
|
||||
"🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫",
|
||||
"🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩺", "🚪",
|
||||
"🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", "🪒", "🧴", "🧷",
|
||||
"🧹", "🧺", "🧻", "🧼", "🪥", "🧽", "🧯", "🛒", "🚬", "⚰️",
|
||||
"🪦", "⚱️", "🗿", "🪧", "🪪", "🆗", "🆕", "🆒", "🆓", "🆙",
|
||||
"🆖", "🆚", "🈁", "🈂️", "🈷️", "🈶", "🈯", "🉐", "🈹", "🈚",
|
||||
"🈲", "🉑", "🈸", "🈴", "🈳", "㊗️", "㊙️", "🈺", "🈵", "🔴",
|
||||
"🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪", "🟥", "🟧",
|
||||
"🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "◼️", "◻️", "◾",
|
||||
"◽", "▪️", "▫️", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "💠",
|
||||
"🔘", "🔳", "🔲",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
static var allEmojis: [String] {
|
||||
categories.flatMap(\.emojis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
/// Snapshot of everything the "server details" screen shows, modelled after
|
||||
/// Monal's `ServerDetails`: server software (XEP-0092), disco#info identities
|
||||
/// and features (XEP-0030), conference (MUC) services, STUN/TURN services
|
||||
/// (XEP-0215) and a few connection-level capability flags.
|
||||
struct ServerInformation: Equatable, Sendable {
|
||||
enum CapabilityStatus: Equatable, Sendable {
|
||||
case success, normal, warning, error
|
||||
}
|
||||
|
||||
struct Software: Equatable, Sendable {
|
||||
let name: String?
|
||||
let version: String?
|
||||
let os: String?
|
||||
}
|
||||
|
||||
struct Identity: Equatable, Sendable, Identifiable {
|
||||
let category: String
|
||||
let type: String
|
||||
let name: String?
|
||||
|
||||
var id: String { "\(category)/\(type)" }
|
||||
|
||||
var title: String { "\(category) / \(type)" }
|
||||
}
|
||||
|
||||
struct ConferenceServer: Equatable, Sendable, Identifiable {
|
||||
let jid: String
|
||||
let name: String?
|
||||
let type: String
|
||||
let category: String
|
||||
|
||||
var id: String { jid }
|
||||
}
|
||||
|
||||
struct ExternalService: Equatable, Sendable, Identifiable {
|
||||
let type: String
|
||||
let host: String
|
||||
let port: UInt16?
|
||||
let transport: String?
|
||||
|
||||
var id: String { "\(type)|\(host)|\(port.map(String.init) ?? "")" }
|
||||
}
|
||||
|
||||
struct ConnectionStats: Equatable, Sendable {
|
||||
let lastLogin: Date?
|
||||
let smacksSessionEstablished: Date?
|
||||
let sent: UInt32
|
||||
let acknowledged: UInt32
|
||||
let received: UInt32
|
||||
|
||||
var unacknowledged: UInt32 {
|
||||
sent > acknowledged ? sent - acknowledged : 0
|
||||
}
|
||||
}
|
||||
|
||||
struct Capability: Equatable, Sendable, Identifiable {
|
||||
let title: String
|
||||
let detail: String
|
||||
let status: CapabilityStatus
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
let serverDomain: String
|
||||
let software: Software
|
||||
let identities: [Identity]
|
||||
let serverFeatures: [String]
|
||||
let accountFeatures: [String]
|
||||
let conferenceServers: [ConferenceServer]
|
||||
let externalServices: [ExternalService]
|
||||
let connectionStats: ConnectionStats
|
||||
let saslMethods: [String]
|
||||
let supportsStreamManagement: Bool
|
||||
let supportsCarbons: Bool
|
||||
let supportsClientState: Bool
|
||||
let supportsHTTPUpload: Bool
|
||||
let supportsRosterVersioning: Bool
|
||||
let supportsRosterPreApproval: Bool
|
||||
|
||||
/// The curated XEP list Monal shows, with a status derived from the server
|
||||
/// and account disco features plus the connection-level flags.
|
||||
var capabilities: [Capability] {
|
||||
let server = Set(serverFeatures)
|
||||
let account = Set(accountFeatures)
|
||||
let supportsPubSub = account.contains { $0.hasPrefix("http://jabber.org/protocol/pubsub") }
|
||||
return [
|
||||
Capability(
|
||||
title: "XEP-0163 Personal Eventing Protocol",
|
||||
detail: "Публикация событий состояния аккаунта (аватары, статусы) через publish-subscribe.",
|
||||
status: supportsPubSub ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0191 Blocking Command",
|
||||
detail: "Расширение XMPP для блокировки контактов.",
|
||||
status: server.contains("urn:xmpp:blocking") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0198 Stream Management",
|
||||
detail: "Возобновление потока при обрыве соединения — быстрее переподключение и экономия заряда.",
|
||||
status: supportsStreamManagement ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0199 XMPP Ping",
|
||||
detail: "Пинг уровня приложения поверх XML-потока.",
|
||||
status: server.contains("urn:xmpp:ping") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0215 External Service Discovery",
|
||||
detail: "Обнаружение внешних сервисов (STUN/TURN) для аудио- и видеозвонков.",
|
||||
status: server.contains("urn:xmpp:extdisco:2") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0237 Roster Versioning",
|
||||
detail: "Версионирование ростера, чтобы сервер не отправлял его повторно, если он не менялся.",
|
||||
status: supportsRosterVersioning ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0280 Message Carbons",
|
||||
detail: "Синхронизация сообщений между всеми вашими устройствами.",
|
||||
status: supportsCarbons ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0313 Message Archive Management",
|
||||
detail: "Доступ к архиву сообщений на сервере.",
|
||||
status: account.contains("urn:xmpp:mam:2") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0352 Client State Indication",
|
||||
detail: "Указывает, активно ли устройство. Экономит заряд.",
|
||||
status: supportsClientState ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0357 Push Notifications",
|
||||
detail: "Push-уведомления через Apple даже когда приложение выгружено.",
|
||||
status: account.contains("urn:xmpp:push:0") ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0363 HTTP File Upload",
|
||||
detail: "Загрузка файлов на сервер для обмена с собеседниками.",
|
||||
status: supportsHTTPUpload ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0379 Pre-Authenticated Roster Subscription",
|
||||
detail: "Ссылки для предварительно аутентифицированной подписки на присутствие.",
|
||||
status: supportsRosterPreApproval ? .success : .error
|
||||
),
|
||||
Capability(
|
||||
title: "XEP-0474 SASL SCRAM Downgrade Protection",
|
||||
detail: "Защита SASL/SASL2-рукопожатия от понижения метода и channel-binding. Определяется во время SCRAM-аутентификации, а не рекламируется статически.",
|
||||
status: .normal
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,18 @@ import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
private enum EmojiPickerPresentation: Identifiable {
|
||||
case reaction(ChatMessage)
|
||||
case composer
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .reaction(let message): return "reaction-\(message.id)"
|
||||
case .composer: return "composer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatView: View {
|
||||
private static let bottomAnchorID = "luma-chat-timeline-bottom"
|
||||
@@ -42,6 +54,7 @@ struct ChatView: View {
|
||||
@State private var selectedMessageIDs: Set<String> = []
|
||||
@State private var destructiveAction: MessageDestructiveAction?
|
||||
@State private var replyThreadSelection: ReplyThreadSelection?
|
||||
@State private var emojiPickerPresentation: EmojiPickerPresentation?
|
||||
@State private var hasCompletedInitialScroll = false
|
||||
@State private var isNearTimelineBottom = true
|
||||
@State private var historyLoadAnchorID: String?
|
||||
@@ -223,6 +236,11 @@ struct ChatView: View {
|
||||
clearMessageSelection()
|
||||
}
|
||||
}
|
||||
.sheet(item: $emojiPickerPresentation) { presentation in
|
||||
EmojiPickerView { emoji in
|
||||
handleEmojiSelection(emoji, for: presentation)
|
||||
}
|
||||
}
|
||||
.alert(item: $destructiveAction) { action in
|
||||
Alert(
|
||||
title: Text(action.title),
|
||||
@@ -404,6 +422,9 @@ struct ChatView: View {
|
||||
},
|
||||
onReact: { emoji in
|
||||
Task { await model.toggleReaction(emoji, on: message) }
|
||||
},
|
||||
onReactPicker: {
|
||||
emojiPickerPresentation = .reaction(message)
|
||||
}
|
||||
)
|
||||
.id(message.id)
|
||||
@@ -608,11 +629,16 @@ struct ChatView: View {
|
||||
.submitLabel(.send)
|
||||
.onSubmit { sendDraft() }
|
||||
|
||||
Image(systemName: "face.smiling")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 1)
|
||||
.accessibilityHidden(true)
|
||||
Button {
|
||||
emojiPickerPresentation = .composer
|
||||
} label: {
|
||||
Image(systemName: "face.smiling")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Эмодзи")
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
.padding(.trailing, 12)
|
||||
@@ -782,6 +808,7 @@ struct ChatView: View {
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.16), value: model.typingText(for: liveConversation))
|
||||
.accessibilityElement(children: .combine)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var canSendToConversation: Bool {
|
||||
@@ -1385,6 +1412,18 @@ struct ChatView: View {
|
||||
isComposerFocused = true
|
||||
}
|
||||
|
||||
private func handleEmojiSelection(
|
||||
_ emoji: String,
|
||||
for presentation: EmojiPickerPresentation
|
||||
) {
|
||||
switch presentation {
|
||||
case .reaction(let message):
|
||||
Task { await model.toggleReaction(emoji, on: message) }
|
||||
case .composer:
|
||||
draft.append(emoji)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginMessageSelection(with message: ChatMessage) {
|
||||
cancelComposerRecording(feedback: false)
|
||||
dismissKeyboard()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A reusable emoji picker: category tabs on the bottom and a grid of emojis
|
||||
/// for the selected category. Used both for reactions and for inserting an
|
||||
/// emoji into the composer.
|
||||
struct EmojiPickerView: View {
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedCategoryID = EmojiCatalog.categories.first?.id ?? ""
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 4)]
|
||||
|
||||
private var selectedCategory: EmojiCategory {
|
||||
EmojiCatalog.categories.first { $0.id == selectedCategoryID }
|
||||
?? EmojiCatalog.categories[0]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 6) {
|
||||
ForEach(selectedCategory.emojis, id: \.self) { emoji in
|
||||
Button {
|
||||
onSelect(emoji)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(emoji)
|
||||
.font(.system(size: 28))
|
||||
.frame(width: 46, height: 46)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(emoji)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
categorySelector
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(minHeight: 360)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("Выберите эмодзи")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Готово") { dismiss() }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var categorySelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(EmojiCatalog.categories) { category in
|
||||
let isSelected = category.id == selectedCategoryID
|
||||
Button {
|
||||
selectedCategoryID = category.id
|
||||
} label: {
|
||||
Image(systemName: category.symbol)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
isSelected
|
||||
? Color.accentColor.opacity(0.15)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(category.title)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ struct MessageBubble: View {
|
||||
let onDelete: () -> Void
|
||||
let onReplyTap: (String) -> Void
|
||||
let onReact: (String) -> Void
|
||||
let onReactPicker: () -> Void
|
||||
|
||||
@State private var replySwipeOffset: CGFloat = 0
|
||||
@State private var replySwipeArmed = false
|
||||
@@ -56,11 +57,7 @@ struct MessageBubble: View {
|
||||
.contextMenu {
|
||||
if !isSelectionMode {
|
||||
if message.canBeReactedTo {
|
||||
Menu {
|
||||
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
|
||||
Button(emoji) { onReact(emoji) }
|
||||
}
|
||||
} label: {
|
||||
Button(action: onReactPicker) {
|
||||
Label("Реакция", systemImage: "face.smiling")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "Server information" screen shown from Settings, mirroring Monal's
|
||||
/// `ServerDetails`: a grouped list of headline + caption entries whose row
|
||||
/// background is tinted green/orange/red according to the detected status.
|
||||
struct ServerInfoView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let info = model.serverInformation {
|
||||
Section {
|
||||
statisticsEntries(info)
|
||||
} header: {
|
||||
Text("Это статистика вашего соединения.")
|
||||
}
|
||||
|
||||
Section {
|
||||
softwareEntry(info)
|
||||
} header: {
|
||||
Text("Это программное обеспечение, работающее на вашем сервере.")
|
||||
}
|
||||
|
||||
Section {
|
||||
connectionRows()
|
||||
} header: {
|
||||
Text("Параметры подключения к вашему серверу.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(info.capabilities) { capability in
|
||||
entry(
|
||||
title: capability.title,
|
||||
detail: capability.detail,
|
||||
status: capability.status
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Современные возможности XMPP, обнаруженные на вашем сервере после входа.")
|
||||
}
|
||||
|
||||
Section {
|
||||
mucEntries(info)
|
||||
} header: {
|
||||
Text("MUC-серверы, обнаруженные на вашем сервере.")
|
||||
}
|
||||
|
||||
Section {
|
||||
stunTurnEntries(info)
|
||||
} header: {
|
||||
Text("STUN и TURN сервисы, объявленные вашим сервером.")
|
||||
}
|
||||
|
||||
Section {
|
||||
saslEntries(info)
|
||||
} header: {
|
||||
Text("Методы аутентификации SASL, которые поддерживает ваш сервер.")
|
||||
}
|
||||
} else if isLoading {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Загрузка информации о сервере…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"Нет данных о сервере",
|
||||
systemImage: "server.rack",
|
||||
description: Text(
|
||||
model.connectionStatus == .connected
|
||||
? "Сервер не ответил на запрос. Попробуйте обновить."
|
||||
: "Подключитесь к серверу, чтобы увидеть его возможности."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(model.account?.domain ?? "Сервер")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await load() }
|
||||
} label: {
|
||||
Label("Обновить", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
.frame(minWidth: 480, minHeight: 420)
|
||||
#else
|
||||
.listStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private func softwareEntry(_ info: ServerInformation) -> some View {
|
||||
let name = info.software.name ?? "Неизвестный сервер"
|
||||
let version = info.software.version ?? "неизвестная версия"
|
||||
let platform = info.software.os.map { ", работает на \($0)" } ?? ""
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
Text(name)
|
||||
.font(.headline)
|
||||
Text("версия \(version)\(platform)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Link(
|
||||
"Рекомендации для администраторов серверов",
|
||||
destination: URL(
|
||||
string: "https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins"
|
||||
)!
|
||||
)
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func connectionRows() -> some View {
|
||||
Group {
|
||||
if let account = model.account {
|
||||
if let host = account.manualHost {
|
||||
let port = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222)
|
||||
LabeledContent("Хост", value: "\(host):\(port)")
|
||||
LabeledContent(
|
||||
"Шифрование",
|
||||
value: account.usesDirectTLS ? "Direct TLS" : "STARTTLS"
|
||||
)
|
||||
} else {
|
||||
LabeledContent("Хост", value: "DNS SRV")
|
||||
}
|
||||
LabeledContent("Ресурс", value: account.effectiveResource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mucEntries(_ info: ServerInformation) -> some View {
|
||||
if info.conferenceServers.isEmpty {
|
||||
entry(
|
||||
title: "Нет",
|
||||
detail: "Этот сервер не предоставляет MUC-серверы.",
|
||||
status: .error
|
||||
)
|
||||
} else {
|
||||
ForEach(info.conferenceServers) { server in
|
||||
entry(
|
||||
title: "Сервер: \(server.jid)",
|
||||
detail: "\(server.name ?? "<неизвестное имя>") (тип «\(server.type)», категория «\(server.category)»)",
|
||||
status: server.type == "text" ? .success : .normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stunTurnEntries(_ info: ServerInformation) -> some View {
|
||||
if info.externalServices.isEmpty {
|
||||
entry(
|
||||
title: "Нет",
|
||||
detail: "Этот сервер не предоставляет STUN / TURN сервисы.",
|
||||
status: .error
|
||||
)
|
||||
} else {
|
||||
ForEach(info.externalServices) { service in
|
||||
let endpoint = "\(service.host):\(service.port.map(String.init) ?? "-")"
|
||||
entry(
|
||||
title: service.type.uppercased(),
|
||||
detail: endpoint,
|
||||
status: ["stun", "turn", "stuns", "turns"].contains(service.type.lowercased())
|
||||
? .success
|
||||
: .error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statisticsEntries(_ info: ServerInformation) -> some View {
|
||||
let stats = info.connectionStats
|
||||
entry(
|
||||
title: "Последний вход",
|
||||
detail: formatDate(stats.lastLogin),
|
||||
status: .normal
|
||||
)
|
||||
entry(
|
||||
title: "Сессия Stream Management установлена",
|
||||
detail: formatDate(stats.smacksSessionEstablished),
|
||||
status: .normal
|
||||
)
|
||||
entry(
|
||||
title: "Станз отправлено / не подтверждено / получено в этой сессии",
|
||||
detail: "\(stats.sent) / \(stats.unacknowledged) / \(stats.received)",
|
||||
status: .normal
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func saslEntries(_ info: ServerInformation) -> some View {
|
||||
if info.saslMethods.isEmpty {
|
||||
entry(
|
||||
title: "Нет",
|
||||
detail: "Сервер не объявил методы SASL-аутентификации.",
|
||||
status: .error
|
||||
)
|
||||
} else {
|
||||
ForEach(info.saslMethods, id: \.self) { method in
|
||||
entry(
|
||||
title: "Метод: \(method)",
|
||||
detail: saslDescription(method),
|
||||
status: method == "PLAIN" ? .warning : .normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saslDescription(_ method: String) -> String {
|
||||
switch method {
|
||||
case "PLAIN":
|
||||
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
|
||||
case "EXTERNAL":
|
||||
return "Использует TLS-клиентские сертификаты для аутентификации."
|
||||
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
|
||||
return "Salted Challenge Response Authentication Mechanism с channel-binding."
|
||||
case let method where method.hasPrefix("SCRAM-"):
|
||||
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
|
||||
default:
|
||||
return "Неизвестный метод аутентификации."
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date?) -> String {
|
||||
guard let date else { return "<неизвестно>" }
|
||||
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
|
||||
}
|
||||
|
||||
// MARK: - Entry
|
||||
|
||||
private func entry(
|
||||
title: String,
|
||||
detail: String,
|
||||
status: ServerInformation.CapabilityStatus
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(status.color)
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard model.connectionStatus == .connected else { return }
|
||||
isLoading = true
|
||||
await model.refreshServerInformation()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private extension ServerInformation.CapabilityStatus {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .success: return Color.green.opacity(0.16)
|
||||
case .normal: return Color.clear
|
||||
case .warning: return Color.orange.opacity(0.16)
|
||||
case .error: return Color.red.opacity(0.16)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
|
||||
NavigationLink {
|
||||
ServerInfoView(model: model)
|
||||
} label: {
|
||||
Label("Информация о сервере", systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Шифрование") {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Martin
|
||||
|
||||
/// Counts XEP-0198 stanza statistics (sent / acknowledged / received) at the
|
||||
/// stream level so the server-details screen can mirror Monal's connection
|
||||
/// statistics. Registered as an `XmppStanzaFilter` *before* stream management
|
||||
/// so it can also observe the server's `<a h='N'>` acknowledgements.
|
||||
final class LumaConnectionStatsModule: XmppModuleBase, XmppModule, XmppStanzaFilter {
|
||||
static let ID = "lumaConnectionStats"
|
||||
static let smNamespace = "urn:xmpp:sm:3"
|
||||
|
||||
let criteria = Criteria.empty()
|
||||
let features: [String] = []
|
||||
|
||||
private let lock = NSLock()
|
||||
private var sent: UInt32 = 0
|
||||
private var received: UInt32 = 0
|
||||
private var acknowledged: UInt32 = 0
|
||||
|
||||
struct Snapshot: Equatable, Sendable {
|
||||
let sent: UInt32
|
||||
let acknowledged: UInt32
|
||||
let received: UInt32
|
||||
|
||||
var unacknowledged: UInt32 {
|
||||
sent > acknowledged ? sent - acknowledged : 0
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot: Snapshot {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Snapshot(sent: sent, acknowledged: acknowledged, received: received)
|
||||
}
|
||||
|
||||
func processIncoming(stanza: Stanza) -> Bool {
|
||||
if stanza.xmlns == Self.smNamespace, stanza.name == "a" {
|
||||
if let raw = stanza.getAttribute("h"), let value = UInt32(raw) {
|
||||
lock.lock()
|
||||
acknowledged = value
|
||||
lock.unlock()
|
||||
}
|
||||
// Let StreamManagementModule consume the acknowledgement too.
|
||||
return false
|
||||
}
|
||||
if Self.isCountable(stanza) {
|
||||
lock.lock()
|
||||
received += 1
|
||||
lock.unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func processOutgoing(stanza: Stanza) {
|
||||
guard Self.isCountable(stanza) else { return }
|
||||
lock.lock()
|
||||
sent += 1
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func process(stanza: Stanza) throws {
|
||||
// Criteria is empty, so this module is never selected by the dispatcher.
|
||||
}
|
||||
|
||||
private static func isCountable(_ stanza: Stanza) -> Bool {
|
||||
stanza.name == "iq" || stanza.name == "message" || stanza.name == "presence"
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,9 @@ final class XMPPService {
|
||||
private let callEngine: LumaCallEngine
|
||||
private var client: XMPPClient?
|
||||
private var omemoStorage: LumaOMEMOStore?
|
||||
private var connectionStatsModule: LumaConnectionStatsModule?
|
||||
private var lastLoginDate: Date?
|
||||
private var smacksSessionEstablishedDate: Date?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
private var account: AccountConfiguration?
|
||||
private var archiveSyncStarted = false
|
||||
@@ -435,6 +438,12 @@ final class XMPPService {
|
||||
|
||||
do {
|
||||
try await client.loginAndWait()
|
||||
let now = Date()
|
||||
lastLoginDate = now
|
||||
let streamManagement = client.module(.streamManagement)
|
||||
if streamManagement.ackEnabled || streamManagement.resumptionEnabled {
|
||||
smacksSessionEstablishedDate = now
|
||||
}
|
||||
eventHandler?(.connection(.connected))
|
||||
fetchAvatar(for: account.normalizedJID)
|
||||
startArchiveSyncIfAvailable()
|
||||
@@ -493,6 +502,9 @@ final class XMPPService {
|
||||
roomRealJIDByNickname.removeAll()
|
||||
omemoConfiguredRoomJIDs.removeAll()
|
||||
knownChatStatePeers.removeAll()
|
||||
connectionStatsModule = nil
|
||||
lastLoginDate = nil
|
||||
smacksSessionEstablishedDate = nil
|
||||
eventHandler?(.connection(.disconnected(reason: nil)))
|
||||
}
|
||||
|
||||
@@ -1354,6 +1366,131 @@ final class XMPPService {
|
||||
ownFingerprint: omemoStorage?.ownFingerprint))
|
||||
}
|
||||
|
||||
/// Queries the server's capabilities to populate the "server information"
|
||||
/// settings screen: XEP-0030 disco#info (server + account), XEP-0092
|
||||
/// software version, conference (MUC) services via disco#items, and
|
||||
/// STUN/TURN services via XEP-0215.
|
||||
func fetchServerInformation() async throws -> ServerInformation {
|
||||
guard let client, client.state == .connected(), let account else {
|
||||
throw LumaXMPPError.notConnected
|
||||
}
|
||||
let disco = client.module(.disco)
|
||||
let versionModule = client.module(.softwareVersion)
|
||||
let serverDomain = account.domain ?? client.userBareJid.domain
|
||||
|
||||
// Discover features, software version, server components and external
|
||||
// services in parallel. The version/components/services queries are
|
||||
// best-effort: not every server implements those XEPs, so they degrade
|
||||
// to nil instead of failing the whole screen.
|
||||
async let serverInfo = disco.serverFeatures()
|
||||
async let accountInfo = disco.accountFeatures()
|
||||
async let software = try? versionModule.checkSoftwareVersion(
|
||||
for: JID(serverDomain)
|
||||
)
|
||||
async let components = try? disco.serverComponents()
|
||||
async let services = try? client.module(.externalServiceDiscovery)
|
||||
.discover(from: nil, type: nil)
|
||||
|
||||
let server = try await serverInfo
|
||||
let accountDisco = try await accountInfo
|
||||
let version = await software
|
||||
let componentItems = await components
|
||||
let extServices = await services
|
||||
|
||||
// Resolve each server component to its disco identity. Keep conference
|
||||
// (MUC) services, and also detect HTTP File Upload when it is announced
|
||||
// on a component (e.g. upload.<domain>) rather than the main domain.
|
||||
var conferenceServers: [ServerInformation.ConferenceServer] = []
|
||||
var uploadOnComponent = false
|
||||
if let componentItems {
|
||||
for item in componentItems.items {
|
||||
guard let info = try? await disco.info(for: item.jid) else { continue }
|
||||
if info.features.contains("urn:xmpp:http:upload:0")
|
||||
|| info.features.contains("http://jabber.org/protocol/httpupload")
|
||||
{
|
||||
uploadOnComponent = true
|
||||
}
|
||||
for identity in info.identities where identity.category == "conference" {
|
||||
conferenceServers.append(
|
||||
ServerInformation.ConferenceServer(
|
||||
jid: item.jid.stringValue,
|
||||
name: identity.name,
|
||||
type: identity.type,
|
||||
category: identity.category
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let externalServices = (extServices ?? []).filter {
|
||||
["stun", "turn", "stuns", "turns"].contains($0.type.lowercased())
|
||||
}.map {
|
||||
ServerInformation.ExternalService(
|
||||
type: $0.type,
|
||||
host: $0.host,
|
||||
port: $0.port,
|
||||
transport: $0.transport?.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
// SASL mechanisms and Client State Indication are advertised in the raw
|
||||
// stream <features>, not in disco#info, so read them from there.
|
||||
let streamFeaturesElement = client.module(.streamFeatures).streamFeatures.element
|
||||
let saslMechanisms = streamFeaturesElement?
|
||||
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
|
||||
.children
|
||||
.filter { $0.name == "mechanism" }
|
||||
.compactMap { $0.value } ?? []
|
||||
let supportsCSI = streamFeaturesElement?
|
||||
.findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil
|
||||
let supportsRosterVersioning = streamFeaturesElement?
|
||||
.findChild(name: "ver", xmlns: "urn:xmpp:features:rosterver") != nil
|
||||
let supportsRosterPreApproval = streamFeaturesElement?
|
||||
.findChild(name: "sub", xmlns: "urn:xmpp:features:pre-approval") != nil
|
||||
|
||||
let uploadOnServer = server.features.contains("urn:xmpp:http:upload:0")
|
||||
|| server.features.contains("http://jabber.org/protocol/httpupload")
|
||||
let supportsHTTPUpload = uploadOnServer || uploadOnComponent
|
||||
|
||||
let stats = connectionStatsModule?.snapshot
|
||||
?? LumaConnectionStatsModule.Snapshot(sent: 0, acknowledged: 0, received: 0)
|
||||
|
||||
return ServerInformation(
|
||||
serverDomain: serverDomain,
|
||||
software: ServerInformation.Software(
|
||||
name: version?.name,
|
||||
version: version?.version,
|
||||
os: version?.os
|
||||
),
|
||||
identities: server.identities.map {
|
||||
ServerInformation.Identity(
|
||||
category: $0.category,
|
||||
type: $0.type,
|
||||
name: $0.name
|
||||
)
|
||||
},
|
||||
serverFeatures: server.features,
|
||||
accountFeatures: accountDisco.features,
|
||||
conferenceServers: conferenceServers,
|
||||
externalServices: externalServices,
|
||||
connectionStats: ServerInformation.ConnectionStats(
|
||||
lastLogin: lastLoginDate,
|
||||
smacksSessionEstablished: smacksSessionEstablishedDate,
|
||||
sent: stats.sent,
|
||||
acknowledged: stats.acknowledged,
|
||||
received: stats.received
|
||||
),
|
||||
saslMethods: saslMechanisms,
|
||||
supportsStreamManagement: client.module(.streamManagement).isAvailable,
|
||||
supportsCarbons: client.module(.messageCarbons).isAvailable,
|
||||
supportsClientState: supportsCSI,
|
||||
supportsHTTPUpload: supportsHTTPUpload,
|
||||
supportsRosterVersioning: supportsRosterVersioning,
|
||||
supportsRosterPreApproval: supportsRosterPreApproval
|
||||
)
|
||||
}
|
||||
|
||||
func addToRoster(jid: String, name: String?) {
|
||||
guard let client, client.state == .connected() else { return }
|
||||
client.module(.roster).addItem(
|
||||
@@ -1414,6 +1551,11 @@ final class XMPPService {
|
||||
) {
|
||||
_ = client.modulesManager.register(AuthModule())
|
||||
_ = client.modulesManager.register(StreamFeaturesModule())
|
||||
// Registered before StreamManagementModule so it can observe the
|
||||
// server's `<a h='N'>` acknowledgements for the stanza statistics.
|
||||
connectionStatsModule = client.modulesManager.register(
|
||||
LumaConnectionStatsModule()
|
||||
)
|
||||
_ = client.modulesManager.register(StreamManagementModule())
|
||||
_ = client.modulesManager.register(SaslModule())
|
||||
_ = client.modulesManager.register(ResourceBinderModule())
|
||||
@@ -1770,15 +1912,25 @@ final class XMPPService {
|
||||
fingerprint = nil
|
||||
contentMessage = message
|
||||
default:
|
||||
// Includes `.duplicateMessage` (our own message without an
|
||||
// encrypt-to-self key, or a key already consumed by a previous
|
||||
// session): the body can no longer be recovered here. Still
|
||||
// emit the message (as an undecryptable bubble) instead of
|
||||
// dropping it, so history does not silently stop. Genuine
|
||||
// duplicates are filtered later in AppModel by stanza/origin ID.
|
||||
security = .decryptionFailed
|
||||
fingerprint = nil
|
||||
contentMessage = nil
|
||||
// Some clients include a plaintext <body> fallback alongside the
|
||||
// OMEMO <encrypted> payload. When decryption fails, prefer that
|
||||
// fallback so a readable message is never shown as undecryptable.
|
||||
if message.body?.isEmpty == false {
|
||||
security = .plaintext
|
||||
fingerprint = nil
|
||||
contentMessage = message
|
||||
} else {
|
||||
// Includes `.duplicateMessage` (our own message without an
|
||||
// encrypt-to-self key, or a key already consumed by a
|
||||
// previous session): the body can no longer be recovered
|
||||
// here. Still emit the message (as an undecryptable bubble)
|
||||
// instead of dropping it, so history does not silently
|
||||
// stop. Genuine duplicates are filtered later in AppModel
|
||||
// by stanza/origin ID.
|
||||
security = .decryptionFailed
|
||||
fingerprint = nil
|
||||
contentMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2032,9 +2184,15 @@ final class XMPPService {
|
||||
)
|
||||
return
|
||||
}
|
||||
security = .decryptionFailed
|
||||
fingerprint = nil
|
||||
contentMessage = nil
|
||||
if message.body?.isEmpty == false {
|
||||
security = .plaintext
|
||||
fingerprint = nil
|
||||
contentMessage = message
|
||||
} else {
|
||||
security = .decryptionFailed
|
||||
fingerprint = nil
|
||||
contentMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if encrypted {
|
||||
|
||||
Reference in New Issue
Block a user