From 0317277d466527715fc49ac0cce2ae6e6524a831 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Tue, 18 Aug 2026 21:44:27 +0700 Subject: [PATCH] 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 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. --- Luma.xcodeproj/project.pbxproj | 30 ++ Scripts/verify.sh | 8 +- Sources/Shared/Models/AppModel.swift | 29 ++ Sources/Shared/Models/EmojiCatalog.swift | 200 +++++++++++++ Sources/Shared/Models/ServerInformation.swift | 156 ++++++++++ Sources/Shared/UI/ChatView.swift | 49 ++- .../UI/Components/EmojiPickerView.swift | 92 ++++++ .../Shared/UI/Components/MessageBubble.swift | 7 +- Sources/Shared/UI/ServerInfoView.swift | 279 ++++++++++++++++++ Sources/Shared/UI/SettingsView.swift | 5 + .../XMPP/LumaConnectionStatsModule.swift | 68 +++++ Sources/Shared/XMPP/XMPPService.swift | 182 +++++++++++- 12 files changed, 1081 insertions(+), 24 deletions(-) create mode 100644 Sources/Shared/Models/EmojiCatalog.swift create mode 100644 Sources/Shared/Models/ServerInformation.swift create mode 100644 Sources/Shared/UI/Components/EmojiPickerView.swift create mode 100644 Sources/Shared/UI/ServerInfoView.swift create mode 100644 Sources/Shared/XMPP/LumaConnectionStatsModule.swift diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index da44cc6..b122a02 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -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 = ""; }; 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadata.swift; sourceTree = ""; }; 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicy.swift; sourceTree = ""; }; + 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerView.swift; sourceTree = ""; }; 2F36BB344D0FDF23127DF069 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = ""; }; 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = ""; }; @@ -322,6 +333,7 @@ 83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = ""; }; 876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = ""; }; 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = ""; }; + 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = ""; }; 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; }; 99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; }; 9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -352,6 +364,7 @@ C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = ""; }; C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = ""; }; C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = ""; }; + CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = ""; }; CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = ""; }; CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = ""; }; D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNotePreview.swift; sourceTree = ""; }; @@ -365,10 +378,12 @@ D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = ""; }; 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 = ""; }; + E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; }; E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; }; 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 = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; + F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = ""; }; F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; }; F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 584dc60..bd481cb 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -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 || { diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index b411e25..cd214dc 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.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[ diff --git a/Sources/Shared/Models/EmojiCatalog.swift b/Sources/Shared/Models/EmojiCatalog.swift new file mode 100644 index 0000000..5883659 --- /dev/null +++ b/Sources/Shared/Models/EmojiCatalog.swift @@ -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) + } +} diff --git a/Sources/Shared/Models/ServerInformation.swift b/Sources/Shared/Models/ServerInformation.swift new file mode 100644 index 0000000..305bc97 --- /dev/null +++ b/Sources/Shared/Models/ServerInformation.swift @@ -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 + ), + ] + } +} diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 457af34..ca220c9 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -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 = [] @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() diff --git a/Sources/Shared/UI/Components/EmojiPickerView.swift b/Sources/Shared/UI/Components/EmojiPickerView.swift new file mode 100644 index 0000000..52d5753 --- /dev/null +++ b/Sources/Shared/UI/Components/EmojiPickerView.swift @@ -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) + } + } +} diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 7207025..495a87a 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -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") } } diff --git a/Sources/Shared/UI/ServerInfoView.swift b/Sources/Shared/UI/ServerInfoView.swift new file mode 100644 index 0000000..a187d13 --- /dev/null +++ b/Sources/Shared/UI/ServerInfoView.swift @@ -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) + } + } +} diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 8ef85a5..962d654 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -58,6 +58,11 @@ struct SettingsView: View { } } LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово") + NavigationLink { + ServerInfoView(model: model) + } label: { + Label("Информация о сервере", systemImage: "server.rack") + } } Section("Шифрование") { diff --git a/Sources/Shared/XMPP/LumaConnectionStatsModule.swift b/Sources/Shared/XMPP/LumaConnectionStatsModule.swift new file mode 100644 index 0000000..9a0441d --- /dev/null +++ b/Sources/Shared/XMPP/LumaConnectionStatsModule.swift @@ -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 `` 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" + } +} diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index c001492..c2a0212 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -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 = [] 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.) 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 , 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 `` 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 fallback alongside the + // OMEMO 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 {