diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 33d13a9a6..1c29824e7 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -112,6 +112,7 @@ 2AD59AD5B09498EF8B3B04EC /* InvitesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */; }; 2BA59D0AEFB4B82A2EC2A326 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50009897F60FAE7D63EF5E5B /* Kingfisher */; }; 2BAA5B222856068158D0B3C6 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = B1E8B697DF78FE7F61FC6CA4 /* MatrixRustSDK */; }; + 2BBA132149DEBED6624084A8 /* MessageForwardingScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FAE373A7F20780BA84B59C /* MessageForwardingScreenCoordinator.swift */; }; 2C4C750D0039AFABDF24236C /* TemplateScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */; }; 2C5E832434EE94E21AB3B238 /* EmojiPickerScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3EAE3E9D5EF4A6D5D9C6CFD /* EmojiPickerScreenViewModel.swift */; }; 2CA6ABBC9A88EB89EA52FCCB /* ConfettiScene.scn in Resources */ = {isa = PBXBuildFile; fileRef = B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */; }; @@ -253,6 +254,7 @@ 65EDA77363BEDC40CDE43B43 /* InvitesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADEA322D2089391E049535 /* InvitesScreen.swift */; }; 661A664C6EDF856B05519206 /* FilePreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F562E2CBA002E8E1B6545C38 /* FilePreviewScreen.swift */; }; 663E198678778F7426A9B27D /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FAFE1C2149E6AC8156ED2B /* Collection.swift */; }; + 6713835120D94BAA8ED7E3E5 /* MessageForwardingScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59846FA04E1DBBFDD8829C2A /* MessageForwardingScreenUITests.swift */; }; 67160204A8D362BB7D4AD259 /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E16574C6F7F9FA1015A8C /* Search.swift */; }; 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */; }; 67D6E0700A9C1E676F6231F8 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = AD544C0FA48DFFB080920061 /* Collections */; }; @@ -260,6 +262,7 @@ 6832733838C57A7D3FE8FEB5 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 78A5A8DE1E2B09C978C7F3B0 /* KeychainAccess */; }; 6860721DB3091BE08164C132 /* MapAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B48B7AD4908C5C374517B892 /* MapAssets.xcassets */; }; 68AC3C84E2B438036B174E30 /* EmoteRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */; }; + 695825D20A761C678809345D /* MessageForwardingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */; }; 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */; }; 69C7B956B74BEC3DB88224EA /* NavigationSplitCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */; }; 6A0E7551E0D1793245F34CDD /* ClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09A267106B9585D3D0CFC0D /* ClientError.swift */; }; @@ -517,6 +520,7 @@ BF675964C9159F718589C36A /* AnalyticsSettingsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16037EE9E9A52AF37B7818E3 /* AnalyticsSettingsScreenUITests.swift */; }; C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */; }; C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B8E176484A89BAC389D4076 /* RoomMembersListScreen.swift */; }; + C13128AAA787A4C2CBE4EE82 /* MessageForwardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */; }; C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */; }; C1D0AB8222D7BAFC9AF9C8C0 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */; }; @@ -542,6 +546,7 @@ C76892321558E75101E68ED6 /* ReadableFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */; }; C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */; }; C8BD80891BAD688EF2C15CDB /* MediaUploadPreviewScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74DD0855F2F76D47E5555082 /* MediaUploadPreviewScreenCoordinator.swift */; }; + C8E0FA0FF2CD6613264FA6B9 /* MessageForwardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */; }; C9BE065FA7D4E77E4C61CB69 /* MapLibreModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81B6170DB690013CEB646F4 /* MapLibreModels.swift */; }; CA45758F08DF42D41D8A4B29 /* FilePreviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF38B69D2C331A499276F400 /* FilePreviewViewModelTests.swift */; }; CAF8755E152204F55F8D6B5B /* RoomMembersListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */; }; @@ -657,11 +662,13 @@ F4433EF57B4BB3C077F8B00E /* SessionVerificationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD9E0FFA29EAACFF3AB9732 /* SessionVerificationScreenViewModel.swift */; }; F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BCC8A9C73C1F838122C645 /* TimelineItemPlainStylerView.swift */; }; F519DE17A3A0F760307B2E6D /* InviteUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */; }; + F54E2D6CAD96E1AC15BC526F /* MessageForwardingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */; }; F587A9AF25A262DE5A7B0369 /* ProgressTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F28551E81CE3700E5F1EC9B5 /* ProgressTracker.swift */; }; F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */; }; F656F92A63D3DC1978D79427 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */; }; F7567DD6635434E8C563BF85 /* AnalyticsClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */; }; + F777C6FEE7D106136E2ED2B2 /* MessageForwardingScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */; }; F78BAD28482A467287A9A5A3 /* EventBasedMessageTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0900BBF0A5D5D775E917C70 /* EventBasedMessageTimelineItemProtocol.swift */; }; F7BC744FFA7FE248FAE7F570 /* UserIndicatorToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57C8022B8A871A1DCD1750A /* UserIndicatorToastView.swift */; }; F86102DC2C68BBBB0521BAAE /* SoftLogoutScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB385E148DE55C85C0A02D6 /* SoftLogoutScreenModels.swift */; }; @@ -752,6 +759,7 @@ 05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenCoordinator.swift; sourceTree = ""; }; 0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelProtocol.swift; sourceTree = ""; }; 06B098A612DCB5A7358EECD5 /* DeveloperOptionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenModels.swift; sourceTree = ""; }; + 06FAE373A7F20780BA84B59C /* MessageForwardingScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenCoordinator.swift; sourceTree = ""; }; 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = ""; }; 07E65E613F057697A1A0BC03 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 086B997409328F091EBA43CE /* RoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenUITests.swift; sourceTree = ""; }; @@ -937,6 +945,7 @@ 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixUserShareLink.swift; sourceTree = ""; }; 51C2BCE0BC1FC69C1B36E688 /* BugReportScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenModels.swift; sourceTree = ""; }; 51C454AE59914B551A6D02C0 /* UserProfileProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileProxy.swift; sourceTree = ""; }; + 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenModels.swift; sourceTree = ""; }; 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = ""; }; 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = ""; }; 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineController.swift; sourceTree = ""; }; @@ -957,6 +966,7 @@ 584A61D9C459FAFEF038A7C0 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; 58C2527813FDAE23E72A9063 /* AnalyticsSettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelTests.swift; sourceTree = ""; }; 592A35163B0749C66BFD6186 /* MapLibreStaticMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreStaticMapView.swift; sourceTree = ""; }; + 59846FA04E1DBBFDD8829C2A /* MessageForwardingScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenUITests.swift; sourceTree = ""; }; 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; @@ -1007,6 +1017,7 @@ 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindableState.swift; sourceTree = ""; }; 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModel.swift; sourceTree = ""; }; + 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModelTests.swift; sourceTree = ""; }; 6FB31A32C93D94930B253FBF /* PermalinkBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkBuilderTests.swift; sourceTree = ""; }; 6FC5015B9634698BDB8701AF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = it; path = it.lproj/Localizable.stringsdict; sourceTree = ""; }; 7023EB4F3B7C7D1FBA68638B /* TimelineItemDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemDebugView.swift; sourceTree = ""; }; @@ -1196,6 +1207,7 @@ BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenCoordinator.swift; sourceTree = ""; }; BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = ""; }; BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; + BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreen.swift; sourceTree = ""; }; C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = ""; }; C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; C08E9043618AE5B0BF7B07E1 /* TemplateScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModelTests.swift; sourceTree = ""; }; @@ -1264,12 +1276,14 @@ D6DC38E64A5ED3FDB201029A /* BugReportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportService.swift; sourceTree = ""; }; D77B3D4950F1707E66E4A45A /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = ""; }; D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServerSelectionScreenState.swift; sourceTree = ""; }; + D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModel.swift; sourceTree = ""; }; D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = ""; }; DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = ""; }; DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = ""; }; DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsKeys.swift; sourceTree = ""; }; DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = ""; }; DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = ""; }; + DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModelProtocol.swift; sourceTree = ""; }; DE846DDA83BFD7EC5C03760B /* ServerConfirmationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenUITests.swift; sourceTree = ""; }; DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileHandleProxy.swift; sourceTree = ""; }; DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationViewModelTests.swift; sourceTree = ""; }; @@ -1754,6 +1768,18 @@ path = Views; sourceTree = ""; }; + 3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */ = { + isa = PBXGroup; + children = ( + 06FAE373A7F20780BA84B59C /* MessageForwardingScreenCoordinator.swift */, + 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */, + D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */, + DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */, + E816FC2BB467DD0B041C5A03 /* View */, + ); + path = MessageForwardingScreen; + sourceTree = ""; + }; 337015ADFBA3AB96660DB3A6 /* Generated */ = { isa = PBXGroup; children = ( @@ -2313,6 +2339,7 @@ F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */, AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */, 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */, + 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */, F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */, 78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */, 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */, @@ -2646,6 +2673,7 @@ ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */, 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */, 39B6C8690AEA1E49FF1BAF95 /* MediaUploadPreviewScreenUITests.swift */, + 59846FA04E1DBBFDD8829C2A /* MessageForwardingScreenUITests.swift */, 0C88046D6A070D9827181C4D /* OnboardingUITests.swift */, 4132F882A984ED971338EE9D /* ReportContentScreenUITests.swift */, 122186B7CD1BC46A9C629DD9 /* RoomDetailsEditScreenUITests.swift */, @@ -3165,6 +3193,7 @@ 948DD12A5533BE1BC260E437 /* LocationSharing */, 87E2774157D9C4894BCFF3F8 /* MediaPickerScreen */, 23605DD08620BE6558242469 /* MediaUploadPreviewScreen */, + 3348D14DBDB54E72FC67E2F3 /* MessageForwardingScreen */, 3F38EAC92E2281990E65DAF2 /* OnboardingScreen */, A448A3A8F764174C60CD0CA1 /* Other */, 5970F275D6014548DCED6106 /* ReportContentScreen */, @@ -3251,6 +3280,14 @@ path = Authentication; sourceTree = ""; }; + E816FC2BB467DD0B041C5A03 /* View */ = { + isa = PBXGroup; + children = ( + BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */, + ); + path = View; + sourceTree = ""; + }; EBBEB5471737E9D116DF4738 /* Background */ = { isa = PBXGroup; children = ( @@ -3804,6 +3841,7 @@ 167D00CAA13FAFB822298021 /* MediaProviderTests.swift in Sources */, B9A8C34A00D03094C0CF56F3 /* MediaUploadPreviewScreenViewModelTests.swift in Sources */, 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */, + F777C6FEE7D106136E2ED2B2 /* MessageForwardingScreenViewModelTests.swift in Sources */, 4E8F17EBA24FBBA6ABB62ECB /* MockBackgroundTaskService.swift in Sources */, 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */, DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */, @@ -4072,6 +4110,11 @@ 8A0BD60CA4A6004DB06B5403 /* MediaUploadingPreprocessor.swift in Sources */, 24906A1E82D0046655958536 /* MessageComposer.swift in Sources */, 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */, + C8E0FA0FF2CD6613264FA6B9 /* MessageForwardingScreen.swift in Sources */, + 2BBA132149DEBED6624084A8 /* MessageForwardingScreenCoordinator.swift in Sources */, + 695825D20A761C678809345D /* MessageForwardingScreenModels.swift in Sources */, + F54E2D6CAD96E1AC15BC526F /* MessageForwardingScreenViewModel.swift in Sources */, + C13128AAA787A4C2CBE4EE82 /* MessageForwardingScreenViewModelProtocol.swift in Sources */, 152AE2B8650FB23AFD2E28B9 /* MockAuthenticationServiceProxy.swift in Sources */, EE4F5601356228FF72FC56B6 /* MockClientProxy.swift in Sources */, B659E3A49889E749E3239EA7 /* MockMediaProvider.swift in Sources */, @@ -4335,6 +4378,7 @@ 8868ACFA45E5B5E19514B575 /* LegalInformationScreenUITests.swift in Sources */, 5C8AFBF168A41E20835F3B86 /* LoginScreenUITests.swift in Sources */, 7FB0BDE26838F1A92782D5E1 /* MediaUploadPreviewScreenUITests.swift in Sources */, + 6713835120D94BAA8ED7E3E5 /* MessageForwardingScreenUITests.swift in Sources */, 6B15FF984906AAFCF9DC4F58 /* OnboardingUITests.swift in Sources */, BA0D3DDCEDD97502DAC4B6E9 /* ReportContentScreenUITests.swift in Sources */, F16109A6F6DF03DA26D59233 /* RoomDetailsEditScreenUITests.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 4ae595e2d..b542b794f 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -192,15 +192,16 @@ "screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; "screen_account_provider_form_title" = "Find an account provider"; +"screen_account_provider_signin_subtitle" = "This is where you conversations will live — just like you would use an email provider to keep your emails."; "screen_account_provider_signin_title" = "You’re about to sign in to %@"; "screen_account_provider_signup_subtitle" = "This is where you conversations will live — just like you would use an email provider to keep your emails."; "screen_account_provider_signup_title" = "You’re about to create an account on %@"; -"screen_analytics_prompt_data_usage" = "We don't record or profile any account data"; -"screen_analytics_prompt_help_us_improve" = "Help us identify issues and improve %1$@ by sharing anonymous usage data."; +"screen_analytics_prompt_data_usage" = "We won't record or profile any personal data"; +"screen_analytics_prompt_help_us_improve" = "Share anonymous usage data to help us identify issues."; "screen_analytics_prompt_read_terms" = "You can read all our terms %1$@."; "screen_analytics_prompt_read_terms_content_link" = "here"; -"screen_analytics_prompt_settings" = "You can turn this off anytime in settings"; -"screen_analytics_prompt_third_party_sharing" = "We don't share information with third parties"; +"screen_analytics_prompt_settings" = "You can turn this off anytime"; +"screen_analytics_prompt_third_party_sharing" = "We won't share your data with third parties"; "screen_analytics_prompt_title" = "Help improve %1$@"; "screen_analytics_settings_share_data" = "Share analytics data"; "screen_bug_report_attach_screenshot" = "Attach screenshot"; @@ -223,6 +224,7 @@ "screen_change_server_form_notice" = "You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$@"; "screen_change_server_subtitle" = "What is the address of your server?"; "screen_create_room_action_create_room" = "New room"; +"screen_create_room_action_invite_people" = "Invite friends to Element"; "screen_create_room_add_people_title" = "Invite people"; "screen_create_room_error_creating_room" = "An error occurred when creating the room"; "screen_create_room_private_option_description" = "Messages in this room are encrypted. Encryption can’t be disabled afterwards."; @@ -269,6 +271,7 @@ "screen_room_details_encryption_enabled_subtitle" = "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."; "screen_room_details_encryption_enabled_title" = "Message encryption enabled"; "screen_room_details_invite_people_title" = "Invite people"; +"screen_room_details_notification_title" = "Notification"; "screen_room_details_room_name_label" = "Room name"; "screen_room_details_share_room_title" = "Share room"; "screen_room_details_updating_room" = "Updating room…"; @@ -283,6 +286,7 @@ "screen_room_member_details_unblock_user" = "Unblock user"; "screen_room_member_list_pending_header_title" = "Pending"; "screen_room_member_list_room_members_header_title" = "Room members"; +"screen_room_no_permission_to_post" = "You do not have permission to post to this room"; "screen_room_retry_send_menu_send_again_action" = "Send again"; "screen_room_retry_send_menu_title" = "Your message failed to send"; "screen_roomlist_a11y_create_message" = "Create a new conversation or room"; @@ -377,7 +381,7 @@ "dialog_title_success" = "Success"; "notification_room_action_quick_reply" = "Quick reply"; "rageshake_detection_dialog_content" = "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"; -"screen_analytics_settings_help_us_improve" = "Help us identify issues and improve %1$@ by sharing anonymous usage data."; +"screen_analytics_settings_help_us_improve" = "Share anonymous usage data to help us identify issues."; "screen_analytics_settings_read_terms" = "You can read all our terms %1$@."; "screen_analytics_settings_read_terms_content_link" = "here"; "screen_bug_report_rash_logs_alert_title" = "%1$@ crashed the last time it was used. Would you like to share a crash report with us?"; diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 1866b0f34..3a175096d 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -29,6 +29,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private let navigationStackCoordinator: NavigationStackCoordinator private let navigationSplitCoordinator: NavigationSplitCoordinator private let emojiProvider: EmojiProviderProtocol + private let userIndicatorController: UserIndicatorControllerProtocol private let stateMachine: StateMachine = .init(state: .initial) @@ -46,12 +47,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, navigationStackCoordinator: NavigationStackCoordinator, navigationSplitCoordinator: NavigationSplitCoordinator, - emojiProvider: EmojiProviderProtocol) { + emojiProvider: EmojiProviderProtocol, + userIndicatorController: UserIndicatorControllerProtocol) { self.userSession = userSession self.roomTimelineControllerFactory = roomTimelineControllerFactory self.navigationStackCoordinator = navigationStackCoordinator self.navigationSplitCoordinator = navigationSplitCoordinator self.emojiProvider = emojiProvider + self.userIndicatorController = userIndicatorController setupStateMachine() } @@ -137,6 +140,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (.dismissEmojiPicker, .emojiPicker(let roomID, _)): return .room(roomID: roomID) + case (.presentMessageForwarding(let itemID), .room(let roomID)): + return .messageForwarding(roomID: roomID, itemID: itemID) + case (.dismissMessageForwarding, .messageForwarding(let roomID, _)): + return .room(roomID: roomID) + default: return nil } @@ -152,7 +160,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { dismissRoom(animated: animated) presentRoom(roomID, animated: animated) case (_, .presentRoom(let roomID), .room): - presentRoom(roomID, animated: animated) + let destinationRoomProxy = (context.userInfo as? EventUserInfo)?.destinationRoomProxy + presentRoom(roomID, animated: animated, destinationRoomProxy: destinationRoomProxy) case (.room, .dismissRoom, .initial): dismissRoom(animated: animated) @@ -199,6 +208,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (.roomMemberDetails, .dismissRoomMemberDetails, .room): break + case (.room, .presentMessageForwarding(let eventID), .messageForwarding): + presentMessageForwarding(for: eventID) + case (.messageForwarding, .dismissMessageForwarding, .room): + break + default: fatalError("Unknown transition: \(context)") } @@ -217,23 +231,39 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func presentRoom(_ roomID: String, animated: Bool) { + + /// Updates the navigation stack so it displays the timeline for the given room + /// - Parameters: + /// - roomID: the identifier of the room that is to be presented + /// - animated: whether it should animate the transition + /// - destinationRoomProxy: an optional already build roomProxy for the target room. It is currently used when + /// forwarding messages so that we can take advantage of the local echo + /// and have the message already there when presenting the room + private func presentRoom(_ roomID: String, animated: Bool, destinationRoomProxy: RoomProxyProtocol? = nil) { Task { - await asyncPresentRoom(roomID, animated: animated) + await asyncPresentRoom(roomID, animated: animated, destinationRoomProxy: destinationRoomProxy) } } // swiftlint:disable:next cyclomatic_complexity function_body_length - private func asyncPresentRoom(_ roomID: String, animated: Bool) async { + private func asyncPresentRoom(_ roomID: String, animated: Bool, destinationRoomProxy: RoomProxyProtocol? = nil) async { if let roomProxy, roomProxy.id == roomID { navigationStackCoordinator.popToRoot() return } - guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { - MXLog.error("Invalid room identifier: \(roomID)") - stateMachine.tryEvent(.dismissRoom) - return + let roomProxy: RoomProxyProtocol + + if let destinationRoomProxy { + roomProxy = destinationRoomProxy + } else { + guard let proxy = await userSession.clientProxy.roomForIdentifier(roomID) else { + MXLog.error("Invalid room identifier: \(roomID)") + stateMachine.tryEvent(.dismissRoom) + return + } + + roomProxy = proxy } actionsSubject.send(.presentedRoom(roomID)) @@ -277,6 +307,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID)) case .presentRoomMemberDetails(member: let member): stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member))) + case .presentMessageForwarding(let itemID): + stateMachine.tryEvent(.presentMessageForwarding(itemID: itemID)) } } .store(in: &cancellables) @@ -398,7 +430,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .cancel: break case .finish: - self?.showSuccess(label: L10n.commonReportSubmitted) + userIndicatorController.submitIndicator(UserIndicator(title: L10n.commonReportSubmitted, iconName: "checkmark")) } } navigationCoordinator.setRootCoordinator(coordinator) @@ -492,9 +524,65 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { self?.stateMachine.tryEvent(.dismissRoomMemberDetails) } } + + private func presentMessageForwarding(for eventID: String) { + guard let roomProxy, let roomSummaryProvider = userSession.clientProxy.roomSummaryProvider else { + fatalError() + } + + let messageForwardingNavigationStackCoordinator = NavigationStackCoordinator() + + let parameters = MessageForwardingScreenCoordinatorParameters(roomSummaryProvider: roomSummaryProvider, + sourceRoomID: roomProxy.id) + let coordinator = MessageForwardingScreenCoordinator(parameters: parameters) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .dismiss: + navigationStackCoordinator.setSheetCoordinator(nil) + case .send(let roomID): + navigationStackCoordinator.setSheetCoordinator(nil) + + Task { + await self.forward(eventID: eventID, toRoomID: roomID) + } + } + }.store(in: &cancellables) + + messageForwardingNavigationStackCoordinator.setRootCoordinator(coordinator) - private func showSuccess(label: String) { - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark")) + navigationStackCoordinator.setSheetCoordinator(messageForwardingNavigationStackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissMessageForwarding) + } + } + + private func forward(eventID: String, toRoomID roomID: String) async { + guard let roomProxy else { + MXLog.error("Failed retrieving current room with id: \(roomID)") + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + return + } + + guard let messageEventContent = roomProxy.messageEventContent(for: eventID) else { + MXLog.error("Failed retrieving forwarded message event content for eventID: \(eventID)") + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + return + } + + guard let targetRoomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { + MXLog.error("Failed retrieving room to forward to with id: \(roomID)") + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + return + } + + if case .failure(let error) = await targetRoomProxy.sendMessageEventContent(messageEventContent) { + MXLog.error("Failed forwarding message with error: \(error)") + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + return + } + + stateMachine.tryEvent(.presentRoom(roomID: roomID), userInfo: EventUserInfo(animated: true, destinationRoomProxy: targetRoomProxy)) } } @@ -521,10 +609,12 @@ private extension RoomFlowCoordinator { case mediaUploadPreview(roomID: String, fileURL: URL) case emojiPicker(roomID: String, itemID: String) case roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper) + case messageForwarding(roomID: String, itemID: String) } struct EventUserInfo { let animated: Bool + var destinationRoomProxy: RoomProxyProtocol? } enum Event: EventType { @@ -551,5 +641,8 @@ private extension RoomFlowCoordinator { case presentRoomMemberDetails(member: HashableRoomMemberWrapper) case dismissRoomMemberDetails + + case presentMessageForwarding(itemID: String) + case dismissMessageForwarding } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 807bac449..8faa5615c 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -59,7 +59,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { roomTimelineControllerFactory: roomTimelineControllerFactory, navigationStackCoordinator: detailNavigationStackCoordinator, navigationSplitCoordinator: navigationSplitCoordinator, - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController) setupStateMachine() diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 66b11949e..8e5599b66 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -466,6 +466,8 @@ public enum L10n { public static var screenAccountProviderFormSubtitle: String { return L10n.tr("Localizable", "screen_account_provider_form_subtitle") } /// Find an account provider public static var screenAccountProviderFormTitle: String { return L10n.tr("Localizable", "screen_account_provider_form_title") } + /// This is where you conversations will live — just like you would use an email provider to keep your emails. + public static var screenAccountProviderSigninSubtitle: String { return L10n.tr("Localizable", "screen_account_provider_signin_subtitle") } /// You’re about to sign in to %@ public static func screenAccountProviderSigninTitle(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_account_provider_signin_title", String(describing: p1)) @@ -476,30 +478,26 @@ public enum L10n { public static func screenAccountProviderSignupTitle(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_account_provider_signup_title", String(describing: p1)) } - /// We don't record or profile any account data + /// We won't record or profile any personal data public static var screenAnalyticsPromptDataUsage: String { return L10n.tr("Localizable", "screen_analytics_prompt_data_usage") } - /// Help us identify issues and improve %1$@ by sharing anonymous usage data. - public static func screenAnalyticsPromptHelpUsImprove(_ p1: Any) -> String { - return L10n.tr("Localizable", "screen_analytics_prompt_help_us_improve", String(describing: p1)) - } + /// Share anonymous usage data to help us identify issues. + public static var screenAnalyticsPromptHelpUsImprove: String { return L10n.tr("Localizable", "screen_analytics_prompt_help_us_improve") } /// You can read all our terms %1$@. public static func screenAnalyticsPromptReadTerms(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_analytics_prompt_read_terms", String(describing: p1)) } /// here public static var screenAnalyticsPromptReadTermsContentLink: String { return L10n.tr("Localizable", "screen_analytics_prompt_read_terms_content_link") } - /// You can turn this off anytime in settings + /// You can turn this off anytime public static var screenAnalyticsPromptSettings: String { return L10n.tr("Localizable", "screen_analytics_prompt_settings") } - /// We don't share information with third parties + /// We won't share your data with third parties public static var screenAnalyticsPromptThirdPartySharing: String { return L10n.tr("Localizable", "screen_analytics_prompt_third_party_sharing") } /// Help improve %1$@ public static func screenAnalyticsPromptTitle(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_analytics_prompt_title", String(describing: p1)) } - /// Help us identify issues and improve %1$@ by sharing anonymous usage data. - public static func screenAnalyticsSettingsHelpUsImprove(_ p1: Any) -> String { - return L10n.tr("Localizable", "screen_analytics_settings_help_us_improve", String(describing: p1)) - } + /// Share anonymous usage data to help us identify issues. + public static var screenAnalyticsSettingsHelpUsImprove: String { return L10n.tr("Localizable", "screen_analytics_settings_help_us_improve") } /// You can read all our terms %1$@. public static func screenAnalyticsSettingsReadTerms(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_analytics_settings_read_terms", String(describing: p1)) @@ -558,6 +556,8 @@ public enum L10n { public static var screenChangeServerTitle: String { return L10n.tr("Localizable", "screen_change_server_title") } /// New room public static var screenCreateRoomActionCreateRoom: String { return L10n.tr("Localizable", "screen_create_room_action_create_room") } + /// Invite friends to Element + public static var screenCreateRoomActionInvitePeople: String { return L10n.tr("Localizable", "screen_create_room_action_invite_people") } /// Invite people public static var screenCreateRoomAddPeopleTitle: String { return L10n.tr("Localizable", "screen_create_room_add_people_title") } /// An error occurred when creating the room @@ -684,6 +684,8 @@ public enum L10n { public static var screenRoomDetailsInvitePeopleTitle: String { return L10n.tr("Localizable", "screen_room_details_invite_people_title") } /// Leave room public static var screenRoomDetailsLeaveRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_leave_room_title") } + /// Notification + public static var screenRoomDetailsNotificationTitle: String { return L10n.tr("Localizable", "screen_room_details_notification_title") } /// People public static var screenRoomDetailsPeopleTitle: String { return L10n.tr("Localizable", "screen_room_details_people_title") } /// Room name @@ -724,6 +726,8 @@ public enum L10n { public static var screenRoomMemberListPendingHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_pending_header_title") } /// Room members public static var screenRoomMemberListRoomMembersHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_room_members_header_title") } + /// You do not have permission to post to this room + public static var screenRoomNoPermissionToPost: String { return L10n.tr("Localizable", "screen_room_no_permission_to_post") } /// Remove public static var screenRoomRetrySendMenuRemoveAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_remove_action") } /// Send again diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 9e7696293..a69ee8b94 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -457,6 +457,11 @@ class RoomProxyMock: RoomProxyProtocol { set(value) { underlyingUpdatesPublisher = value } } var underlyingUpdatesPublisher: AnyPublisher! + var timelineProvider: RoomTimelineProviderProtocol { + get { return underlyingTimelineProvider } + set(value) { underlyingTimelineProvider = value } + } + var underlyingTimelineProvider: RoomTimelineProviderProtocol! //MARK: - loadAvatarURLForUserId @@ -500,23 +505,6 @@ class RoomProxyMock: RoomProxyProtocol { return loadDisplayNameForUserIdReturnValue } } - //MARK: - registerTimelineListenerIfNeeded - - var registerTimelineListenerIfNeededCallsCount = 0 - var registerTimelineListenerIfNeededCalled: Bool { - return registerTimelineListenerIfNeededCallsCount > 0 - } - var registerTimelineListenerIfNeededReturnValue: Result<[TimelineItem], RoomProxyError>! - var registerTimelineListenerIfNeededClosure: (() -> Result<[TimelineItem], RoomProxyError>)? - - func registerTimelineListenerIfNeeded() -> Result<[TimelineItem], RoomProxyError> { - registerTimelineListenerIfNeededCallsCount += 1 - if let registerTimelineListenerIfNeededClosure = registerTimelineListenerIfNeededClosure { - return registerTimelineListenerIfNeededClosure() - } else { - return registerTimelineListenerIfNeededReturnValue - } - } //MARK: - paginateBackwards var paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount = 0 @@ -559,6 +547,48 @@ class RoomProxyMock: RoomProxyProtocol { return sendReadReceiptForReturnValue } } + //MARK: - messageEventContent + + var messageEventContentForCallsCount = 0 + var messageEventContentForCalled: Bool { + return messageEventContentForCallsCount > 0 + } + var messageEventContentForReceivedEventID: String? + var messageEventContentForReceivedInvocations: [String] = [] + var messageEventContentForReturnValue: RoomMessageEventContent? + var messageEventContentForClosure: ((String) -> RoomMessageEventContent?)? + + func messageEventContent(for eventID: String) -> RoomMessageEventContent? { + messageEventContentForCallsCount += 1 + messageEventContentForReceivedEventID = eventID + messageEventContentForReceivedInvocations.append(eventID) + if let messageEventContentForClosure = messageEventContentForClosure { + return messageEventContentForClosure(eventID) + } else { + return messageEventContentForReturnValue + } + } + //MARK: - sendMessageEventContent + + var sendMessageEventContentCallsCount = 0 + var sendMessageEventContentCalled: Bool { + return sendMessageEventContentCallsCount > 0 + } + var sendMessageEventContentReceivedMessageContent: RoomMessageEventContent? + var sendMessageEventContentReceivedInvocations: [RoomMessageEventContent] = [] + var sendMessageEventContentReturnValue: Result! + var sendMessageEventContentClosure: ((RoomMessageEventContent) async -> Result)? + + func sendMessageEventContent(_ messageContent: RoomMessageEventContent) async -> Result { + sendMessageEventContentCallsCount += 1 + sendMessageEventContentReceivedMessageContent = messageContent + sendMessageEventContentReceivedInvocations.append(messageContent) + if let sendMessageEventContentClosure = sendMessageEventContentClosure { + return await sendMessageEventContentClosure(messageContent) + } else { + return sendMessageEventContentReturnValue + } + } //MARK: - sendMessage var sendMessageInReplyToCallsCount = 0 diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 78e173851..c0a3c1cb2 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -74,7 +74,6 @@ extension RoomProxyMock { updateMembersClosure = { } acceptInvitationClosure = { .success(()) } - registerTimelineListenerIfNeededClosure = { .success([]) } underlyingUpdatesPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() setNameClosure = { _ in .success(()) } setTopicClosure = { _ in .success(()) } diff --git a/ElementX/Sources/Other/AvatarSize.swift b/ElementX/Sources/Other/AvatarSize.swift index 74a0b462b..e747b9a5c 100644 --- a/ElementX/Sources/Other/AvatarSize.swift +++ b/ElementX/Sources/Other/AvatarSize.swift @@ -76,12 +76,15 @@ enum UserAvatarSizeOnScreen { enum RoomAvatarSizeOnScreen { case timeline case home + case messageForwarding case details var value: CGFloat { switch self { case .timeline: return 32 + case .messageForwarding: + return 36 case .home: return 52 case .details: diff --git a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift index 87679b325..d63fff17e 100644 --- a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift +++ b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift @@ -23,7 +23,8 @@ struct FormRowAccessory: View { enum Kind { case navigationLink case progressView - case selection(isSelected: Bool) + case singleSelection(isSelected: Bool) + case multipleSelection(isSelected: Bool) } let kind: Kind @@ -36,8 +37,12 @@ struct FormRowAccessory: View { .init(kind: .progressView) } - static func selection(isSelected: Bool) -> Self { - .init(kind: .selection(isSelected: isSelected)) + static func singleSelection(isSelected: Bool) -> Self { + .init(kind: .singleSelection(isSelected: isSelected)) + } + + static func multipleSelection(isSelected: Bool) -> Self { + .init(kind: .multipleSelection(isSelected: isSelected)) } var body: some View { @@ -48,7 +53,13 @@ struct FormRowAccessory: View { .foregroundColor(.compound.iconQuaternary) case .progressView: ProgressView() - case .selection(let isSelected): + case .singleSelection(let isSelected): + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.compound.bodyLG) + .foregroundColor(isSelected && isEnabled ? .compound.iconPrimary : .compound.iconTertiary) + } + case .multipleSelection(let isSelected): Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.compound.bodyLG) .foregroundColor(isSelected && isEnabled ? .compound.iconPrimary : .compound.iconTertiary) @@ -94,7 +105,16 @@ struct FormButtonStyle: PrimitiveButtonStyle { } .contentShape(Rectangle()) .padding(FormRow.insets) // Re-apply the normal insets using padding. - .background(configuration.isPressed ? Color.compound.bgSubtlePrimary : .clear) + .background(backgroundColor(for: configuration)) + } + + private func backgroundColor(for configuration: Configuration) -> Color { + switch accessory?.kind { + case .none, .navigationLink, .progressView: + return configuration.isPressed ? .compound.bgSubtlePrimary : .element.formRowBackground + default: + return .element.formRowBackground + } } } } @@ -147,7 +167,7 @@ struct FormButtonStyles_Previews: PreviewProvider { } .formSectionStyle() - Section { + Section("Single selection") { Button { } label: { Text("Hello world") } @@ -156,18 +176,42 @@ struct FormButtonStyles_Previews: PreviewProvider { Button { } label: { Text("Selected") } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + .buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: true))) Button { } label: { Text("Selected (disabled)") } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + .buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: true))) .disabled(true) Button { } label: { Text("Unselected") } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) + .buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: false))) + } + .formSectionStyle() + + Section("Multiple selection") { + Button { } label: { + Text("Hello world") + } + .buttonStyle(FormButtonStyle()) + + Button { } label: { + Text("Selected") + } + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true))) + + Button { } label: { + Text("Selected (disabled)") + } + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true))) + .disabled(true) + + Button { } label: { + Text("Unselected") + } + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: false))) } .formSectionStyle() diff --git a/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift b/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift index eb9a4d8ec..2cf5c57b2 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/UserProfileCell.swift @@ -97,33 +97,33 @@ struct UserProfileCell_Previews: PreviewProvider { Button(action: action) { UserProfileCell(user: .mockAlice, membership: nil, imageProvider: MockMediaProvider()) } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true))) .previewDisplayName("Selected user") Button(action: action) { UserProfileCell(user: .mockBob, membership: nil, imageProvider: MockMediaProvider()) } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: false))) .previewDisplayName("Unselected user") Button(action: action) { UserProfileCell(user: .mockCharlie, membership: .join, imageProvider: MockMediaProvider()) } .disabled(true) - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true))) .previewDisplayName("Selected disabled user") Button(action: action) { UserProfileCell(user: .init(userID: "@someone:matrix.org"), membership: .join, imageProvider: MockMediaProvider()) } .disabled(true) - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true))) + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true))) .previewDisplayName("Unverified joined user") Button(action: action) { UserProfileCell(user: .init(userID: "@someone:matrix.org"), membership: nil, imageProvider: MockMediaProvider()) } - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: false))) .previewDisplayName("Unverified user") } } diff --git a/ElementX/Sources/Screens/AnalyticsPromptScreen/AnalyticsPromptScreenModels.swift b/ElementX/Sources/Screens/AnalyticsPromptScreen/AnalyticsPromptScreenModels.swift index 879a535d0..d6fe3d4ba 100644 --- a/ElementX/Sources/Screens/AnalyticsPromptScreen/AnalyticsPromptScreenModels.swift +++ b/ElementX/Sources/Screens/AnalyticsPromptScreen/AnalyticsPromptScreenModels.swift @@ -43,7 +43,7 @@ struct AnalyticsPromptScreenStrings { let point3 = L10n.screenAnalyticsPromptSettings init(termsURL: URL) { - let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove(InfoPlistReader.main.bundleDisplayName)) + let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove) // Create the 'read terms' with a placeholder. let linkPlaceholder = "{link}" diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift b/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift index a2fb89788..e67a1bca5 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift @@ -45,7 +45,7 @@ struct EmojiPickerScreen: View { .navigationTitle(L10n.commonReactions) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } - .searchable(text: $searchString) + .searchable(text: $searchString, placement: .navigationBarDrawer(displayMode: .always)) .compoundSearchField() .onChange(of: searchString) { _ in context.send(viewAction: .search(searchString: searchString)) diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift index d5dc749e7..03d66268e 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift @@ -53,7 +53,7 @@ struct HomeScreenRoomCell: View { .accessibilityIdentifier(A11yIdentifiers.homeScreen.roomName(room.name)) } - @ViewBuilder + @ViewBuilder @MainActor var avatar: some View { if dynamicTypeSize < .accessibility3 { LoadableAvatarImage(url: room.avatarURL, diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift index 3b28cf006..9c53ade05 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift @@ -79,7 +79,7 @@ struct InviteUsersScreen: View { imageProvider: context.imageProvider) } .disabled(context.viewState.isUserDisabled(user)) - .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user)))) + .buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: context.viewState.isUserSelected(user)))) } } header: { if let title = context.viewState.usersSection.title { diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift new file mode 100644 index 000000000..b485b2665 --- /dev/null +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift @@ -0,0 +1,62 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +struct MessageForwardingScreenCoordinatorParameters { + let roomSummaryProvider: RoomSummaryProviderProtocol + let sourceRoomID: String +} + +enum MessageForwardingScreenCoordinatorAction { + case dismiss + case send(roomID: String) +} + +final class MessageForwardingScreenCoordinator: CoordinatorProtocol { + private let parameters: MessageForwardingScreenCoordinatorParameters + private var viewModel: MessageForwardingScreenViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables: Set = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: MessageForwardingScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = MessageForwardingScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, + sourceRoomID: parameters.sourceRoomID) + } + + func start() { + viewModel.actions.sink { [weak self] action in + switch action { + case .dismiss: + self?.actionsSubject.send(.dismiss) + case .send(let roomID): + self?.actionsSubject.send(.send(roomID: roomID)) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(MessageForwardingScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift new file mode 100644 index 000000000..ff2e3dd45 --- /dev/null +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift @@ -0,0 +1,54 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum MessageForwardingScreenViewModelAction { + case dismiss + case send(roomID: String) +} + +struct MessageForwardingScreenViewState: BindableState { + var rooms: [MessageForwardingRoom] = [] + var selectedRoomID: String? + + var visibleRooms: [MessageForwardingRoom] { + if bindings.searchQuery.isEmpty { + return rooms + } + + return rooms.filter { $0.name.localizedStandardContains(bindings.searchQuery) } + } + + var bindings = MessageForwardingScreenViewStateBindings() +} + +struct MessageForwardingScreenViewStateBindings { + var searchQuery = "" +} + +enum MessageForwardingScreenViewAction { + case cancel + case send + case selectRoom(roomID: String) +} + +struct MessageForwardingRoom: Identifiable, Equatable { + let id: String + let name: String + let alias: String? + let avatarURL: URL? +} diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift new file mode 100644 index 000000000..f81db4cc1 --- /dev/null +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift @@ -0,0 +1,94 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +typealias MessageForwardingScreenViewModelType = StateStoreViewModel + +class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, MessageForwardingScreenViewModelProtocol { + private let roomSummaryProvider: RoomSummaryProviderProtocol? + private let sourceRoomID: String + + private var actionsSubject: PassthroughSubject = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(roomSummaryProvider: RoomSummaryProviderProtocol, + sourceRoomID: String) { + self.roomSummaryProvider = roomSummaryProvider + self.sourceRoomID = sourceRoomID + + super.init(initialViewState: MessageForwardingScreenViewState()) + + roomSummaryProvider.roomListPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateRooms() + } + .store(in: &cancellables) + + updateRooms() + } + + override func process(viewAction: MessageForwardingScreenViewAction) { + switch viewAction { + case .cancel: + actionsSubject.send(.dismiss) + case .send: + guard let roomID = state.selectedRoomID else { + fatalError() + } + + actionsSubject.send(.send(roomID: roomID)) + case .selectRoom(let roomID): + state.selectedRoomID = roomID + } + } + + // MARK: - Private + + private func updateRooms() { + guard let roomSummaryProvider else { + MXLog.error("Room summary provider unavailable") + return + } + + MXLog.info("Updating rooms") + + var rooms = [MessageForwardingRoom]() + + for summary in roomSummaryProvider.roomListPublisher.value { + switch summary { + case .empty, .invalidated: + continue + case .filled(let details): + if details.id == sourceRoomID { + continue + } + + let room = MessageForwardingRoom(id: details.id, name: details.name, alias: details.canonicalAlias, avatarURL: details.avatarURL) + rooms.append(room) + } + } + + state.rooms = rooms + + MXLog.info("Finished updating rooms") + } +} diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModelProtocol.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModelProtocol.swift new file mode 100644 index 000000000..7f166c7ee --- /dev/null +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +@MainActor +protocol MessageForwardingScreenViewModelProtocol { + var actions: AnyPublisher { get } + var context: MessageForwardingScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift new file mode 100644 index 000000000..d04599640 --- /dev/null +++ b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift @@ -0,0 +1,108 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct MessageForwardingScreen: View { + @ObservedObject var context: MessageForwardingScreenViewModel.Context + + var body: some View { + Form { + Section { + ForEach(context.viewState.visibleRooms) { room in + MessageForwardingRoomCell(room: room, context: context) + .buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: context.viewState.selectedRoomID == room.id))) + } + } + .formSectionStyle() + } + .listRowSeparator(.automatic) + .elementFormStyle() + .navigationTitle(L10n.commonForwardMessage) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.actionCancel) { + context.send(viewAction: .cancel) + } + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.actionSend) { + context.send(viewAction: .send) + } + .disabled(context.viewState.selectedRoomID == nil) + } + } + .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always)) + .compoundSearchField() + .disableAutocorrection(true) + } +} + +private struct MessageForwardingRoomCell: View { + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + let room: MessageForwardingRoom + let context: MessageForwardingScreenViewModel.Context + + var body: some View { + Button { + context.send(viewAction: .selectRoom(roomID: room.id)) + } label: { + HStack(spacing: 16.0) { + avatar + + VStack(alignment: .leading, spacing: 2.0) { + Text(room.name) + .font(.compound.bodyMD) + .foregroundColor(.compound.textPrimary) + .lineLimit(1) + Text(room.alias ?? room.id) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + .lineLimit(1) + } + } + .accessibilityElement(children: .combine) + } + } + + @ViewBuilder @MainActor + var avatar: some View { + if dynamicTypeSize < .accessibility3 { + LoadableAvatarImage(url: room.avatarURL, + name: room.name, + contentID: room.id, + avatarSize: .room(on: .messageForwarding), + imageProvider: context.imageProvider) + .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) + .accessibilityHidden(true) + } + } +} + +// MARK: - Previews + +struct MessageForwardingScreen_Previews: PreviewProvider { + static var previews: some View { + let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms)) + let viewModel = MessageForwardingScreenViewModel(roomSummaryProvider: summaryProvider, sourceRoomID: "") + + NavigationStack { + MessageForwardingScreen(context: viewModel.context) + } + } +} diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index 0b5301684..bbb0d7566 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -89,13 +89,6 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr // MARK: - Private private func setupRoomSubscription() { - switch roomProxy.registerTimelineListenerIfNeeded() { - case .success, .failure(.roomListenerAlreadyRegistered): - break - case .failure: - MXLog.error("Failed to register a room listener in room's details for the room \(roomProxy.id)") - } - roomProxy.updatesPublisher .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 61817aada..125bcaf1f 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -32,6 +32,7 @@ enum RoomScreenCoordinatorAction { case presentRoomDetails case presentEmojiPicker(itemID: String) case presentRoomMemberDetails(member: RoomMemberProxyProtocol) + case presentMessageForwarding(itemID: String) } final class RoomScreenCoordinator: CoordinatorProtocol { @@ -54,6 +55,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { // MARK: - Public + // swiftlint:disable:next cyclomatic_complexity func start() { viewModel.callback = { [weak self] action in guard let self else { return } @@ -77,6 +79,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentMediaUploadPreviewScreen(url)) case .displayRoomMemberDetails(let member): actionsSubject.send(.presentRoomMemberDetails(member: member)) + case .displayMessageForwarding(let itemID): + actionsSubject.send(.presentMessageForwarding(itemID: itemID)) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 2d8541c84..139c36153 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -28,6 +28,7 @@ enum RoomScreenViewModelAction { case displayDocumentPicker case displayMediaUploadPreviewScreen(url: URL) case displayRoomMemberDetails(member: RoomMemberProxyProtocol) + case displayMessageForwarding(itemID: String) } enum RoomScreenComposerMode: Equatable { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index e60feba32..ee0bbbe33 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -330,12 +330,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } var actions: [TimelineItemMenuAction] = [ - .reply, .copyPermalink + .reply ] if timelineItem is EventBasedMessageTimelineItemProtocol { - actions.append(contentsOf: [.copy, .quote]) + actions.append(contentsOf: [.forward(itemID: itemId), .copy, .quote]) } + + actions.append(.copyPermalink) if item.isEditable { actions.append(.edit) @@ -403,6 +405,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: buildReplyContent(for: eventTimelineItem)) state.composerMode = .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails) + case .forward(let itemID): + callback?(.displayMessageForwarding(itemID: itemID)) case .viewSource: let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id) MXLog.info(debugInfo) diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift index a7b6ace5c..7bfaf1bbd 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift @@ -37,6 +37,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case copyPermalink case redact case reply + case forward(itemID: String) case viewSource case retryDecryption(sessionID: String) case report @@ -193,6 +194,10 @@ public struct TimelineItemMenu: View { Button { send(action) } label: { MenuLabel(title: L10n.actionReply, systemImageName: "arrowshape.turn.up.left") } + case .forward: + Button { send(action) } label: { + MenuLabel(title: L10n.actionForward, systemImageName: "arrowshape.turn.up.right") + } case .redact: Button(role: .destructive) { send(action) } label: { MenuLabel(title: L10n.actionRemove, systemImageName: "trash") diff --git a/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenModels.swift index fb86133df..85e5953e2 100644 --- a/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenModels.swift @@ -34,7 +34,7 @@ struct AnalyticsSettingsScreenStrings { let sectionFooter: AttributedString init(termsURL: URL) { - let content = AttributedString(L10n.screenAnalyticsSettingsHelpUsImprove(InfoPlistReader.main.bundleDisplayName)) + let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove) // Create the 'read terms' with a placeholder. let linkPlaceholder = "{link}" diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 1858966d7..926f7b5ee 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -46,6 +46,11 @@ class RoomProxy: RoomProxyProtocol { updatesSubject.eraseToAnyPublisher() } + var innerTimelineProvider: RoomTimelineProviderProtocol! + var timelineProvider: RoomTimelineProviderProtocol { + innerTimelineProvider + } + deinit { Task { @MainActor [roomTimelineObservationToken, roomListItem] in roomTimelineObservationToken?.cancel() @@ -59,6 +64,30 @@ class RoomProxy: RoomProxyProtocol { self.roomListItem = roomListItem self.room = room self.backgroundTaskService = backgroundTaskService + + let settings = RoomSubscription(requiredState: [RequiredState(key: "m.room.name", value: ""), + RequiredState(key: "m.room.topic", value: ""), + RequiredState(key: "m.room.avatar", value: ""), + RequiredState(key: "m.room.canonical_alias", value: ""), + RequiredState(key: "m.room.join_rules", value: "")], + timelineLimit: UInt32(SlidingSyncConstants.defaultTimelineLimit)) + roomListItem.subscribe(settings: settings) + + let timelineListener = RoomTimelineListener { [weak self] timelineDiff in + self?.updatesSubject.send(timelineDiff) + } + + self.timelineListener = timelineListener + + let result = room.addTimelineListener(listener: timelineListener) + roomTimelineObservationToken = result.itemsStream + + innerTimelineProvider = RoomTimelineProvider(currentItems: result.items, updatePublisher: updatesPublisher) + + Task { + await fetchMembers() + await updateMembers() + } } lazy var id: String = room.id() @@ -162,36 +191,6 @@ class RoomProxy: RoomProxyProtocol { } } - func registerTimelineListenerIfNeeded() -> Result<[TimelineItem], RoomProxyError> { - guard timelineListener == nil else { - return .failure(.roomListenerAlreadyRegistered) - } - - let settings = RoomSubscription(requiredState: [RequiredState(key: "m.room.name", value: ""), - RequiredState(key: "m.room.topic", value: ""), - RequiredState(key: "m.room.avatar", value: ""), - RequiredState(key: "m.room.canonical_alias", value: ""), - RequiredState(key: "m.room.join_rules", value: "")], - timelineLimit: UInt32(SlidingSyncConstants.defaultTimelineLimit)) - roomListItem.subscribe(settings: settings) - - let timelineListener = RoomTimelineListener { [weak self] timelineDiff in - self?.updatesSubject.send(timelineDiff) - } - - self.timelineListener = timelineListener - - let result = room.addTimelineListener(listener: timelineListener) - roomTimelineObservationToken = result.itemsStream - - Task { - await fetchMembers() - await updateMembers() - } - - return .success(result.items) - } - func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result { do { try await Task.dispatch(on: .global()) { @@ -219,6 +218,24 @@ class RoomProxy: RoomProxyProtocol { } } } + + func messageEventContent(for eventID: String) -> RoomMessageEventContent? { + try? room.getTimelineEventContentByEventId(eventId: eventID) + } + + func sendMessageEventContent(_ messageContent: RoomMessageEventContent) async -> Result { + sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) + defer { + sendMessageBackgroundTask?.stop() + } + + let transactionId = genTransactionId() + + return await Task.dispatch(on: userInitiatedDispatchQueue) { + self.room.send(msg: messageContent, txnId: transactionId) + return .success(()) + } + } func sendMessage(_ message: String, inReplyTo eventID: String? = nil) async -> Result { sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index fb2298719..639824812 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -20,7 +20,6 @@ import MatrixRustSDK enum RoomProxyError: Error { case noMoreMessagesToBackPaginate - case roomListenerAlreadyRegistered case failedPaginatingBackwards case failedRetrievingMemberAvatarURL case failedRetrievingMemberDisplayName @@ -73,22 +72,23 @@ protocol RoomProxyProtocol { var activeMembersCount: Int { get } /// Publishes the room's updates. - /// The publisher starts publishing after the first call to `registerTimelineListenerIfNeeded()` /// The thread on which this publisher sends the output isn't defined. var updatesPublisher: AnyPublisher { get } + + var timelineProvider: RoomTimelineProviderProtocol { get } func loadAvatarURLForUserId(_ userId: String) async -> Result func loadDisplayNameForUserId(_ userId: String) async -> Result - /// Registers a timeline listener if not registered already. - /// Updates for this object will be published on the `updatesPublisher` publisher. - func registerTimelineListenerIfNeeded() -> Result<[TimelineItem], RoomProxyError> - func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result func sendReadReceipt(for eventID: String) async -> Result + func messageEventContent(for eventID: String) -> RoomMessageEventContent? + + func sendMessageEventContent(_ messageContent: RoomMessageEventContent) async -> Result + func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result func sendReaction(_ reaction: String, to eventID: String) async -> Result diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index 15d726377..ad2a6d37c 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -19,7 +19,6 @@ import Foundation import MatrixRustSDK class RoomTimelineProvider: RoomTimelineProviderProtocol { - private let roomProxy: RoomProxyProtocol private var cancellables = Set() private let serialDispatchQueue: DispatchQueue @@ -35,27 +34,15 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { } } - init(roomProxy: RoomProxyProtocol) { - self.roomProxy = roomProxy + init(currentItems: [TimelineItem], updatePublisher: AnyPublisher) { serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) - itemProxies = [] + + itemProxies = currentItems.map(TimelineItemProxy.init) - roomProxy - .updatesPublisher + updatePublisher .collect(.byTime(serialDispatchQueue, 0.1)) .sink { [weak self] in self?.updateItemsWithDiffs($0) } .store(in: &cancellables) - - switch roomProxy.registerTimelineListenerIfNeeded() { - case let .success(items): - itemProxies = items.map(TimelineItemProxy.init) - MXLog.verbose("Added timeline listener, current items (\(items.count)) : \(items.map(\.debugIdentifier))") - case .failure(.roomListenerAlreadyRegistered): - MXLog.verbose("Listener already registered for the room: \(roomProxy.id)") - case .failure: - let roomID = roomProxy.id - MXLog.error("Failed adding timeline listener on room with identifier: \(roomID)") - } } // MARK: - Private diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 6b5a52ecb..ce972af9f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -68,7 +68,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func debugInfo(for itemID: String) -> TimelineItemDebugInfo { .init(model: "Mock debug description", originalJSON: nil, latestEditJSON: nil) } - + func retryDecryption(for sessionID: String) async { } // MARK: - UI Test signalling diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 243520c22..4da2c5f1d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -43,17 +43,16 @@ class RoomTimelineController: RoomTimelineControllerProtocol { init(userId: String, roomProxy: RoomProxyProtocol, - timelineProvider: RoomTimelineProviderProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) { self.userId = userId - self.timelineProvider = timelineProvider + self.roomProxy = roomProxy + timelineProvider = roomProxy.timelineProvider self.timelineItemFactory = timelineItemFactory self.mediaProvider = mediaProvider - self.roomProxy = roomProxy serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) - self.timelineProvider + timelineProvider .itemsPublisher .receive(on: serialDispatchQueue) .sink { [weak self] _ in diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index b40ade4e9..3d29ea90f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -23,7 +23,6 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol { RoomTimelineController(userId: userId, roomProxy: roomProxy, - timelineProvider: RoomTimelineProvider(roomProxy: roomProxy), timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) } diff --git a/UITests/Sources/MessageForwardingScreenUITests.swift b/UITests/Sources/MessageForwardingScreenUITests.swift new file mode 100644 index 000000000..3969712d9 --- /dev/null +++ b/UITests/Sources/MessageForwardingScreenUITests.swift @@ -0,0 +1,21 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import ElementX +import XCTest + +@MainActor +class MessageForwardingScreenUITests: XCTestCase { } diff --git a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift new file mode 100644 index 000000000..00222917a --- /dev/null +++ b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift @@ -0,0 +1,69 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import XCTest + +@testable import ElementX + +@MainActor +class MessageForwardingScreenViewModelTests: XCTestCase { + var viewModel: MessageForwardingScreenViewModelProtocol! + var context: MessageForwardingScreenViewModelType.Context! + var cancellables = Set() + + override func setUpWithError() throws { + viewModel = MessageForwardingScreenViewModel(roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)), sourceRoomID: "1") + context = viewModel.context + } + + func testInitialState() { + XCTAssertNil(context.viewState.rooms.first(where: { $0.id == "1" }), "The source room ID shouldn't be shown") + } + + func testRoomSelection() { + context.send(viewAction: .selectRoom(roomID: "2")) + XCTAssertEqual(context.viewState.selectedRoomID, "2") + } + + func testSearching() { + context.searchQuery = "Second" + XCTAssertEqual(context.viewState.visibleRooms.count, 1) + } + + func testForwarding() { + context.send(viewAction: .selectRoom(roomID: "2")) + XCTAssertEqual(context.viewState.selectedRoomID, "2") + + let expectation = expectation(description: "Wait for confirmation") + + viewModel.actions + .sink { action in + switch action { + case .send(let roomID): + XCTAssertEqual(roomID, "2") + expectation.fulfill() + default: + break + } + } + .store(in: &cancellables) + + context.send(viewAction: .send) + + waitForExpectations(timeout: 5.0) + } +} diff --git a/UnitTests/Sources/RoomDetailsViewModelTests.swift b/UnitTests/Sources/RoomDetailsViewModelTests.swift index 492bd810a..76cbc2c65 100644 --- a/UnitTests/Sources/RoomDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsViewModelTests.swift @@ -221,11 +221,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase { await Task.yield() XCTAssertTrue(callbackCorrectlyCalled) } - - func testRoomSubscription() async { - XCTAssertEqual(roomProxyMock.registerTimelineListenerIfNeededCallsCount, 1) - } - + func testCanEditAvatar() async { let owner: RoomMemberProxyMock = .mockOwner(allowedStateEvents: [.roomAvatar]) let mockedMembers: [RoomMemberProxyMock] = [owner, .mockBob, .mockAlice] diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 7bfc8ce81..b57748e72 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -38,7 +38,8 @@ class RoomFlowCoordinatorTests: XCTestCase { roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), navigationStackCoordinator: navigationStackCoordinator, navigationSplitCoordinator: navigationSplitCoordinator, - emojiProvider: EmojiProvider()) + emojiProvider: EmojiProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController) } func testRoomPresentation() async { diff --git a/changelog.d/978.feature b/changelog.d/978.feature new file mode 100644 index 000000000..aab2ce9e9 --- /dev/null +++ b/changelog.d/978.feature @@ -0,0 +1 @@ +Added support for message forwarding \ No newline at end of file