add server info screen, connection stats, and emoji picker
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

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:
wt
2026-08-18 21:44:27 +07:00
parent a886917dc8
commit 0317277d46
12 changed files with 1081 additions and 24 deletions
+30
View File
@@ -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
View File
@@ -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 || {
+29
View File
@@ -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[
+200
View File
@@ -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
),
]
}
}
+44 -5
View File
@@ -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")
}
}
+279
View File
@@ -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)
}
}
}
+5
View File
@@ -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"
}
}
+170 -12
View File
@@ -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 {