diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index caa252091..88688c60f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -74,12 +74,15 @@ 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05707BF550D770168A406DB /* LoginViewModelTests.swift */; }; 1F04C63D4FA95948E3F52147 /* FileRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51E3D86A84341C3A0CB8A40 /* FileRoomTimelineView.swift */; }; 1F3232BD368DF430AB433907 /* DesignKit in Frameworks */ = {isa = PBXBuildFile; productRef = A5A56C4F47C368EBE5C5E870 /* DesignKit */; }; + 1FD56B9EA4CA804120A2E743 /* ImageProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D28E3A5AE9934EE4FB4720 /* ImageProviderProtocol.swift */; }; 1FE593ECEC40A43789105D80 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; 1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */; }; 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; }; + 20840B4549FFF1301D0A5FF2 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A46F50F2A46103ADF143ADB /* MediaLoader.swift */; }; 214C6B416609E58CCBF6DCEE /* SoftLogoutModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC77FC5C4F2000133047AA27 /* SoftLogoutModels.swift */; }; 214CDBF0C783155242FFE4A0 /* NotificationItemProxy+NSE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1FBF8CA40199B8058B1F08 /* NotificationItemProxy+NSE.swift */; }; 2276870A19F34B3FFFDA690F /* SoftLogoutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AEA20A6B4883E60469ACF8F /* SoftLogoutCoordinator.swift */; }; + 233221E32DA045018D3D3050 /* SettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF540C393C7DDEE9C902DFF /* SettingsScreenCoordinator.swift */; }; 2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */; }; 237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; }; 23B2CD5A06B16055BDDD0994 /* ApplicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44D8C8431416EB8DFEC7E235 /* ApplicationTests.swift */; }; @@ -100,7 +103,6 @@ 2BA59D0AEFB4B82A2EC2A326 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 78A5A8DE1E2B09C978C7F3B0 /* KeychainAccess */; }; 2BAA5B222856068158D0B3C6 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = B1E8B697DF78FE7F61FC6CA4 /* MatrixRustSDK */; }; 2C5E832434EE94E21AB3B238 /* EmojiPickerScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3EAE3E9D5EF4A6D5D9C6CFD /* EmojiPickerScreenViewModel.swift */; }; - 2CA8AD07773A38BA4662098B /* MediaProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3D31C2DA6910AA0079678A /* MediaProxyProtocol.swift */; }; 2CB6787E25B11711518E9588 /* OnboardingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6281B199D8A8F0892490C2E /* OnboardingCoordinator.swift */; }; 2D794361CFE790C8FB3C9C0F /* message.caf in Resources */ = {isa = PBXBuildFile; fileRef = ED482057AE39D5C6D9C5F3D8 /* message.caf */; }; 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; }; @@ -109,12 +111,10 @@ 2F94054F50E312AF30BE07F3 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B21E611DADDEF00307E7AC /* String.swift */; }; 308BD9343B95657FAA583FB7 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = AD2AC190E55B2BD4D0F1D4A7 /* SwiftyBeaver */; }; 3097A0A867D2B19CE32DAE58 /* UIKitBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1FFC3336EB23374BBBFCC /* UIKitBackgroundTaskService.swift */; }; - 323F36D880363C473B81A9EA /* MediaProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3D31C2DA6910AA0079678A /* MediaProxyProtocol.swift */; }; 3274219F7F26A5C6C2C55630 /* FilePreviewViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */; }; 33CAC1226DFB8B5D8447D286 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; 33D630461FC4562CC767EE9F /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B0B1226DA8DB55918B34CD /* FileCache.swift */; }; 340D39DB87F3800D53A6A621 /* EmojiPickerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */; }; - 34966D4C1C2C6D37FE3F7F50 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */; }; 34C752A73717C691582DC6C7 /* UnsupportedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B8500C152BC59445647DA8 /* UnsupportedRoomTimelineItem.swift */; }; 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */; }; 3588F34D05B4D731A73214C6 /* BugReportScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED59F9EFF273BFA2055FFDF /* BugReportScreen.swift */; }; @@ -129,7 +129,6 @@ 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; }; 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; }; 3A64A93A651A3CB8774ADE8E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; - 3B770CB4DED51CC362C66D47 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */; }; 3C549A0BF39F8A854D45D9FD /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; 3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; }; 3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DC38E64A5ED3FDB201029A /* BugReportService.swift */; }; @@ -142,18 +141,16 @@ 41DFDD212D1BE57CA50D783B /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = FD43A50D9B75C9D6D30F006B /* SwiftyBeaver */; }; 4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */; }; 42F1C8731166633E35A6D7E6 /* RoomEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A307A44F952CD73E63AE31 /* RoomEventStringBuilder.swift */; }; - 438FB9BC535BC95948AA5F34 /* SettingsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2F9D5C39A4494D19F33E38 /* SettingsViewModelProtocol.swift */; }; 43BD17BC8794BB9B04F2A26B /* MediaSourceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179423E34EE846E048E49CBF /* MediaSourceProxy.swift */; }; 43FD77998F33C32718C51450 /* TemplateCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD460ED7ED1E03B85DEA25C /* TemplateCoordinator.swift */; }; 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */; }; 447E8580A0A2569E32529E17 /* MockRoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */; }; 44AE0752E001D1D10605CD88 /* Swipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FDA5344F7C4C6E4E863E13 /* Swipe.swift */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; - 490E606044B18985055FF690 /* SettingsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E29F98CF0E960689A410E3 /* SettingsUITests.swift */; }; 492274DA6691EE985C2FCCAA /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 1BCD21310B997A6837B854D6 /* GZIP */; }; 49E9B99CB6A275C7744351F0 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D58333B377888012740101 /* LoginViewModel.swift */; }; 49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; }; - 4A2E0DBB63919AC8309B6D40 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */; }; + 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */; }; 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */; }; 4C3365818DE1CEAEDF590FD3 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; }; 4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */; }; @@ -163,6 +160,7 @@ 4FF90E2242DBD596E1ED2E27 /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */; }; 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 874A1842477895F199567BD7 /* TimelineView.swift */; }; 501304F26B52DF7024011B6C /* EmojiMartJSONLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */; }; + 50C59870BEB1F29C60252FD4 /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F72B9701D847C591ABE1A8 /* SettingsScreenViewModelProtocol.swift */; }; 50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 518C93DC6516D3D018DE065F /* UNNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */; }; 51DB67C5B5BC68B0A6FF54D4 /* MockRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBDC1D28EFB7789EB467E0 /* MockRoomProxy.swift */; }; @@ -175,6 +173,7 @@ 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */; }; 588411C8FD72B2A2DFE5F7DE /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E992D7B8BE54B2AB454613AF /* XCUIElement.swift */; }; 59F940FCBE6BC343AECEF75E /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2245243369B99216C7D84E /* ImageCache.swift */; }; + 5B300BACD8A1B252AC95FA34 /* MediaLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3DF4FAB9FBEF782DF08F3A /* MediaLoaderProtocol.swift */; }; 5B8B51CEC4717AF487794685 /* NotificationServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B490675B8E31423AF116BDA /* NotificationServiceProxy.swift */; }; 5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C830A64609CBD152F06E0457 /* NotificationConstants.swift */; }; 5C8AFBF168A41E20835F3B86 /* LoginScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */; }; @@ -215,7 +214,9 @@ 6C9F6C7F2B35288C4230EF3F /* FilePreviewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA4B03F92F31EAA83B3F7B /* FilePreviewModels.swift */; }; 6CA81428F0970785CDCC5E86 /* UserNotificationToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A4E5941ACBA4BB9FEF94C /* UserNotificationToastView.swift */; }; 6D046D653DA28ADF1E6E59A4 /* BackgroundTaskServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE73D571D4F9C36DD45255A /* BackgroundTaskServiceProtocol.swift */; }; + 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */; }; 6E6E0AAF6C44C0B117EBBE5A /* SlidingSyncViewProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F3B445BD6EF1C751806B22 /* SlidingSyncViewProxy.swift */; }; + 6EC7A40A537CFB3D526A111C /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47EBB5D698CE9A25BB553A2D /* Strings.swift */; }; 6F2AB43A1EFAD8A97AF41A15 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = A7CA6F33C553805035C3B114 /* DeviceKit */; }; 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; }; 6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */; }; @@ -224,8 +225,10 @@ 70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; }; 706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */; }; + 709B1C6026A3056662FF93EE /* MediaLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3DF4FAB9FBEF782DF08F3A /* MediaLoaderProtocol.swift */; }; 719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; }; 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; + 7313970ED46B213AF4CFB4B3 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A46F50F2A46103ADF143ADB /* MediaLoader.swift */; }; 7354D094A4C59B555F407FA1 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 7361B011A79BF723D8C9782B /* EmojiCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; @@ -249,7 +252,6 @@ 7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */; }; 7C1A7B594B2F8143F0DD0005 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; }; 7CD16990BA843BE9ED639129 /* ImageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */; }; - 7D1DAAA364A9A29D554BD24E /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0950733DD4BA83EEE752E259 /* PlaceholderAvatarImage.swift */; }; 7E3B1F8D72573ED2FCB2D94B /* NotificationServiceProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD5FEE195446A9E458DDDAF /* NotificationServiceProxyProtocol.swift */; }; 7E3C34BC10936AD4F77975F4 /* EmojiMartJSONLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39001365B76B89983FDB7AD8 /* EmojiMartJSONLoader.swift */; }; 7E7DF1867F98B0D10A6C0A63 /* FileCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3648F2FADEF2672D6A0D489 /* FileCacheTests.swift */; }; @@ -301,6 +303,7 @@ 968A5B890004526AB58A217C /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; }; 96FE26ABD4E5B8B6EF0EF596 /* RoomMemberDetailsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CEFB5144EF8F50C77CF6E14 /* RoomMemberDetailsCoordinator.swift */; }; 97189E495F0E47805D1868DB /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 527578916BD388A09F5A8036 /* DTCoreText */; }; + 973B5350B5C72E3EB1E62E67 /* ImageProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D28E3A5AE9934EE4FB4720 /* ImageProviderProtocol.swift */; }; 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */; }; 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */; }; 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */; }; @@ -315,8 +318,10 @@ 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */; }; 9BE7A9CF6C593251D734B461 /* MockServerSelectionScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A20AE75FF4FF35B1FF6CA7 /* MockServerSelectionScreenState.swift */; }; 9C45CE85325CD591DADBC4CA /* ElementXTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */; }; + 9C5A07E7C33F3F40287D7861 /* SettingsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */; }; 9CCC77C31CB399661A034739 /* UserProperties+Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A6C4BE591FE5C38CE9C7EF3 /* UserProperties+Element.swift */; }; 9D2E03DB175A6AB14589076D /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA4E1BEB4E9BC2467006E12B /* AppAuth */; }; + 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */; }; 9D9690D2FD4CD26FF670620F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75EF87651B00A176AB08E97 /* AppDelegate.swift */; }; 9DC5FB22B8F86C3B51E907C1 /* HomeScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E4C37E9F0E53D3DF951AC /* HomeScreenUITests.swift */; }; 9E8AE387FD03E4F1C1B8815A /* SessionVerificationStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06FCD42EEFEFC220F14EAC5 /* SessionVerificationStateMachine.swift */; }; @@ -339,11 +344,13 @@ A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; }; A8771F5975A82759FA5138AE /* RoomMemberDetailsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F19DBE940499D3E3DD405D8 /* RoomMemberDetailsScreenUITests.swift */; }; + A896998A6784DB6F16E912F4 /* MockMediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */; }; A8EC7C9D886244DAE9433E37 /* SessionVerificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C18FAAD59AE7F1462D817E /* SessionVerificationViewModel.swift */; }; A9D23B78F42BCDD896531436 /* UserNotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649759084B0C9FE1F8DF8D17 /* UserNotificationPresenter.swift */; }; AA050DF4AEE54A641BA7CA22 /* RoomSummaryProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */; }; AAF0BBED840DF4A53EE85E77 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = C2C69B8BA5A9702E7A8BC08F /* MatrixRustSDK */; }; AB34401E4E1CAD5D2EC3072B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9760103CF316DF68698BCFE6 /* LaunchScreen.storyboard */; }; + ABDC81BD1C3C8B62665F2C72 /* MockMediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6920A4869821BF72FFC58842 /* MockMediaProvider.swift */; }; ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */; }; AC5CC8250CEAE57B73900C57 /* UserNotificationModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD80F22830C2360F3F39DDCE /* UserNotificationModalView.swift */; }; AC69B6DF15FC451AB2945036 /* UserSessionStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */; }; @@ -427,6 +434,8 @@ D85D4FA590305180B4A41795 /* Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3073CCD77D906B330BC1D6 /* Tests.swift */; }; D876EC0FED3B6D46C806912A /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; }; D8CFF02C2730EE5BC4F17ABF /* ElementToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */; }; + D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352359663A0E52BA20761EE /* LoadableImage.swift */; }; + DBAA69CC2CE4D44BC8E20105 /* SettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 548E7D356609ACD33AE7643E /* SettingsScreenModels.swift */; }; DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */; }; DD9B70DE54B24E0694A35D8A /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; DE4F8C4E0F1DB4832F09DE97 /* HomeScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31D6764D6976D235926FE5FC /* HomeScreenViewModel.swift */; }; @@ -449,7 +458,6 @@ E89536FC8C0E4B79E9842A78 /* RoomTimelineControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0197EAE9D45A662B8847B6 /* RoomTimelineControllerProtocol.swift */; }; E8AB8D16E6D8E8E501F29BD9 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B0B1226DA8DB55918B34CD /* FileCache.swift */; }; E96005321849DBD7C72A28F2 /* UITestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */; }; - E9631F628251F77A24AA4BB4 /* MockMediaProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CFF3E430B27B7C3AD0A28 /* MockMediaProxy.swift */; }; EA1E7949533E19C6D862680A /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885D8C42DD17625B5261BEFF /* MediaProvider.swift */; }; EA31DD9043B91ECB8E45A9A6 /* ScreenshotDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03C9D319676F3C0DC6B0203 /* ScreenshotDetectorTests.swift */; }; EA65360A0EC026DD83AC0CF5 /* AuthenticationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA5F386C7701C129398945 /* AuthenticationCoordinator.swift */; }; @@ -457,16 +465,19 @@ EBE13FAB4E29738AC41BD3E5 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; EC280623A42904341363EAAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 886A0A498FA01E8EDD451D05 /* Sentry */; }; EC4C31963E755EEC77BD778C /* AnalyticsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B362E695A7103C11F64B185 /* AnalyticsSettings.swift */; }; + ECA636DAF071C611FDC2BB57 /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; EE4F5601356228FF72FC56B6 /* MockClientProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40F48279322E504153AB0D /* MockClientProxy.swift */; }; EE6933C935080B4E0348A58B /* EmojiMartCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C3AACCAA82392D08924496 /* EmojiMartCategory.swift */; }; EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CF8C196BE143AE226628D /* DecorationTimelineItemProtocol.swift */; }; EE8A37E2A1A77DE5CF941632 /* StateRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED983D4DCA5AFA6E1ED96099 /* StateRoomTimelineView.swift */; }; EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */; }; + EEC499F9AC7DD6D18760F81D /* SettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E3CEDE000EFF6E988BEFDE /* SettingsScreenViewModel.swift */; }; EF7924005216B8189898F370 /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */; }; F040ABFEB0A2B142D948BA12 /* Untranslated.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = F75DF9500D69A3AAF8339E69 /* Untranslated.stringsdict */; }; F06CE9132855E81EBB6DDC32 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 800631D7250B7F93195035F1 /* KeychainAccess */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; F0F82C3C848C865C3098AA52 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; }; + F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; F257F964493A9CD02A6F720C /* OnboardingPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2717AB91060260E5F4781 /* OnboardingPageView.swift */; }; F32B271F60531BE92C6E62A1 /* StickerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612EF972F2A1800682D32C5E /* StickerRoomTimelineView.swift */; }; F425C3F85BFF28C9AC593F52 /* MockNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96561CC53F7C1E24D4C292E4 /* MockNotificationManager.swift */; }; @@ -475,12 +486,10 @@ F656F92A63D3DC1978D79427 /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3F7BCCB18C15B30CCA39A9 /* AnalyticsEvents */; }; F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */; }; F7567DD6635434E8C563BF85 /* AnalyticsClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */; }; - F9981191DC408AED537C1749 /* MediaProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */; }; F99FB21EFC6D99D247FE7CBE /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DE8DC9B3FBA402117DC4C49F /* Kingfisher */; }; F9F6D2883BBEBB9A3789A137 /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */; }; FA2BBAE9FC5E2E9F960C0980 /* NavigationCoordinators.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */; }; FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */; }; - FBCD77D557AACBE9B445133A /* MediaProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */; }; FBF09B6C900415800DDF2A21 /* EmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C113E0CB7E15E9765B1817A /* EmojiProvider.swift */; }; FC10228E73323BDC09526F97 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9C73F37731C9FDED1BB24C1C /* Collections */; }; FCD3F2B82CAB29A07887A127 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; }; @@ -549,11 +558,9 @@ 086B997409328F091EBA43CE /* RoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenUITests.swift; sourceTree = ""; }; 08F64963396A6A23538EFCEC /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = is; path = is.lproj/Localizable.stringsdict; sourceTree = ""; }; 09199C43BAB209C0BD89A836 /* OnboardingPageIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPageIndicator.swift; sourceTree = ""; }; - 0950733DD4BA83EEE752E259 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = ""; }; 095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderProtocol.swift; sourceTree = ""; }; 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToggleStyle.swift; sourceTree = ""; }; 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; - 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 0AB7A0C06CB527A1095DEB33 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = da; path = da.lproj/Localizable.stringsdict; sourceTree = ""; }; 0B490675B8E31423AF116BDA /* NotificationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceProxy.swift; sourceTree = ""; }; 0B869438A1B52836F912A702 /* MockSoftLogoutScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftLogoutScreenState.swift; sourceTree = ""; }; @@ -624,6 +631,7 @@ 287FC98AF2664EAD79C0D902 /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 28959C7DB36C7688A01D4045 /* BugReportViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModelProtocol.swift; sourceTree = ""; }; 28EA8BE9EEDBD17555141C7E /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = el; path = el.lproj/Localizable.stringsdict; sourceTree = ""; }; + 2A46F50F2A46103ADF143ADB /* MediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoader.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; 2AE83A3DD63BCFBB956FE5CB /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = ""; }; 2AEA20A6B4883E60469ACF8F /* SoftLogoutCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutCoordinator.swift; sourceTree = ""; }; @@ -669,10 +677,10 @@ 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelTests.swift; sourceTree = ""; }; 3D4DD336905C72F95EAF34B7 /* ElementX-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ElementX-Bridging-Header.h"; sourceTree = ""; }; 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingTests.swift; sourceTree = ""; }; - 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = ""; }; 3DD6E7C1D8B53F47789778CD /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; 3DF1FFC3336EB23374BBBFCC /* UIKitBackgroundTaskService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTaskService.swift; sourceTree = ""; }; 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineItem.swift; sourceTree = ""; }; + 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoaderTests.swift; sourceTree = ""; }; 3F40F48279322E504153AB0D /* MockClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockClientProxy.swift; sourceTree = ""; }; 3F9E67AAB66638C69626866C /* UserSessionFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinator.swift; sourceTree = ""; }; 3FEE631F3A4AFDC6652DD9DA /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = ""; }; @@ -699,10 +707,10 @@ 48CE6BF18E542B32FA52CE06 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fa; path = fa.lproj/Localizable.stringsdict; sourceTree = ""; }; 49193CB0C248D621A96FB2AA /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; 4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; - 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; 49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = ""; }; 4A57A4AFA6A068668AFBD070 /* UIActivityViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityViewControllerWrapper.swift; sourceTree = ""; }; + 4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaLoader.swift; sourceTree = ""; }; 4B362E695A7103C11F64B185 /* AnalyticsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettings.swift; sourceTree = ""; }; 4B40B7F6FCCE2D8C242492D9 /* ga */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ga; path = ga.lproj/Localizable.strings; sourceTree = ""; }; 4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = ""; }; @@ -716,6 +724,7 @@ 4F5F0662483ED69791D63B16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = et; path = et.lproj/Localizable.stringsdict; sourceTree = ""; }; 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = ""; }; 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineItem.swift; sourceTree = ""; }; + 50E3CEDE000EFF6E988BEFDE /* SettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModel.swift; sourceTree = ""; }; 51DF91C374901E94D93276F1 /* es-MX */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "es-MX"; path = "es-MX.lproj/Localizable.stringsdict"; sourceTree = ""; }; 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = ""; }; 529513218340CC8419273165 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; @@ -728,6 +737,7 @@ 541542F5AC323709D8563458 /* AnalyticsPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPrompt.swift; sourceTree = ""; }; 542D4F49FABA056DEEEB3400 /* RustTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustTracing.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 548E7D356609ACD33AE7643E /* SettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenModels.swift; sourceTree = ""; }; 55AEEF8142DF1B59DB40FB93 /* TimelineItemSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemSender.swift; sourceTree = ""; }; 55BC11560C8A2598964FFA4C /* bs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bs; path = bs.lproj/Localizable.strings; sourceTree = ""; }; 55D7187F6B0C0A651AC3DFFA /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = in; path = in.lproj/Localizable.strings; sourceTree = ""; }; @@ -738,7 +748,6 @@ 5773C86AF04AEF26515AD00C /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 57CF0E6DD78FB3F6CBF5AC38 /* RoomMemberDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsViewModel.swift; sourceTree = ""; }; 57F95CADD0A5DBD76B990FCB /* ServiceLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLocator.swift; sourceTree = ""; }; - 5B2F9D5C39A4494D19F33E38 /* SettingsViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelProtocol.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinatorUITests.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; @@ -774,6 +783,7 @@ 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModel.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 = ""; }; + 6FF540C393C7DDEE9C902DFF /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 71556206CD5E8B1F53F07178 /* MockRoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineControllerFactory.swift; sourceTree = ""; }; 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenModels.swift; sourceTree = ""; }; 71D52BAA5BADB06E5E8C295D /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; @@ -814,6 +824,7 @@ 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = ""; }; 8888D13645C04AC9818F5778 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; + 8A3DF4FAB9FBEF782DF08F3A /* MediaLoaderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoaderProtocol.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -822,6 +833,7 @@ 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC26871038FB0E4AAE22605 /* apple_emojis_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = apple_emojis_data.json; sourceTree = ""; }; @@ -918,6 +930,7 @@ B4C18FAAD59AE7F1462D817E /* SessionVerificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationViewModel.swift; sourceTree = ""; }; B4DE1CF8F5EFD353B1A5E36F /* AnalyticsPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptCoordinator.swift; sourceTree = ""; }; B516212D9FE785DDD5E490D1 /* BugReportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportModels.swift; sourceTree = ""; }; + B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = ""; }; B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = ""; }; B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = ""; }; B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; @@ -944,6 +957,7 @@ C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; C1198B925F4A88DA74083662 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = ""; }; + C352359663A0E52BA20761EE /* LoadableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableImage.swift; sourceTree = ""; }; C38AE3617D7619EF30CDD229 /* EmojiMartStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartStore.swift; sourceTree = ""; }; C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewViewModelProtocol.swift; sourceTree = ""; }; C483956FA3D665E3842E319A /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; @@ -951,6 +965,7 @@ C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = ""; }; C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = ""; }; C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportUITests.swift; sourceTree = ""; }; + C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = ""; }; C75EF87651B00A176AB08E97 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C789E7BFC066CF39B8AE0974 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; C830A64609CBD152F06E0457 /* NotificationConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationConstants.swift; sourceTree = ""; }; @@ -959,6 +974,7 @@ C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = ""; }; C95ADE8D9527523572532219 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hu; path = hu.lproj/Localizable.stringsdict; sourceTree = ""; }; C9A86C95340248A8B7BA9A43 /* AnalyticsPromptViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptViewModelProtocol.swift; sourceTree = ""; }; + C9F72B9701D847C591ABE1A8 /* SettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelProtocol.swift; sourceTree = ""; }; CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = ""; }; CA78F8D91974DFFEDC05485A /* RoomMemberDetailsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsModels.swift; sourceTree = ""; }; CA89A2DD51B6BBE1DA55E263 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; @@ -985,6 +1001,7 @@ D3D455BC2423D911A62ACFB2 /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; D5AC06FC11B6638F7BF1670E /* TimelineDeliveryStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineDeliveryStatusView.swift; sourceTree = ""; }; + D5D28E3A5AE9934EE4FB4720 /* ImageProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProviderProtocol.swift; sourceTree = ""; }; D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenCoordinator.swift; sourceTree = ""; }; D67CBAFA48ED0B6FCE74F88F /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/Localizable.strings; sourceTree = ""; }; D6CA5F386C7701C129398945 /* AuthenticationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinator.swift; sourceTree = ""; }; @@ -1006,15 +1023,12 @@ DF38B69D2C331A499276F400 /* FilePreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewViewModelTests.swift; sourceTree = ""; }; DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineItem.swift; sourceTree = ""; }; E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = ""; }; - E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProxy.swift; sourceTree = ""; }; E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = ""; }; E18CF12478983A5EB390FB26 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarSize.swift; sourceTree = ""; }; E26747B3154A5DBC3A7E24A5 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; - E36CFF3E430B27B7C3AD0A28 /* MockMediaProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProxy.swift; sourceTree = ""; }; E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientProtocol.swift; sourceTree = ""; }; - E3E29F98CF0E960689A410E3 /* SettingsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUITests.swift; sourceTree = ""; }; E45C57120F28F8D619150219 /* sr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sr; path = sr.lproj/Localizable.strings; sourceTree = ""; }; E4BB9A17AC512A7EF4B106E5 /* SessionVerificationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationModels.swift; sourceTree = ""; }; E51E3D86A84341C3A0CB8A40 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = ""; }; @@ -1070,7 +1084,6 @@ F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = ""; }; FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = ""; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = ""; }; - FC3D31C2DA6910AA0079678A /* MediaProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProxyProtocol.swift; sourceTree = ""; }; FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1307,6 +1320,9 @@ isa = PBXGroup; children = ( 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */, + B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */, + C352359663A0E52BA20761EE /* LoadableImage.swift */, + C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */, 839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */, ); path = Views; @@ -1657,10 +1673,10 @@ 70B74A432C241E56A7ACE610 /* Settings */ = { isa = PBXGroup; children = ( - 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */, - 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */, - 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */, - 5B2F9D5C39A4494D19F33E38 /* SettingsViewModelProtocol.swift */, + 6FF540C393C7DDEE9C902DFF /* SettingsScreenCoordinator.swift */, + 548E7D356609ACD33AE7643E /* SettingsScreenModels.swift */, + 50E3CEDE000EFF6E988BEFDE /* SettingsScreenViewModel.swift */, + C9F72B9701D847C591ABE1A8 /* SettingsScreenViewModelProtocol.swift */, 4541090DFE1A5499BD67BD14 /* View */, ); path = Settings; @@ -1735,11 +1751,12 @@ 7583EAC171059A86B767209F /* MediaProvider */ = { isa = PBXGroup; children = ( + 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */, 62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */, 3948D16F021DFDB2CD26EAA8 /* MockBackgroundTaskService.swift */, AEC96B3DC55090BBF8876CC2 /* MockFileCache.swift */, AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */, - E36CFF3E430B27B7C3AD0A28 /* MockMediaProxy.swift */, + 4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */, ); path = MediaProvider; sourceTree = ""; @@ -1808,10 +1825,11 @@ 79E560F5113ED25D172E550C /* Media */ = { isa = PBXGroup; children = ( + D5D28E3A5AE9934EE4FB4720 /* ImageProviderProtocol.swift */, + 2A46F50F2A46103ADF143ADB /* MediaLoader.swift */, + 8A3DF4FAB9FBEF782DF08F3A /* MediaLoaderProtocol.swift */, 885D8C42DD17625B5261BEFF /* MediaProvider.swift */, C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */, - E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */, - FC3D31C2DA6910AA0079678A /* MediaProxyProtocol.swift */, 179423E34EE846E048E49CBF /* MediaSourceProxy.swift */, 6920A4869821BF72FFC58842 /* MockMediaProvider.swift */, ); @@ -1991,7 +2009,7 @@ 086B997409328F091EBA43CE /* RoomScreenUITests.swift */, 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */, 6D4777F0142E330A75C46FE4 /* SessionVerificationUITests.swift */, - E3E29F98CF0E960689A410E3 /* SettingsUITests.swift */, + 8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */, 55F30E764BED111C81739844 /* SoftLogoutUITests.swift */, F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */, ); @@ -2183,7 +2201,6 @@ D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */, B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */, 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */, - 0950733DD4BA83EEE752E259 /* PlaceholderAvatarImage.swift */, B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */, C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */, 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */, @@ -2875,6 +2892,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */, 968A5B890004526AB58A217C /* AvatarSize.swift in Sources */, EF7924005216B8189898F370 /* BackgroundTaskProtocol.swift in Sources */, 1B4B3E847BF944DB2C1C217F /* BackgroundTaskServiceProtocol.swift in Sources */, @@ -2884,16 +2902,18 @@ E8AB8D16E6D8E8E501F29BD9 /* FileCache.swift in Sources */, A33784831AD880A670CAA9F9 /* FileManager.swift in Sources */, 59F940FCBE6BC343AECEF75E /* ImageCache.swift in Sources */, + 1FD56B9EA4CA804120A2E743 /* ImageProviderProtocol.swift in Sources */, EBE13FAB4E29738AC41BD3E5 /* InfoPlistReader.swift in Sources */, 8691186F9B99BCDDB7CACDD8 /* KeychainController.swift in Sources */, A440D4BC02088482EC633A88 /* KeychainControllerProtocol.swift in Sources */, AD2A81B65A9F6163012086F1 /* MXLog.swift in Sources */, 8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */, + 20840B4549FFF1301D0A5FF2 /* MediaLoader.swift in Sources */, + 5B300BACD8A1B252AC95FA34 /* MediaLoaderProtocol.swift in Sources */, 0F3F2FDD4021A25A0D57F801 /* MediaProvider.swift in Sources */, 4C3365818DE1CEAEDF590FD3 /* MediaProviderProtocol.swift in Sources */, - F9981191DC408AED537C1749 /* MediaProxy.swift in Sources */, - 2CA8AD07773A38BA4662098B /* MediaProxyProtocol.swift in Sources */, 5E25568E1CDAD983517E58B5 /* MediaSourceProxy.swift in Sources */, + ABDC81BD1C3C8B62665F2C72 /* MockMediaProvider.swift in Sources */, 5455147CAC63F71E48F7D699 /* NSELogger.swift in Sources */, 5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */, 214CDBF0C783155242FFE4A0 /* NotificationItemProxy+NSE.swift in Sources */, @@ -2903,6 +2923,8 @@ 7E3B1F8D72573ED2FCB2D94B /* NotificationServiceProxyProtocol.swift in Sources */, 414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */, 7354D094A4C59B555F407FA1 /* RustTracing.swift in Sources */, + ECA636DAF071C611FDC2BB57 /* Strings+Untranslated.swift in Sources */, + 6EC7A40A537CFB3D526A111C /* Strings.swift in Sources */, 719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */, BA074E9812F96FFA3200ED1D /* TimelineItemProxy.swift in Sources */, B8C316C6CA24512DFE9A27FD /* TimelineItemSender.swift in Sources */, @@ -2934,12 +2956,13 @@ 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */, 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */, 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */, + 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */, 167D00CAA13FAFB822298021 /* MediaProviderTests.swift in Sources */, 4E8F17EBA24FBBA6ABB62ECB /* MockBackgroundTaskService.swift in Sources */, 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */, CF6319CC05F964B4D05BF614 /* MockFileCache.swift in Sources */, DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */, - E9631F628251F77A24AA4BB4 /* MockMediaProxy.swift in Sources */, + A896998A6784DB6F16E912F4 /* MockMediaLoader.swift in Sources */, 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */, 69C7B956B74BEC3DB88224EA /* NavigationSplitCoordinatorTests.swift in Sources */, 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */, @@ -3065,6 +3088,7 @@ 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */, 03D684A3AE85A23B3DA3B43F /* Image.swift in Sources */, BA31448FBD9697F8CB9A83CD /* ImageCache.swift in Sources */, + 973B5350B5C72E3EB1E62E67 /* ImageProviderProtocol.swift in Sources */, 7CD16990BA843BE9ED639129 /* ImageRoomTimelineItem.swift in Sources */, D5EA4C6C80579279770D5804 /* ImageRoomTimelineView.swift in Sources */, B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */, @@ -3073,6 +3097,8 @@ 1FE593ECEC40A43789105D80 /* KeychainController.swift in Sources */, CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */, 15D867E638BFD0E5E71DB1EF /* List.swift in Sources */, + 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */, + D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */, 83E5054739949181CA981193 /* LoginCoordinator.swift in Sources */, 872A6457DF573AF8CEAE927A /* LoginHomeserver.swift in Sources */, CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */, @@ -3084,10 +3110,10 @@ B94368839BDB69172E28E245 /* MXLog.swift in Sources */, B66757D0254843162595B25D /* MXLogger.swift in Sources */, 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */, + 7313970ED46B213AF4CFB4B3 /* MediaLoader.swift in Sources */, + 709B1C6026A3056662FF93EE /* MediaLoaderProtocol.swift in Sources */, EA1E7949533E19C6D862680A /* MediaProvider.swift in Sources */, 7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */, - FBCD77D557AACBE9B445133A /* MediaProxy.swift in Sources */, - 323F36D880363C473B81A9EA /* MediaProxyProtocol.swift in Sources */, 43BD17BC8794BB9B04F2A26B /* MediaSourceProxy.swift in Sources */, 24906A1E82D0046655958536 /* MessageComposer.swift in Sources */, 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */, @@ -3132,7 +3158,7 @@ 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */, 764AFCC225B044CF5F9B41E5 /* PaginationIndicatorRoomTimelineView.swift in Sources */, 80D00A7C62AAB44F54725C43 /* PermalinkBuilder.swift in Sources */, - 7D1DAAA364A9A29D554BD24E /* PlaceholderAvatarImage.swift in Sources */, + 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */, DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */, 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */, 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */, @@ -3199,11 +3225,11 @@ 9E8AE387FD03E4F1C1B8815A /* SessionVerificationStateMachine.swift in Sources */, A8EC7C9D886244DAE9433E37 /* SessionVerificationViewModel.swift in Sources */, D6417E5A799C3C7F14F9EC0A /* SessionVerificationViewModelProtocol.swift in Sources */, - 34966D4C1C2C6D37FE3F7F50 /* SettingsCoordinator.swift in Sources */, - 3B770CB4DED51CC362C66D47 /* SettingsModels.swift in Sources */, 7FED310F6AB7A70CBFB7C8A3 /* SettingsScreen.swift in Sources */, - 4A2E0DBB63919AC8309B6D40 /* SettingsViewModel.swift in Sources */, - 438FB9BC535BC95948AA5F34 /* SettingsViewModelProtocol.swift in Sources */, + 233221E32DA045018D3D3050 /* SettingsScreenCoordinator.swift in Sources */, + DBAA69CC2CE4D44BC8E20105 /* SettingsScreenModels.swift in Sources */, + EEC499F9AC7DD6D18760F81D /* SettingsScreenViewModel.swift in Sources */, + 50C59870BEB1F29C60252FD4 /* SettingsScreenViewModelProtocol.swift in Sources */, 6E6E0AAF6C44C0B117EBBE5A /* SlidingSyncViewProxy.swift in Sources */, 2276870A19F34B3FFFDA690F /* SoftLogoutCoordinator.swift in Sources */, 214C6B416609E58CCBF6DCEE /* SoftLogoutModels.swift in Sources */, @@ -3294,7 +3320,7 @@ 2F1CF90A3460C153154427F0 /* RoomScreenUITests.swift in Sources */, 77FACC29F98FE2E65BBB6A5F /* ServerSelectionUITests.swift in Sources */, 05EC896A4B9AF4A56670C0BB /* SessionVerificationUITests.swift in Sources */, - 490E606044B18985055FF690 /* SettingsUITests.swift in Sources */, + 9C5A07E7C33F3F40287D7861 /* SettingsScreenUITests.swift in Sources */, B064D42BA087649ACAE462E8 /* SoftLogoutUITests.swift in Sources */, DD9B70DE54B24E0694A35D8A /* Strings+Untranslated.swift in Sources */, B3357B00F1AA930E54F76609 /* Strings.swift in Sources */, diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d371686e3..b14bec7f2 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-rust-components-swift", "state" : { - "revision" : "2d702d0d52805e4f81924507b39ad81c8a74a63f", - "version" : "1.0.33-alpha" + "revision" : "a119a8f16bbe3adc34cfdf2e092886d44e01705a", + "version" : "1.0.32-alpha" } }, { diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index b231eeabe..cd44cce8d 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -25,11 +25,7 @@ class AppCoordinator: AppCoordinatorProtocol { /// Common background task to resume long-running tasks in the background. /// When this task expiring, we'll try to suspend the state machine by `suspend` event. private var backgroundTask: BackgroundTaskProtocol? - private var isSuspended = false { - didSet { - MXLog.info("didSet to: \(isSuspended)") - } - } + private var isSuspended = false private var userSession: UserSessionProtocol! { didSet { diff --git a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift index c6668c571..7b7f561e7 100644 --- a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift +++ b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift @@ -36,6 +36,10 @@ class ViewModelContext: ObservableObject { /// Get-able/Observable `Published` property for the `ViewState` @Published fileprivate(set) var viewState: ViewState + + /// An optional image loading service so that views can manage themselves + /// Intentionally non-generic so that it doesn't grow uncontrollably + let imageProvider: ImageProviderProtocol? /// Set-able/Bindable access to the bindable state. subscript(dynamicMember keyPath: WritableKeyPath) -> T { @@ -43,9 +47,10 @@ class ViewModelContext: ObservableObject { set { viewState.bindings[keyPath: keyPath] = newValue } } - init(initialViewState: ViewState) { + init(initialViewState: ViewState, imageProvider: ImageProviderProtocol?) { self.viewActions = PassthroughSubject() self.viewState = initialViewState + self.imageProvider = imageProvider } /// Send a `ViewAction` to the `ViewModel` for processing. @@ -78,8 +83,8 @@ class StateStoreViewModel { set { context.viewState = newValue } } - init(initialViewState: State) { - context = Context(initialViewState: initialViewState) + init(initialViewState: State, imageProvider: ImageProviderProtocol? = nil) { + context = Context(initialViewState: initialViewState, imageProvider: imageProvider) context.viewActions .sink { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift new file mode 100644 index 000000000..56bc101c7 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift @@ -0,0 +1,50 @@ +// +// Copyright 2023 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 LoadableAvatarImage: View { + private let url: URL? + private let name: String? + private let contentID: String? + private let avatarSize: AvatarSize + private let imageProvider: ImageProviderProtocol? + + @ScaledMetric private var frameSize: CGFloat + + init(url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, imageProvider: ImageProviderProtocol?) { + self.url = url + self.name = name + self.contentID = contentID + self.avatarSize = avatarSize + self.imageProvider = imageProvider + + _frameSize = ScaledMetric(wrappedValue: avatarSize.value) + } + + var body: some View { + LoadableImage(url: url, + size: avatarSize.scaledSize, + imageProvider: imageProvider) { image in + image + .scaledToFill() + } placeholder: { + PlaceholderAvatarImage(name: name, contentID: contentID) + } + .frame(width: frameSize, height: frameSize) + .clipShape(Circle()) + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift b/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift new file mode 100644 index 000000000..ee8fe8d1b --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift @@ -0,0 +1,102 @@ +// +// Copyright 2023 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 LoadableImage: View { + private let mediaSource: MediaSourceProxy? + private let blurhash: String? + private let size: CGSize? + private let imageProvider: ImageProviderProtocol? + + private var transformer: (Image) -> TransformerView + private let placeholder: () -> PlaceholderView + + @State private var cachedImage: UIImage? + + /// A SwiftUI view that automatically fetches images + /// It will try fetching the image from in-memory cache and if that's not available + /// it will fire a task to load it through the image provider + /// - Parameters: + /// - mediaSource: the source of the image + /// - blurhash: an optional blurhash + /// - transformer: entry point for configuring the resulting image view + /// - placeholder: a view to show while the image or blurhash are not available + init(mediaSource: MediaSourceProxy?, + blurhash: String? = nil, + size: CGSize? = nil, + imageProvider: ImageProviderProtocol?, + transformer: @escaping (Image) -> TransformerView = { $0 }, + placeholder: @escaping () -> PlaceholderView) { + self.mediaSource = mediaSource + self.blurhash = blurhash + self.size = size + self.imageProvider = imageProvider + + self.transformer = transformer + self.placeholder = placeholder + } + + init(url: URL?, + blurhash: String? = nil, + size: CGSize? = nil, + imageProvider: ImageProviderProtocol?, + transformer: @escaping (Image) -> TransformerView = { $0 }, + placeholder: @escaping () -> PlaceholderView) { + let mediaSource = url.map(MediaSourceProxy.init) + + self.init(mediaSource: mediaSource, + blurhash: blurhash, + size: size, + imageProvider: imageProvider, + transformer: transformer, + placeholder: placeholder) + } + + var body: some View { + let _ = Task { + // Future improvement: Does guarding against a nil image prevent the image being updated when the URL changes? + guard image == nil, let mediaSource else { return } + + if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) { + self.cachedImage = image + } + } + + ZStack { + if let image { + transformer( + Image(uiImage: image) + .resizable() + ) + } else if let blurhash, + // Build a small blurhash image so that it's fast + let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) { + transformer( + Image(uiImage: image) + .resizable() + ) + } else { + placeholder() + } + } + .animation(.elementDefault, value: image) + } + + private var image: UIImage? { + cachedImage ?? imageProvider?.imageFromSource(mediaSource, size: size) + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift new file mode 100644 index 000000000..dd1649c3d --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift @@ -0,0 +1,76 @@ +// +// 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 PlaceholderAvatarImage: View { + @Environment(\.redactionReasons) var redactionReasons + + private let textForImage: String + private let contentID: String? + + var body: some View { + ZStack { + bgColor + + // This text's frame doesn't look right when redacted + if redactionReasons != .placeholder { + Text(textForImage) + .padding(4) + .foregroundColor(.white) + .font(.system(size: 200).weight(.semibold)) + .minimumScaleFactor(0.001) + } + } + .aspectRatio(1, contentMode: .fill) + } + + init(name: String?, contentID: String?) { + let baseName = name ?? contentID?.trimmingCharacters(in: .punctuationCharacters) + textForImage = baseName?.first?.uppercased() ?? "" + self.contentID = contentID + } + + private var bgColor: Color { + if redactionReasons == .placeholder { + return .element.systemGray4 + } + + guard let contentID else { + return .element.accent + } + + return .element.avatarBackground(for: contentID) + } +} + +struct PlaceholderAvatarImage_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 75) { + PlaceholderAvatarImage(name: "Xavier", contentID: "@userid1:matrix.org") + .clipShape(Circle()) + .frame(width: 150, height: 100) + + PlaceholderAvatarImage(name: "@*~AmazingName~*@", contentID: "@userid2:matrix.org") + .clipShape(Circle()) + .frame(width: 150, height: 100) + + PlaceholderAvatarImage(name: nil, contentID: "@userid3:matrix.org") + .clipShape(Circle()) + .frame(width: 150, height: 100) + } + } +} diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 1b953a635..fc3c9eff4 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -34,7 +34,6 @@ enum HomeScreenViewUserMenuAction { } enum HomeScreenViewAction { - case loadRoomData(roomIdentifier: String) case selectRoom(roomIdentifier: String) case userMenu(action: HomeScreenViewUserMenuAction) case verifySession @@ -59,7 +58,7 @@ enum HomeScreenRoomListMode: CustomStringConvertible { struct HomeScreenViewState: BindableState { var userID: String var userDisplayName: String? - var userAvatar: UIImage? + var userAvatarURL: URL? var showSessionVerificationBanner = false @@ -96,7 +95,6 @@ struct HomeScreenViewStateBindings { struct HomeScreenRoom: Identifiable, Equatable { private static let placeholderLastMessage = AttributedString("Last message") - private static let placeholderAvatar = UIImage(systemName: "photo") /// The list item identifier can be a real room identifier, a custom one for invalidated entries /// or a completely unique one for empty items and skeletons @@ -115,8 +113,6 @@ struct HomeScreenRoom: Identifiable, Equatable { var avatarURL: URL? - var avatar: UIImage? - var isPlaceholder = false static func placeholder() -> HomeScreenRoom { @@ -126,7 +122,6 @@ struct HomeScreenRoom: Identifiable, Equatable { hasUnreads: false, timestamp: "Now", lastMessage: Self.placeholderLastMessage, - avatar: Self.placeholderAvatar, isPlaceholder: true) } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 7ad286357..54a178f47 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -37,7 +37,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol // MARK: - Setup - // swiftlint:disable:next function_body_length cyclomatic_complexity + // swiftlint:disable:next function_body_length init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) { self.userSession = userSession self.attributedStringBuilder = attributedStringBuilder @@ -45,7 +45,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol visibleRoomsSummaryProvider = userSession.clientProxy.visibleRoomsSummaryProvider allRoomsSummaryProvider = userSession.clientProxy.allRoomsSummaryProvider - super.init(initialViewState: HomeScreenViewState(userID: userSession.userID)) + super.init(initialViewState: HomeScreenViewState(userID: userSession.userID), + imageProvider: userSession.mediaProvider) userSession.callbacks .receive(on: DispatchQueue.main) @@ -71,9 +72,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol Task { if case let .success(url) = await userSession.clientProxy.loadUserAvatarURL() { - if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURL(url, avatarSize: .user(on: .home)) { - state.userAvatar = avatar - } + state.userAvatarURL = url } } @@ -139,13 +138,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol // MARK: - Public - // swiftlint:disable:next cyclomatic_complexity override func process(viewAction: HomeScreenViewAction) async { switch viewAction { - case .loadRoomData(let roomIdentifier): - if state.roomListMode != .skeletons { - loadDataForRoomIdentifier(roomIdentifier) - } case .selectRoom(let roomIdentifier): callback?(.presentRoom(roomIdentifier: roomIdentifier)) case .userMenu(let action): @@ -178,27 +172,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol } // MARK: - Private - - private func loadDataForRoomIdentifier(_ identifier: String) { - guard let room = roomsForIdentifiers[identifier], - room.avatar == nil, - let avatarURL = room.avatarURL else { - return - } - Task { - if case let .success(image) = await userSession.mediaProvider.loadImageFromURL(avatarURL, avatarSize: .room(on: .home)) { - guard let roomIndex = state.rooms.firstIndex(where: { $0.roomId == identifier }) else { - return - } - - var room = state.rooms[roomIndex] - room.avatar = image - state.rooms[roomIndex] = room - } - } - } - /// This method will update all view state rooms by merging the data from both summary providers /// If a room is empty in the visible room summary provider it will try to get it from the allRooms one /// This ensures that we show as many room details as possible without loading up timelines @@ -256,10 +230,6 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol room.hasUnreads = details.unreadNotificationCount > 0 room.lastMessage = details.lastMessage - if let avatarURL = details.avatarURL { - room.avatar = userSession.mediaProvider.imageFromURL(avatarURL, avatarSize: .room(on: .home)) - } - if let lastMessageTimestamp = details.lastMessageTimestamp { room.timestamp = lastMessageTimestamp.formattedMinimal() } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index f975570b5..28dd5ab8c 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -102,9 +102,12 @@ struct HomeScreen: View { } } } label: { - userAvatarImageView - .animation(.elementDefault, value: context.viewState.userAvatar) - .transition(.opacity) + LoadableAvatarImage(url: context.viewState.userAvatarURL, + name: context.viewState.userDisplayName, + contentID: context.viewState.userID, + avatarSize: .user(on: .home), + imageProvider: context.imageProvider) + .accessibilityIdentifier("userAvatarImage") } .alert(ElementL10n.actionSignOut, isPresented: $showingLogoutConfirmation) { @@ -116,26 +119,6 @@ struct HomeScreen: View { } .accessibilityLabel(ElementL10n.a11yAllChatsUserAvatarMenu) } - - @ViewBuilder - private var userAvatarImageView: some View { - userAvatarImage - .frame(width: AvatarSize.user(on: .home).value, height: AvatarSize.user(on: .home).value, alignment: .center) - .clipShape(Circle()) - .accessibilityIdentifier("userAvatarImage") - } - - @ViewBuilder - private var userAvatarImage: some View { - if let avatar = context.viewState.userAvatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - } else { - PlaceholderAvatarImage(text: context.viewState.userDisplayName ?? context.viewState.userID, - contentId: context.viewState.userID) - } - } private var sessionVerificationBanner: some View { VStack(alignment: .leading, spacing: 16) { diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift index 69d491018..6d69d4fe0 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift @@ -17,8 +17,6 @@ import SwiftUI struct HomeScreenRoomCell: View { - @ScaledMetric private var avatarSize = AvatarSize.room(on: .home).value - let room: HomeScreenRoom let context: HomeScreenViewModel.Context @@ -38,11 +36,6 @@ struct HomeScreenRoomCell: View { } .frame(minHeight: 64.0) .accessibilityElement(children: .combine) - .task { - if let roomId = room.roomId { - context.send(viewAction: .loadRoomData(roomIdentifier: roomId)) - } - } } .buttonStyle(HomeScreenRoomCellButtonStyle()) .accessibilityIdentifier("roomName:\(room.name)") @@ -50,19 +43,12 @@ struct HomeScreenRoomCell: View { @ViewBuilder var avatar: some View { - if let avatar = room.avatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - .accessibilityHidden(true) - } else { - PlaceholderAvatarImage(text: room.name, contentId: room.roomId) - .clipShape(Circle()) - .frame(width: avatarSize, height: avatarSize) - .accessibilityHidden(true) - } + LoadableAvatarImage(url: room.avatarURL, + name: room.name, + contentID: room.roomId, + avatarSize: .room(on: .home), + imageProvider: context.imageProvider) + .accessibilityHidden(true) } @ViewBuilder diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift index 281b51722..6861ef979 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift @@ -32,9 +32,9 @@ struct RoomDetailsViewState: BindableState { let roomId: String let isEncrypted: Bool let isDirect: Bool - var roomTitle = "" - var roomTopic: String? - var roomAvatar: UIImage? + var title = "" + var topic: String? + var avatarURL: URL? var members: [RoomDetailsMember] var isLoadingMembers: Bool { @@ -62,8 +62,6 @@ struct RoomDetailsMember: Identifiable, Equatable { let id: String let name: String? let avatarURL: URL? - // cached - var avatar: UIImage? init(withProxy proxy: RoomMemberProxy) { id = proxy.userId diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift index e30a51559..1a2711084 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift @@ -19,29 +19,25 @@ import SwiftUI typealias RoomDetailsViewModelType = StateStoreViewModel class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtocol { - private let roomProxy: RoomProxyProtocol - private let mediaProvider: MediaProviderProtocol - private var members: [RoomMemberProxy] = [] { didSet { state.members = members.map { RoomDetailsMember(withProxy: $0) } } } - + var callback: ((RoomDetailsViewModelAction) -> Void)? - - init(roomProxy: RoomProxyProtocol, - mediaProvider: MediaProviderProtocol) { - self.roomProxy = roomProxy - self.mediaProvider = mediaProvider + + init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol) { super.init(initialViewState: .init(roomId: roomProxy.id, isEncrypted: roomProxy.isEncrypted, isDirect: roomProxy.isDirect, - roomTitle: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room", - roomTopic: roomProxy.topic, + title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room", + topic: roomProxy.topic, + avatarURL: roomProxy.avatarURL, members: [], - bindings: .init())) - + bindings: .init()), + imageProvider: mediaProvider) + Task { switch await roomProxy.members() { case .success(let members): @@ -51,14 +47,6 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc state.bindings.alertInfo = AlertInfo(id: .alert(ElementL10n.unknownError)) } } - - if let avatarURL = roomProxy.avatarURL { - Task { - if case let .success(avatar) = await mediaProvider.loadImageFromURL(avatarURL, avatarSize: .room(on: .details)) { - state.roomAvatar = avatar - } - } - } } // MARK: - Public diff --git a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift index d655f28b0..f27c22a42 100644 --- a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift @@ -17,24 +17,16 @@ import SwiftUI struct RoomDetailsScreen: View { - // MARK: Private - - @Environment(\.colorScheme) private var colorScheme - @ScaledMetric private var avatarSize = AvatarSize.room(on: .details).value @ScaledMetric private var menuIconSize = 30.0 private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) - // MARK: Public - @ObservedObject var context: RoomDetailsViewModel.Context - // MARK: Views - var body: some View { Form { headerSection - if let topic = context.viewState.roomTopic { + if let topic = context.viewState.topic { topicSection(with: topic) } @@ -47,11 +39,19 @@ struct RoomDetailsScreen: View { .alert(item: $context.alertInfo) { $0.alert } .navigationTitle(ElementL10n.roomDetailsTitle) } + + // MARK: - Private private var headerSection: some View { VStack(spacing: 16.0) { - roomAvatarImage - Text(context.viewState.roomTitle) + LoadableAvatarImage(url: context.viewState.avatarURL, + name: context.viewState.title, + contentID: context.viewState.roomId, + avatarSize: .room(on: .details), + imageProvider: context.imageProvider) + .accessibilityIdentifier("roomAvatarImage") + + Text(context.viewState.title) .foregroundColor(.element.primaryContent) .font(.element.headline) .multilineTextAlignment(.center) @@ -128,23 +128,6 @@ struct RoomDetailsScreen: View { } } } - - @ViewBuilder private var roomAvatarImage: some View { - if let avatar = context.viewState.roomAvatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - .accessibilityIdentifier("roomAvatarImage") - } else { - PlaceholderAvatarImage(text: context.viewState.roomTitle, - contentId: context.viewState.roomId) - .clipShape(Circle()) - .frame(width: avatarSize, height: avatarSize) - .accessibilityIdentifier("roomAvatarPlaceholderImage") - } - } } // MARK: - Previews diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift b/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift index ee96ded1a..5c354f4c5 100644 --- a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift +++ b/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift @@ -46,5 +46,4 @@ struct RoomMemberDetailsViewStateBindings { enum RoomMemberDetailsViewAction { case selectMember(id: String) - case loadMemberData(id: String) } diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift b/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift index f20c721db..7af0aadc8 100644 --- a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift +++ b/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift @@ -27,7 +27,8 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta members: [RoomMemberProxy]) { self.mediaProvider = mediaProvider super.init(initialViewState: .init(members: members.map { RoomDetailsMember(withProxy: $0) }, - bindings: .init())) + bindings: .init()), + imageProvider: mediaProvider) } // MARK: - Public @@ -36,30 +37,6 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta switch viewAction { case .selectMember(let id): MXLog.debug("Member selected: \(id)") - case .loadMemberData(let id): - await loadAvatar(forMember: id) - } - } - - private func loadAvatar(forMember memberId: String) async { - guard let member = state.members.first(where: { $0.id == memberId }) else { - return - } - if member.avatar != nil { - // Avatar already loaded. - return - } - guard let avatarURL = member.avatarURL else { - return - } - - switch await mediaProvider.loadImageFromURL(avatarURL, avatarSize: .user(on: .roomDetails)) { - case .success(let image): - if let index = state.members.firstIndex(where: { $0.id == memberId }) { - state.members[index].avatar = image - } - case .failure(let error): - MXLog.error("Failed to retrieve room member avatar: \(error)") } } } diff --git a/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift b/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift index d8c426d15..2efd717fe 100644 --- a/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift +++ b/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift @@ -27,19 +27,12 @@ struct RoomMemberDetailsMemberCell: View { context.send(viewAction: .selectMember(id: member.id)) } label: { HStack { - if let avatar = member.avatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - .accessibilityHidden(true) - } else { - PlaceholderAvatarImage(text: member.name ?? "", contentId: member.id) - .clipShape(Circle()) - .frame(width: avatarSize, height: avatarSize) - .accessibilityHidden(true) - } + LoadableAvatarImage(url: member.avatarURL, + name: member.name ?? "", + contentID: member.id, + avatarSize: .user(on: .roomDetails), + imageProvider: context.imageProvider) + .accessibilityHidden(true) Text(member.name ?? "") .font(.element.callout.bold()) @@ -49,9 +42,6 @@ struct RoomMemberDetailsMemberCell: View { Spacer() } .accessibilityElement(children: .combine) - .task { - context.send(viewAction: .loadMemberData(id: member.id)) - } } } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 736ec5cc2..27df699cd 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -49,7 +49,7 @@ enum RoomScreenViewAction { struct RoomScreenViewState: BindableState { var roomId: String var roomTitle = "" - var roomAvatar: UIImage? + var roomAvatarURL: URL? var items: [RoomTimelineViewProvider] = [] var canBackPaginate = true var isBackPaginating = false diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 55ab399c5..cb7d5e49a 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -28,7 +28,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let timelineController: RoomTimelineControllerProtocol private let timelineViewFactory: RoomTimelineViewFactoryProtocol - private let mediaProvider: MediaProviderProtocol // MARK: - Setup @@ -39,12 +38,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol roomAvatarUrl: URL? = nil) { self.timelineController = timelineController self.timelineViewFactory = timelineViewFactory - self.mediaProvider = mediaProvider super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID, roomTitle: roomName ?? "Unknown room 💥", - roomAvatar: nil, - bindings: .init(composerText: "", composerFocused: false))) + roomAvatarURL: roomAvatarUrl, + bindings: .init(composerText: "", composerFocused: false)), + imageProvider: mediaProvider) timelineController.callbacks .receive(on: DispatchQueue.main) @@ -76,15 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.contextMenuBuilder = buildContextMenuForItemId(_:) buildTimelineViews() - - if let roomAvatarUrl { - Task { - if case let .success(avatar) = await mediaProvider.loadImageFromURL(roomAvatarUrl, - avatarSize: .room(on: .timeline)) { - state.roomAvatar = avatar - } - } - } } // MARK: - Public diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift index b734d550b..7f1f2c289 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift @@ -38,24 +38,12 @@ struct RoomHeaderView: View { } @ViewBuilder private var roomAvatar: some View { - ZStack(alignment: .bottomTrailing) { - roomAvatarImage - .clipShape(Circle()) - } - .frame(width: AvatarSize.room(on: .timeline).value, height: AvatarSize.room(on: .timeline).value) - } - - @ViewBuilder private var roomAvatarImage: some View { - if let avatar = context.viewState.roomAvatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .accessibilityIdentifier("roomAvatarImage") - } else { - PlaceholderAvatarImage(text: context.viewState.roomTitle, - contentId: context.viewState.roomId) - .accessibilityIdentifier("roomAvatarPlaceholderImage") - } + LoadableAvatarImage(url: context.viewState.roomAvatarURL, + name: context.viewState.roomTitle, + contentID: context.viewState.roomId, + avatarSize: .room(on: .timeline), + imageProvider: context.imageProvider) + .accessibilityIdentifier("roomAvatarImage") } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 1e26e8f6d..604abefb3 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -18,33 +18,29 @@ import Foundation import SwiftUI struct ImageRoomTimelineView: View { + @EnvironmentObject private var context: RoomScreenViewModel.Context let timelineItem: ImageRoomTimelineItem var body: some View { TimelineStyler(timelineItem: timelineItem) { - if let image = timelineItem.image { - Image(uiImage: image) - .resizable() - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) - } else if let blurhash = timelineItem.blurhash, - // Build a small blurhash image so that it's fast - let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) { - Image(uiImage: image) - .resizable() - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) - } else { - ZStack { - Rectangle() - .foregroundColor(.element.systemGray6) - .opacity(0.3) - - ProgressView(ElementL10n.loading) - .frame(maxWidth: .infinity) - } - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + LoadableImage(mediaSource: timelineItem.source, + blurhash: timelineItem.blurhash, + imageProvider: context.imageProvider) { + placeholder } + .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + } + } + + var placeholder: some View { + ZStack { + Rectangle() + .foregroundColor(.element.systemGray6) + .opacity(0.3) + + ProgressView(ElementL10n.loading) + .frame(maxWidth: .infinity) } - .animation(.elementDefault, value: timelineItem.image) } } @@ -63,8 +59,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil, - image: UIImage(systemName: "photo"))) + source: nil)) ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, text: "Some other image", @@ -73,8 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil, - image: nil)) + source: nil)) ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, text: "Blurhashed image", @@ -84,7 +78,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { isEditable: false, sender: .init(id: "Bob"), source: nil, - image: nil, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift deleted file mode 100644 index 73c3cfcaa..000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// 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 PlaceholderAvatarImage: View { - private let textForImage: String - private let contentId: String? - - var body: some View { - ZStack { - bgColor - Text(textForImage) - .padding(4) - .foregroundColor(.white) - .font(.system(size: 200).weight(.semibold)) - .minimumScaleFactor(0.001) - } - .aspectRatio(1, contentMode: .fill) - } - - init(text: String, contentId: String? = nil) { - textForImage = text.first?.uppercased() ?? "" - self.contentId = contentId - } - - private var bgColor: Color { - guard let contentId else { - return .element.accent - } - - return .element.avatarBackground(for: contentId) - } -} - -struct PlaceholderAvatarImage_Previews: PreviewProvider { - static var previews: some View { - PlaceholderAvatarImage(text: "X", contentId: "@userid:matrix.org") - .clipShape(Circle()) - .frame(width: 150, height: 100) - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift index 4a5cddcee..49c4184d6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift @@ -18,35 +18,31 @@ import Foundation import SwiftUI struct StickerRoomTimelineView: View { + @EnvironmentObject private var context: RoomScreenViewModel.Context let timelineItem: StickerRoomTimelineItem var body: some View { TimelineStyler(timelineItem: timelineItem) { - if let image = timelineItem.image { - Image(uiImage: image) - .resizable() - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) - } else if let blurhash = timelineItem.blurhash, - // Build a small blurhash image so that it's fast - let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) { - Image(uiImage: image) - .resizable() - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) - } else { - ZStack { - Rectangle() - .foregroundColor(.element.systemGray6) - .opacity(0.3) - - ProgressView(ElementL10n.loading) - .frame(maxWidth: .infinity) - } - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + LoadableImage(url: timelineItem.imageURL, + blurhash: timelineItem.blurhash, + imageProvider: context.imageProvider) { + placeholder } + .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } - .animation(.elementDefault, value: timelineItem.image) .accessibilityLabel(timelineItem.text) } + + private var placeholder: some View { + ZStack { + Rectangle() + .foregroundColor(.element.systemGray6) + .opacity(0.3) + + ProgressView(ElementL10n.loading) + .frame(maxWidth: .infinity) + } + } } struct StickerRoomTimelineView_Previews: PreviewProvider { @@ -64,8 +60,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - imageURL: nil, - image: UIImage(systemName: "photo"))) + imageURL: URL.picturesDirectory)) StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, text: "Some other image", @@ -74,8 +69,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - imageURL: nil, - image: nil)) + imageURL: URL.picturesDirectory)) StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, text: "Blurhashed image", @@ -84,8 +78,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider { isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - imageURL: nil, - image: nil, + imageURL: URL.picturesDirectory, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index 9d0e6905f..3905dd557 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -18,42 +18,39 @@ import Foundation import SwiftUI struct VideoRoomTimelineView: View { + @EnvironmentObject private var context: RoomScreenViewModel.Context let timelineItem: VideoRoomTimelineItem var body: some View { TimelineStyler(timelineItem: timelineItem) { - if let image = timelineItem.image { - thumbnail(with: image) - } else if let blurhash = timelineItem.blurhash, - // Build a small blurhash image so that it's fast - let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) { - thumbnail(with: image) - } else { - ZStack { - Rectangle() - .foregroundColor(.element.systemGray6) - .opacity(0.3) - - ProgressView(ElementL10n.loading) - .frame(maxWidth: .infinity) - } - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + LoadableImage(mediaSource: timelineItem.thumbnailSource, + blurhash: timelineItem.blurhash, + imageProvider: context.imageProvider) { imageView in + imageView + .overlay { playIcon } + } placeholder: { + placeholder } + .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } - .animation(.elementDefault, value: timelineItem.image) } - - @ViewBuilder - private func thumbnail(with image: UIImage) -> some View { + + var playIcon: some View { + Image(systemName: "play.circle.fill") + .resizable() + .frame(width: 50, height: 50) + .background(.ultraThinMaterial, in: Circle()) + .foregroundColor(.white) + } + + var placeholder: some View { ZStack { - Image(uiImage: image) - .resizable() - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) - Image(systemName: "play.circle.fill") - .resizable() - .frame(width: 50, height: 50) - .background(.ultraThinMaterial, in: Circle()) - .foregroundColor(.white) + Rectangle() + .foregroundColor(.element.systemGray6) + .opacity(0.3) + + ProgressView(ElementL10n.loading) + .frame(maxWidth: .infinity) } } } @@ -75,8 +72,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { sender: .init(id: "Bob"), duration: 21, source: nil, - thumbnailSource: nil, - image: UIImage(systemName: "photo"))) + thumbnailSource: nil)) VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, text: "Some other video", @@ -87,8 +83,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { sender: .init(id: "Bob"), duration: 22, source: nil, - thumbnailSource: nil, - image: nil)) + thumbnailSource: nil)) VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, text: "Blurhashed video", @@ -100,7 +95,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { duration: 23, source: nil, thumbnailSource: nil, - image: nil, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift index 2dd752d45..29b5bd130 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift @@ -18,29 +18,19 @@ import Foundation import SwiftUI struct TimelineSenderAvatarView: View { - let timelineItem: EventBasedTimelineItemProtocol - + @EnvironmentObject private var context: RoomScreenViewModel.Context @ScaledMetric private var avatarSize = AvatarSize.user(on: .timeline).value - + + let timelineItem: EventBasedTimelineItemProtocol + var body: some View { - ZStack(alignment: .center) { - if let avatar = timelineItem.sender.avatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .overlay(Circle().stroke(Color.element.accent)) - } else { - PlaceholderAvatarImage(text: timelineItem.sender.displayName ?? timelineItem.sender.id, - contentId: timelineItem.sender.id) - } - } - .clipShape(Circle()) - .frame(width: avatarSize, height: avatarSize) - .overlay( - Circle() - .stroke(Color.element.background, lineWidth: 3) - ) - - .animation(.elementDefault, value: timelineItem.sender.avatar) + LoadableAvatarImage(url: timelineItem.sender.avatarURL, + name: timelineItem.sender.displayName, + contentID: timelineItem.sender.id, + avatarSize: .user(on: .timeline), + imageProvider: context.imageProvider) + .overlay( + Circle().stroke(Color.element.background, lineWidth: 3) + ) } } diff --git a/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift similarity index 85% rename from ElementX/Sources/Screens/Settings/SettingsCoordinator.swift rename to ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift index df37151bc..d649b0ab1 100644 --- a/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift @@ -16,30 +16,30 @@ import SwiftUI -struct SettingsCoordinatorParameters { +struct SettingsScreenCoordinatorParameters { let navigationStackCoordinator: NavigationStackCoordinator let userNotificationController: UserNotificationControllerProtocol let userSession: UserSessionProtocol let bugReportService: BugReportServiceProtocol } -enum SettingsCoordinatorAction { +enum SettingsScreenCoordinatorAction { case dismiss case logout } -final class SettingsCoordinator: CoordinatorProtocol { - private let parameters: SettingsCoordinatorParameters - private var viewModel: SettingsViewModelProtocol +final class SettingsScreenCoordinator: CoordinatorProtocol { + private let parameters: SettingsScreenCoordinatorParameters + private var viewModel: SettingsScreenViewModelProtocol - var callback: ((SettingsCoordinatorAction) -> Void)? + var callback: ((SettingsScreenCoordinatorAction) -> Void)? // MARK: - Setup - init(parameters: SettingsCoordinatorParameters) { + init(parameters: SettingsScreenCoordinatorParameters) { self.parameters = parameters - viewModel = SettingsViewModel(withUserSession: parameters.userSession) + viewModel = SettingsScreenViewModel(withUserSession: parameters.userSession) viewModel.callback = { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Screens/Settings/SettingsModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift similarity index 79% rename from ElementX/Sources/Screens/Settings/SettingsModels.swift rename to ElementX/Sources/Screens/Settings/SettingsScreenModels.swift index 51c326a0d..6901bb127 100644 --- a/ElementX/Sources/Screens/Settings/SettingsModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift @@ -17,26 +17,26 @@ import Foundation import UIKit -enum SettingsViewModelAction { +enum SettingsScreenViewModelAction { case close case toggleAnalytics case reportBug case logout } -struct SettingsViewState: BindableState { - var bindings: SettingsViewStateBindings +struct SettingsScreenViewState: BindableState { + var bindings: SettingsScreenViewStateBindings var deviceID: String? var userID: String - var userAvatar: UIImage? + var userAvatarURL: URL? var userDisplayName: String? } -struct SettingsViewStateBindings { +struct SettingsScreenViewStateBindings { var enableAnalytics = ServiceLocator.shared.settings.enableAnalytics } -enum SettingsViewAction { +enum SettingsScreenViewAction { case close case toggleAnalytics case reportBug diff --git a/ElementX/Sources/Screens/Settings/SettingsViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift similarity index 70% rename from ElementX/Sources/Screens/Settings/SettingsViewModel.swift rename to ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift index eff90dfea..a7ccbbbe9 100644 --- a/ElementX/Sources/Screens/Settings/SettingsViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift @@ -16,23 +16,22 @@ import SwiftUI -typealias SettingsViewModelType = StateStoreViewModel +typealias SettingsScreenViewModelType = StateStoreViewModel -class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol { +class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol { private let userSession: UserSessionProtocol - var callback: ((SettingsViewModelAction) -> Void)? + var callback: ((SettingsScreenViewModelAction) -> Void)? init(withUserSession userSession: UserSessionProtocol) { self.userSession = userSession - let bindings = SettingsViewStateBindings() - super.init(initialViewState: .init(bindings: bindings, deviceID: userSession.deviceId, userID: userSession.userID)) + let bindings = SettingsScreenViewStateBindings() + super.init(initialViewState: .init(bindings: bindings, deviceID: userSession.deviceId, userID: userSession.userID), + imageProvider: userSession.mediaProvider) Task { if case let .success(userAvatarURL) = await userSession.clientProxy.loadUserAvatarURL() { - if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURL(userAvatarURL, avatarSize: .user(on: .settings)) { - state.userAvatar = avatar - } + state.userAvatarURL = userAvatarURL } } @@ -43,7 +42,7 @@ class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol { } } - override func process(viewAction: SettingsViewAction) async { + override func process(viewAction: SettingsScreenViewAction) async { switch viewAction { case .close: callback?(.close) diff --git a/ElementX/Sources/Screens/Settings/SettingsViewModelProtocol.swift b/ElementX/Sources/Screens/Settings/SettingsScreenViewModelProtocol.swift similarity index 77% rename from ElementX/Sources/Screens/Settings/SettingsViewModelProtocol.swift rename to ElementX/Sources/Screens/Settings/SettingsScreenViewModelProtocol.swift index 65541bd6d..149523328 100644 --- a/ElementX/Sources/Screens/Settings/SettingsViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenViewModelProtocol.swift @@ -17,7 +17,7 @@ import Foundation @MainActor -protocol SettingsViewModelProtocol { - var callback: ((SettingsViewModelAction) -> Void)? { get set } - var context: SettingsViewModelType.Context { get } +protocol SettingsScreenViewModelProtocol { + var callback: ((SettingsScreenViewModelAction) -> Void)? { get set } + var context: SettingsScreenViewModelType.Context { get } } diff --git a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift index 16bf76571..a74ccdbff 100644 --- a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift @@ -25,7 +25,7 @@ struct SettingsScreen: View { @ScaledMetric private var menuIconSize = 30.0 private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) - @ObservedObject var context: SettingsViewModel.Context + @ObservedObject var context: SettingsScreenViewModel.Context var body: some View { Form { @@ -69,7 +69,12 @@ struct SettingsScreen: View { private var userSection: some View { Section { HStack(spacing: 13) { - userAvatar + LoadableAvatarImage(url: context.viewState.userAvatarURL, + name: context.viewState.userDisplayName, + contentID: context.viewState.userID, + avatarSize: .user(on: .settings), + imageProvider: context.imageProvider) + VStack(alignment: .leading, spacing: 4) { Text(context.viewState.userDisplayName ?? "") .font(.title3) @@ -80,21 +85,6 @@ struct SettingsScreen: View { .listRowInsets(listRowInsets) } } - - @ViewBuilder - private var userAvatar: some View { - if let avatar = context.viewState.userAvatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - } else { - PlaceholderAvatarImage(text: context.viewState.userDisplayName ?? context.viewState.userID, contentId: context.viewState.userID) - .clipShape(Circle()) - .frame(width: avatarSize, height: avatarSize) - } - } private var analyticsSection: some View { Section { @@ -198,11 +188,11 @@ extension TimelineStyle: CustomStringConvertible { // MARK: - Previews -struct Settings_Previews: PreviewProvider { +struct SettingsScreen_Previews: PreviewProvider { static var previews: some View { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), mediaProvider: MockMediaProvider()) - let viewModel = SettingsViewModel(withUserSession: userSession) + let viewModel = SettingsScreenViewModel(withUserSession: userSession) NavigationView { SettingsScreen(context: viewModel.context) diff --git a/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift b/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift index d90a1fb4a..4328f8cde 100644 --- a/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift +++ b/ElementX/Sources/Services/Background/UIKitBackgroundTask.swift @@ -58,7 +58,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol { } if identifier == .invalid { - MXLog.info("Do not start background task: \(name), as OS declined") + MXLog.error("Do not start background task: \(name), as OS declined") // call expiration handler immediately expirationHandler?(self) return nil @@ -69,7 +69,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol { reuse() } - MXLog.info("Start background task #\(identifier.rawValue) - \(name)") + MXLog.verbose("Start background task #\(identifier.rawValue) - \(name)") } func reuse() { @@ -92,7 +92,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol { private func endTask() { if identifier != .invalid { - MXLog.info("End background task #\(identifier.rawValue) - \(name) after \(readableElapsedTime)") + MXLog.verbose("End background task #\(identifier.rawValue) - \(name) after \(readableElapsedTime)") application.endBackgroundTask(identifier) identifier = .invalid diff --git a/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift b/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift index 2d7d18307..9b8c76a82 100644 --- a/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift +++ b/ElementX/Sources/Services/Background/UIKitBackgroundTaskService.swift @@ -41,7 +41,7 @@ class UIKitBackgroundTaskService: BackgroundTaskServiceProtocol { } if avoidStartingNewTasks(for: application) { - MXLog.info("Do not start background task: \(name), as not enough time exists") + MXLog.error("Do not start background task: \(name), as not enough time exists") // call expiration handler immediately expirationHandler?() return nil @@ -82,7 +82,7 @@ class UIKitBackgroundTaskService: BackgroundTaskServiceProtocol { let appState = application.applicationState let remainingTime = readableBackgroundTimeRemaining(application.backgroundTimeRemaining) - MXLog.info("Background task \(name) \(created ? "started" : "reused") with app state: \(appState) and estimated background time remaining: \(remainingTime)") + MXLog.verbose("Background task \(name) \(created ? "started" : "reused") with app state: \(appState) and estimated background time remaining: \(remainingTime)") return result } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 40edd1c87..2866b81a7 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -56,7 +56,7 @@ class ClientProxy: ClientProxyProtocol { private let client: ClientProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol private var sessionVerificationControllerProxy: SessionVerificationControllerProxy? - private let mediaProxy: MediaProxyProtocol + private let mediaLoader: MediaLoaderProtocol private let clientQueue: DispatchQueue private var slidingSyncObserverToken: StoppableSpawn? @@ -86,7 +86,7 @@ class ClientProxy: ClientProxyProtocol { self.backgroundTaskService = backgroundTaskService clientQueue = .init(label: "ClientProxyQueue", attributes: .concurrent) - mediaProxy = MediaProxy(client: client, clientQueue: clientQueue) + mediaLoader = MediaLoader(client: client, clientQueue: clientQueue) client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self)) @@ -401,16 +401,16 @@ class ClientProxy: ClientProxyProtocol { } } -extension ClientProxy: MediaProxyProtocol { - func mediaSourceForURL(_ url: URL) -> MediaSourceProxy { - mediaProxy.mediaSourceForURL(url) +extension ClientProxy: MediaLoaderProtocol { + func mediaSourceForURL(_ url: URL) async -> MediaSourceProxy { + await mediaLoader.mediaSourceForURL(url) } func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data { - try await mediaProxy.loadMediaContentForSource(source) + try await mediaLoader.loadMediaContentForSource(source) } func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data { - try await mediaProxy.loadMediaThumbnailForSource(source, width: width, height: height) + try await mediaLoader.loadMediaThumbnailForSource(source, width: width, height: height) } } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index d9597c4c3..d2d2aceb4 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -58,7 +58,7 @@ enum PushFormat { // } } -protocol ClientProxyProtocol: AnyObject, MediaProxyProtocol { +protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { var callbacks: PassthroughSubject { get } var userID: String { get } diff --git a/ElementX/Sources/Services/Media/ImageProviderProtocol.swift b/ElementX/Sources/Services/Media/ImageProviderProtocol.swift new file mode 100644 index 000000000..ec172dd05 --- /dev/null +++ b/ElementX/Sources/Services/Media/ImageProviderProtocol.swift @@ -0,0 +1,45 @@ +// +// Copyright 2023 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 UIKit + +protocol ImageProviderProtocol { + func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? + + @discardableResult func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result + + func imageFromURL(_ url: URL?, size: CGSize?) -> UIImage? + + @discardableResult func loadImageFromURL(_ url: URL, size: CGSize?) async -> Result +} + +extension ImageProviderProtocol { + func imageFromSource(_ source: MediaSourceProxy?) -> UIImage? { + imageFromSource(source, size: nil) + } + + @discardableResult func loadImageFromSource(_ source: MediaSourceProxy) async -> Result { + await loadImageFromSource(source, size: nil) + } + + func imageFromURL(_ url: URL?) -> UIImage? { + imageFromURL(url, size: nil) + } + + @discardableResult func loadImageFromURL(_ url: URL) async -> Result { + await loadImageFromURL(url, size: nil) + } +} diff --git a/ElementX/Sources/Services/Media/MediaLoader.swift b/ElementX/Sources/Services/Media/MediaLoader.swift new file mode 100644 index 000000000..b991caf3c --- /dev/null +++ b/ElementX/Sources/Services/Media/MediaLoader.swift @@ -0,0 +1,84 @@ +// +// 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 Foundation +import MatrixRustSDK +import UIKit + +private final class MediaRequest { + var continuations: [CheckedContinuation] = [] +} + +actor MediaLoader: MediaLoaderProtocol { + private let client: ClientProtocol + private let clientQueue: DispatchQueue + private var ongoingRequests = [MediaSourceProxy: MediaRequest]() + + init(client: ClientProtocol, + clientQueue: DispatchQueue = .global()) { + self.client = client + self.clientQueue = clientQueue + } + + func mediaSourceForURL(_ url: URL) -> MediaSourceProxy { + .init(url: url) + } + + func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data { + try await enqueueLoadMediaRequest(forSource: source) { + try self.client.getMediaContent(source: source.underlyingSource) + } + } + + func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data { + try await enqueueLoadMediaRequest(forSource: source) { + try self.client.getMediaThumbnail(source: source.underlyingSource, width: UInt64(width), height: UInt64(height)) + } + } + + // MARK: - Private + + private func enqueueLoadMediaRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> [UInt8]) async throws -> Data { + if let ongoingRequest = ongoingRequests[source] { + return try await withCheckedThrowingContinuation { continuation in + ongoingRequest.continuations.append(continuation) + } + } + + let ongoingRequest = MediaRequest() + ongoingRequests[source] = ongoingRequest + + defer { + ongoingRequests[source] = nil + } + + do { + let result = try await Task.dispatch(on: clientQueue) { + let bytes = try operation() + return Data(bytes: bytes, count: bytes.count) + } + + ongoingRequest.continuations.forEach { $0.resume(returning: result) } + + return result + + } catch { + ongoingRequest.continuations.forEach { $0.resume(throwing: error) } + throw error + } + } +} diff --git a/ElementX/Sources/Services/Media/MediaProxyProtocol.swift b/ElementX/Sources/Services/Media/MediaLoaderProtocol.swift similarity index 89% rename from ElementX/Sources/Services/Media/MediaProxyProtocol.swift rename to ElementX/Sources/Services/Media/MediaLoaderProtocol.swift index 4acbb7815..d9d81f270 100644 --- a/ElementX/Sources/Services/Media/MediaProxyProtocol.swift +++ b/ElementX/Sources/Services/Media/MediaLoaderProtocol.swift @@ -16,8 +16,8 @@ import Foundation -protocol MediaProxyProtocol { - func mediaSourceForURL(_ url: URL) -> MediaSourceProxy +protocol MediaLoaderProtocol { + func mediaSourceForURL(_ url: URL) async -> MediaSourceProxy func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift index 663a1e342..42778e966 100644 --- a/ElementX/Sources/Services/Media/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -18,53 +18,54 @@ import Kingfisher import UIKit struct MediaProvider: MediaProviderProtocol { - private let mediaProxy: MediaProxyProtocol + private let mediaLoader: MediaLoaderProtocol private let imageCache: Kingfisher.ImageCache private let fileCache: FileCacheProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol? - init(mediaProxy: MediaProxyProtocol, + init(mediaLoader: MediaLoaderProtocol, imageCache: Kingfisher.ImageCache, fileCache: FileCacheProtocol, backgroundTaskService: BackgroundTaskServiceProtocol?) { - self.mediaProxy = mediaProxy + self.mediaLoader = mediaLoader self.imageCache = imageCache self.fileCache = fileCache self.backgroundTaskService = backgroundTaskService } - func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? { + // MARK: Images + + func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? { guard let url = source?.url else { return nil } - let cacheKey = cacheKeyForURL(url, avatarSize: avatarSize) + let cacheKey = cacheKeyForURL(url, size: size) return imageCache.retrieveImageInMemoryCache(forKey: cacheKey, options: nil) } - func imageFromURL(_ url: URL?, avatarSize: AvatarSize?) -> UIImage? { + func imageFromURL(_ url: URL?, size: CGSize?) -> UIImage? { guard let url else { return nil } - return imageFromSource(.init(url: url), avatarSize: avatarSize) + return imageFromSource(.init(url: url), size: size) } - func loadImageFromURL(_ url: URL, avatarSize: AvatarSize?) async -> Result { - await loadImageFromSource(.init(url: url), avatarSize: avatarSize) + func loadImageFromURL(_ url: URL, size: CGSize?) async -> Result { + await loadImageFromSource(.init(url: url), size: size) } - func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result { - if let image = imageFromSource(source, avatarSize: avatarSize) { + func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result { + if let image = imageFromSource(source, size: size) { return .success(image) } - #warning("Media loading should check for existing in flight operations and de-dupe requests.") let loadImageBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadImage: \(source.url.hashValue)") defer { loadImageBgTask?.stop() } - let cacheKey = cacheKeyForURL(source.url, avatarSize: avatarSize) + let cacheKey = cacheKeyForURL(source.url, size: size) if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: cacheKey), let image = cacheResult.image { @@ -73,10 +74,10 @@ struct MediaProvider: MediaProviderProtocol { do { let imageData: Data - if let avatarSize { - imageData = try await mediaProxy.loadMediaThumbnailForSource(source, width: UInt(avatarSize.scaledValue), height: UInt(avatarSize.scaledValue)) + if let size { + imageData = try await mediaLoader.loadMediaThumbnailForSource(source, width: UInt(size.width), height: UInt(size.height)) } else { - imageData = try await mediaProxy.loadMediaContentForSource(source) + imageData = try await mediaLoader.loadMediaContentForSource(source) } guard let image = UIImage(data: imageData) else { @@ -92,6 +93,8 @@ struct MediaProvider: MediaProviderProtocol { return .failure(.failedRetrievingImage) } } + + // MARK: Files func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? { guard let source else { @@ -100,6 +103,18 @@ struct MediaProvider: MediaProviderProtocol { let cacheKey = fileCacheKeyForURL(source.url) return fileCache.file(forKey: cacheKey, fileExtension: fileExtension) } + + func fileFromURL(_ url: URL?, fileExtension: String) -> URL? { + guard let url else { + return nil + } + + return fileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension) + } + + func loadFileFromURL(_ url: URL, fileExtension: String) async -> Result { + await loadFileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension) + } @discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result { if let url = fileFromSource(source, fileExtension: fileExtension) { @@ -114,7 +129,7 @@ struct MediaProvider: MediaProviderProtocol { let cacheKey = fileCacheKeyForURL(source.url) do { - let data = try await mediaProxy.loadMediaContentForSource(source) + let data = try await mediaLoader.loadMediaContentForSource(source) let url = try fileCache.store(data, with: fileExtension, forKey: cacheKey) return .success(url) @@ -124,23 +139,11 @@ struct MediaProvider: MediaProviderProtocol { } } - func fileFromURL(_ url: URL?, fileExtension: String) -> URL? { - guard let url else { - return nil - } - - return fileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension) - } - - func loadFileFromURL(_ url: URL, fileExtension: String) async -> Result { - await loadFileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension) - } - // MARK: - Private - private func cacheKeyForURL(_ url: URL, avatarSize: AvatarSize?) -> String { - if let avatarSize { - return "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" + private func cacheKeyForURL(_ url: URL, size: CGSize?) -> String { + if let size { + return "\(url.absoluteString){\(size.width),\(size.height)}" } else { return url.absoluteString } diff --git a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift index 9e3899a1d..72b627f39 100644 --- a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift +++ b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift @@ -23,15 +23,7 @@ enum MediaProviderError: Error { case invalidImageData } -protocol MediaProviderProtocol { - func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? - - @discardableResult func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result - - func imageFromURL(_ url: URL?, avatarSize: AvatarSize?) -> UIImage? - - @discardableResult func loadImageFromURL(_ url: URL, avatarSize: AvatarSize?) async -> Result - +protocol MediaProviderProtocol: ImageProviderProtocol { func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? @discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result @@ -40,21 +32,3 @@ protocol MediaProviderProtocol { @discardableResult func loadFileFromURL(_ url: URL, fileExtension: String) async -> Result } - -extension MediaProviderProtocol { - func imageFromSource(_ source: MediaSourceProxy?) -> UIImage? { - imageFromSource(source, avatarSize: nil) - } - - @discardableResult func loadImageFromSource(_ source: MediaSourceProxy) async -> Result { - await loadImageFromSource(source, avatarSize: nil) - } - - func imageFromURL(_ url: URL?) -> UIImage? { - imageFromURL(url, avatarSize: nil) - } - - @discardableResult func loadImageFromURL(_ url: URL) async -> Result { - await loadImageFromURL(url, avatarSize: nil) - } -} diff --git a/ElementX/Sources/Services/Media/MediaProxy.swift b/ElementX/Sources/Services/Media/MediaProxy.swift deleted file mode 100644 index 6fd70c2b7..000000000 --- a/ElementX/Sources/Services/Media/MediaProxy.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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 Foundation -import MatrixRustSDK -import UIKit - -class MediaProxy: MediaProxyProtocol { - private let client: ClientProtocol - private let clientQueue: DispatchQueue - - init(client: ClientProtocol, - clientQueue: DispatchQueue = .global()) { - self.client = client - self.clientQueue = clientQueue - } - - func mediaSourceForURL(_ url: URL) -> MediaSourceProxy { - .init(url: url) - } - - func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data { - try await Task.dispatch(on: clientQueue) { - let bytes = try self.client.getMediaContent(source: source.underlyingSource) - return Data(bytes: bytes, count: bytes.count) - } - } - - func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data { - try await Task.dispatch(on: clientQueue) { - let bytes = try self.client.getMediaThumbnail(source: source.underlyingSource, width: UInt64(width), height: UInt64(height)) - return Data(bytes: bytes, count: bytes.count) - } - } -} diff --git a/ElementX/Sources/Services/Media/MockMediaProvider.swift b/ElementX/Sources/Services/Media/MockMediaProvider.swift index 61f3d3830..23e39d08a 100644 --- a/ElementX/Sources/Services/Media/MockMediaProvider.swift +++ b/ElementX/Sources/Services/Media/MockMediaProvider.swift @@ -18,31 +18,31 @@ import Foundation import UIKit struct MockMediaProvider: MediaProviderProtocol { - func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? { + func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? { nil } - func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result { + func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result { .failure(.failedRetrievingImage) } - func imageFromURL(_ url: URL?, avatarSize: AvatarSize?) -> UIImage? { + func imageFromURL(_ url: URL?, size: CGSize?) -> UIImage? { guard url != nil else { return nil } - if let avatarSize { - switch avatarSize { - case .room: + #warning("Fix me. this is stupid!") + if let size { + if size == AvatarSize.room(on: .details).scaledSize + || size == AvatarSize.room(on: .home).scaledSize + || size == AvatarSize.room(on: .timeline).scaledSize { return Asset.Images.appLogo.image - default: - return UIImage(systemName: "photo") } } return UIImage(systemName: "photo") } - func loadImageFromURL(_ url: URL, avatarSize: AvatarSize?) async -> Result { + func loadImageFromURL(_ url: URL, size: CGSize?) async -> Result { guard let image = UIImage(systemName: "photo") else { fatalError() } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index ae6e8a7b6..2dd12f4a1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -103,20 +103,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } if let item = timelineItem as? EventBasedTimelineItemProtocol { - await loadUserAvatarForTimelineItem(item) await loadUserDisplayNameForTimelineItem(item) } - - switch timelineItem { - case let item as ImageRoomTimelineItem: - await loadThumbnailForImageTimelineItem(item) - case let item as VideoRoomTimelineItem: - await loadThumbnailForVideoTimelineItem(item) - case let item as StickerRoomTimelineItem: - await loadImageForStickerTimelineItem(item) - default: - break - } } func processItemDisappearance(_ itemID: String) { } @@ -339,78 +327,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return .middle } - private func loadThumbnailForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async { - if timelineItem.image != nil { - return - } - - guard let source = timelineItem.source else { - return - } - - switch await mediaProvider.loadImageFromSource(source) { - case .success(let image): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? ImageRoomTimelineItem else { - return - } - - item.image = image - timelineItems[index] = item - callbacks.send(.updatedTimelineItem(timelineItem.id)) - case .failure: - break - } - } - - private func loadThumbnailForVideoTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { - if timelineItem.image != nil { - return - } - - guard let source = timelineItem.thumbnailSource else { - return - } - - switch await mediaProvider.loadImageFromSource(source) { - case .success(let image): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? VideoRoomTimelineItem else { - return - } - - item.image = image - timelineItems[index] = item - callbacks.send(.updatedTimelineItem(timelineItem.id)) - case .failure: - break - } - } - - private func loadImageForStickerTimelineItem(_ timelineItem: StickerRoomTimelineItem) async { - if timelineItem.image != nil { - return - } - - guard let url = timelineItem.imageURL else { - return - } - - switch await mediaProvider.loadImageFromURL(url) { - case .success(let image): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? StickerRoomTimelineItem else { - return - } - - item.image = image - timelineItems[index] = item - callbacks.send(.updatedTimelineItem(timelineItem.id)) - case .failure: - break - } - } - private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { if timelineItem.cachedVideoURL != nil { // already cached @@ -511,29 +427,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { break } } - - private func loadUserAvatarForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) async { - guard timelineItem.shouldShowSenderDetails, - let avatarURL = timelineItem.sender.avatarURL, - timelineItem.sender.avatar == nil else { - return - } - switch await mediaProvider.loadImageFromURL(avatarURL, avatarSize: .user(on: .timeline)) { - case .success(let avatar): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? EventBasedTimelineItemProtocol else { - return - } - - item.sender.avatar = avatar - timelineItems[index] = item - callbacks.send(.updatedTimelineItem(timelineItem.id)) - case .failure: - break - } - } - #warning("This is here because sender profiles aren't working properly. Remove it entirely later") private func loadUserDisplayNameForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) async { if timelineItem.shouldShowSenderDetails == false || timelineItem.sender.displayName != nil { diff --git a/ElementX/Sources/Services/Timeline/TimelineItemSender.swift b/ElementX/Sources/Services/Timeline/TimelineItemSender.swift index 5f2610b46..5fb33ee9c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemSender.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemSender.swift @@ -23,5 +23,4 @@ struct TimelineItemSender: Identifiable, Hashable { // Lazy loaded properties, displayName and avatarURL will be come lets. var displayName: String? var avatarURL: URL? - var avatar: UIImage? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index 80453a1a3..a1f7c1ee5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -27,7 +27,6 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash var sender: TimelineItemSender let source: MediaSourceProxy? - var image: UIImage? var cachedFileURL: URL? var width: CGFloat? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index f7e8d62d2..ec78178e4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -29,7 +29,6 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let duration: UInt64 let source: MediaSourceProxy? let thumbnailSource: MediaSourceProxy? - var image: UIImage? var cachedVideoURL: URL? var width: CGFloat? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift index 1f82adc33..a93a992b8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift @@ -26,8 +26,7 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha var sender: TimelineItemSender - let imageURL: URL? - var image: UIImage? + let imageURL: URL var width: CGFloat? var height: CGFloat? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index b3bbd22d5..4ac4df5e7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -38,64 +38,61 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { // swiftlint:disable:next cyclomatic_complexity func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy, groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol? { - var sender = eventItemProxy.sender - if let senderAvatarURL = eventItemProxy.sender.avatarURL, - let image = mediaProvider.imageFromURL(senderAvatarURL, avatarSize: .user(on: .timeline)) { - sender.avatar = image - } - let isOutgoing = eventItemProxy.isOwn switch eventItemProxy.content.kind() { case .unableToDecrypt(let encryptedMessage): - return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, sender, isOutgoing, groupState) + return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing, groupState) case .redactedMessage: - return buildRedactedTimelineItem(eventItemProxy, sender, isOutgoing, groupState) - case .sticker(let body, let imageInfo, let url): - return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, sender, isOutgoing, groupState) + return buildRedactedTimelineItem(eventItemProxy, isOutgoing, groupState) + case .sticker(let body, let imageInfo, let urlString): + guard let url = URL(string: urlString) else { + MXLog.error("Invalid sticker url string: \(urlString)") + return buildUnsupportedTimelineItem(eventItemProxy, "m.sticker", "Invalid Sticker URL", isOutgoing, groupState) + } + + return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, isOutgoing, groupState) case .failedToParseMessageLike(let eventType, let error): - return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, sender, isOutgoing, groupState) + return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState) case .failedToParseState(let eventType, _, let error): - return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, sender, isOutgoing, groupState) + return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState) case .message: guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") } switch messageContent.msgtype() { case .text(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildTextTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState) + return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) case .image(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildImageTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState) + return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) case .video(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildVideoTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState) + return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) case .file(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildFileTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState) + return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) case .notice(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildNoticeTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState) + return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) case .emote(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildEmoteTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState) + return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) case .none: - return buildFallbackTimelineItem(eventItemProxy, sender, isOutgoing, groupState) + return buildFallbackTimelineItem(eventItemProxy, isOutgoing, groupState) } case .state(let stateKey, let content): - return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, sender: sender, isOutgoing: isOutgoing) + return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing) case .roomMembership(userId: let userID, change: let change): - return buildStateMembershipChangeTimelineItemFor(eventItemProxy: eventItemProxy, member: userID, membershipChange: change, sender: sender, isOutgoing: isOutgoing) + return buildStateMembershipChangeTimelineItemFor(eventItemProxy: eventItemProxy, member: userID, membershipChange: change, isOutgoing: isOutgoing) } } // MARK: - Message Events - // swiftlint:disable:next function_parameter_count private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ eventType: String, _ error: String, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { UnsupportedRoomTimelineItem(id: eventItemProxy.id, @@ -106,7 +103,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties()) } @@ -114,8 +111,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ body: String, _ imageInfo: ImageInfo, - _ imageURLString: String, - _ sender: TimelineItemSender, + _ imageURL: URL, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? @@ -125,20 +121,14 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { aspectRatio = width / height } - var image: UIImage? - if let url = URL(string: imageURLString) { - image = mediaProvider.imageFromURL(url) - } - return StickerRoomTimelineItem(id: eventItemProxy.id, text: body, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, - imageURL: URL(string: imageURLString), - image: image, + sender: eventItemProxy.sender, + imageURL: imageURL, width: width, height: height, aspectRatio: aspectRatio, @@ -149,7 +139,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ encryptedMessage: EncryptedMessage, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { var encryptionType = EncryptedRoomTimelineItem.EncryptionType.unknown @@ -169,12 +158,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties()) } private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { RedactedRoomTimelineItem(id: eventItemProxy.id, @@ -183,12 +171,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties()) } private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { let attributedText = attributedStringBuilder.fromPlain(eventItemProxy.body) @@ -201,14 +188,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties(isEdited: eventItemProxy.content.asMessage()?.isEdited() ?? false, reactions: aggregateReactions(eventItemProxy.reactions))) } private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) @@ -221,7 +207,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) @@ -229,7 +215,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? @@ -244,9 +229,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, source: message.source, - image: mediaProvider.imageFromSource(message.source), width: message.width, height: message.height, aspectRatio: aspectRatio, @@ -258,7 +242,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? @@ -273,11 +256,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, duration: message.duration, source: message.source, thumbnailSource: message.thumbnailSource, - image: mediaProvider.imageFromSource(message.thumbnailSource), width: message.width, height: message.height, aspectRatio: aspectRatio, @@ -289,7 +271,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { FileRoomTimelineItem(id: message.id, @@ -298,7 +279,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, source: message.source, thumbnailSource: message.thumbnailSource, properties: RoomTimelineItemProperties(isEdited: message.isEdited, @@ -308,7 +289,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) @@ -321,7 +301,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) @@ -329,10 +309,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ sender: TimelineItemSender, _ isOutgoing: Bool, _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { - let name = sender.displayName ?? sender.id + let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id var attributedText: AttributedString? if let htmlBody = message.htmlBody { @@ -350,7 +329,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, - sender: sender, + sender: eventItemProxy.sender, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) @@ -368,28 +347,26 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildStateTimelineItemFor(eventItemProxy: EventTimelineItemProxy, state: OtherState, stateKey: String, - sender: TimelineItemSender, isOutgoing: Bool) -> RoomTimelineItemProtocol? { - guard let text = stateEventStringBuilder.buildString(for: state, stateKey: stateKey, sender: sender, isOutgoing: isOutgoing) else { return nil } - return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, sender: sender, isOutgoing: isOutgoing) + guard let text = stateEventStringBuilder.buildString(for: state, stateKey: stateKey, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil } + return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing) } private func buildStateMembershipChangeTimelineItemFor(eventItemProxy: EventTimelineItemProxy, member: String, membershipChange: MembershipChange, - sender: TimelineItemSender, isOutgoing: Bool) -> RoomTimelineItemProtocol? { guard let text = stateEventStringBuilder.buildString(for: membershipChange, member: member, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil } - return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, sender: sender, isOutgoing: isOutgoing) + return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing) } - private func buildStateTimelineItem(eventItemProxy: EventTimelineItemProxy, text: String, sender: TimelineItemSender, isOutgoing: Bool) -> RoomTimelineItemProtocol { + private func buildStateTimelineItem(eventItemProxy: EventTimelineItemProxy, text: String, isOutgoing: Bool) -> RoomTimelineItemProtocol { StateRoomTimelineItem(id: eventItemProxy.id, text: text, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), groupState: .single, isOutgoing: isOutgoing, isEditable: false, - sender: sender) + sender: eventItemProxy.sender) } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index f79acedcd..8b85c769a 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -190,12 +190,12 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationStackCoordinator) - let parameters = SettingsCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator, - userNotificationController: userNotificationController, - userSession: userSession, - bugReportService: bugReportService) - let settingsCoordinator = SettingsCoordinator(parameters: parameters) - settingsCoordinator.callback = { [weak self] action in + let parameters = SettingsScreenCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator, + userNotificationController: userNotificationController, + userSession: userSession, + bugReportService: bugReportService) + let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters) + settingsScreenCoordinator.callback = { [weak self] action in guard let self else { return } switch action { case .dismiss: @@ -206,7 +206,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { } } - settingsNavigationStackCoordinator.setRootCoordinator(settingsCoordinator) + settingsNavigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator) navigationSplitCoordinator.setSheetCoordinator(userNotificationController) { [weak self] in self?.stateMachine.processEvent(.dismissedSettingsScreen) diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 73459d763..31e688e30 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -96,7 +96,7 @@ class UserSessionStore: UserSessionStoreProtocol { imageCache.memoryStorage.config.keepWhenEnteringBackground = true return UserSession(clientProxy: clientProxy, - mediaProvider: MediaProvider(mediaProxy: clientProxy, + mediaProvider: MediaProvider(mediaLoader: clientProxy, imageCache: imageCache, fileCache: FileCache.default, backgroundTaskService: backgroundTaskService)) diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 50a9fa000..72b52f0fc 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -118,11 +118,11 @@ class MockScreen: Identifiable { return navigationStackCoordinator case .settings: let navigationStackCoordinator = NavigationStackCoordinator() - let coordinator = SettingsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, - userNotificationController: MockUserNotificationController(), - userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), - mediaProvider: MockMediaProvider()), - bugReportService: MockBugReportService())) + let coordinator = SettingsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, + userNotificationController: MockUserNotificationController(), + userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), + mediaProvider: MockMediaProvider()), + bugReportService: MockBugReportService())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .bugReport: diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index 90b67b917..c67373372 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -125,15 +125,15 @@ class NotificationServiceExtension: UNNotificationServiceExtension { let client = try builder.build() try client.restoreSession(session: credentials.restorationToken.session) - + MXLog.info("\(tag) creating media provider") - - return MediaProvider(mediaProxy: MediaProxy(client: client), + + return MediaProvider(mediaLoader: MediaLoader(client: client), imageCache: .onlyOnDisk, fileCache: FileCache.default, backgroundTaskService: nil) } - + private func notify() { MXLog.info("\(tag) notify") diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 949112853..905d58784 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -66,6 +66,7 @@ targets: sources: - path: ../Sources - path: ../SupportingFiles + - path: ../../ElementX/Sources/Generated - path: ../../ElementX/Sources/Services/Timeline/TimelineItemProxy.swift - path: ../../ElementX/Sources/Services/Timeline/TimelineItemSender.swift - path: ../../ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift @@ -75,11 +76,7 @@ targets: - path: ../../ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift - path: ../../ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift - path: ../../ElementX/Sources/Services/Notification/NotificationConstants.swift - - path: ../../ElementX/Sources/Services/Media/MediaProxyProtocol.swift - - path: ../../ElementX/Sources/Services/Media/MediaProxy.swift - - path: ../../ElementX/Sources/Services/Media/MediaProviderProtocol.swift - - path: ../../ElementX/Sources/Services/Media/MediaProvider.swift - - path: ../../ElementX/Sources/Services/Media/MediaSourceProxy.swift + - path: ../../ElementX/Sources/Services/Media - path: ../../ElementX/Sources/Services/Background/BackgroundTaskServiceProtocol.swift - path: ../../ElementX/Sources/Services/Background/BackgroundTaskProtocol.swift - path: ../../ElementX/Sources/Services/Cache/FileCache.swift diff --git a/UITests/Sources/RoomScreenUITests.swift b/UITests/Sources/RoomScreenUITests.swift index 03186ef87..3612a3766 100644 --- a/UITests/Sources/RoomScreenUITests.swift +++ b/UITests/Sources/RoomScreenUITests.swift @@ -24,7 +24,7 @@ class RoomScreenUITests: XCTestCase { app.goToScreenWithIdentifier(.roomPlainNoAvatar) XCTAssert(app.staticTexts["roomNameLabel"].exists) - XCTAssert(app.staticTexts["roomAvatarPlaceholderImage"].exists) + XCTAssert(app.staticTexts["roomAvatarImage"].exists) app.assertScreenshot(.roomPlainNoAvatar) } diff --git a/UITests/Sources/SettingsUITests.swift b/UITests/Sources/SettingsScreenUITests.swift similarity index 100% rename from UITests/Sources/SettingsUITests.swift rename to UITests/Sources/SettingsScreenUITests.swift diff --git a/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift b/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift new file mode 100644 index 000000000..6e3af9aeb --- /dev/null +++ b/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift @@ -0,0 +1,115 @@ +// +// Copyright 2023 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. +// + +@testable import ElementX +import MatrixRustSDK +import XCTest + +final class MediaLoaderTests: XCTestCase { + func testMediaRequestCoalescing() async { + let mediaLoadingClient = MockMediaLoadingClient() + let mediaLoader = MediaLoader(client: mediaLoadingClient) + + let mediaSource = MediaSourceProxy(url: URL.documentsDirectory) + + do { + for _ in 1...10 { + _ = try await mediaLoader.loadMediaContentForSource(mediaSource) + } + + XCTAssertEqual(mediaLoadingClient.numberOfInvocations, 10) + } catch { + fatalError() + } + } + + func testMediaThumbnailRequestCoalescing() async { + let mediaLoadingClient = MockMediaLoadingClient() + let mediaLoader = MediaLoader(client: mediaLoadingClient) + + let mediaSource = MediaSourceProxy(url: URL.documentsDirectory) + + do { + for _ in 1...10 { + _ = try await mediaLoader.loadMediaThumbnailForSource(mediaSource, width: 100, height: 100) + } + + XCTAssertEqual(mediaLoadingClient.numberOfInvocations, 10) + } catch { + fatalError() + } + } +} + +private class MockMediaLoadingClient: ClientProtocol { + private(set) var numberOfInvocations = 0 + + func getMediaContent(source: MatrixRustSDK.MediaSource) throws -> [UInt8] { + numberOfInvocations += 1 + return [] + } + + func getMediaThumbnail(source: MatrixRustSDK.MediaSource, width: UInt64, height: UInt64) throws -> [UInt8] { + numberOfInvocations += 1 + return [] + } + + // MARK: - Not implemented + + func setDelegate(delegate: MatrixRustSDK.ClientDelegate?) { } + + func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) throws { } + + func restoreSession(session: MatrixRustSDK.Session) throws { } + + func session() throws -> MatrixRustSDK.Session { fatalError() } + + func userId() throws -> String { fatalError() } + + func displayName() throws -> String { fatalError() } + + func setDisplayName(name: String) throws { } + + func avatarUrl() throws -> String { fatalError() } + + func deviceId() throws -> String { fatalError() } + + func accountData(eventType: String) throws -> String? { fatalError() } + + func setAccountData(eventType: String, content: String) throws { fatalError() } + + func uploadMedia(mimeType: String, content: [UInt8]) throws -> String { fatalError() } + + func getSessionVerificationController() throws -> MatrixRustSDK.SessionVerificationController { fatalError() } + + func fullSlidingSync() throws -> MatrixRustSDK.SlidingSync { fatalError() } + + func logout() throws { } + + func hasFirstSynced() -> Bool { fatalError() } + + func homeserver() -> String { fatalError() } + + func isSoftLogout() -> Bool { fatalError() } + + func isSyncing() -> Bool { fatalError() } + + func rooms() -> [MatrixRustSDK.Room] { fatalError() } + + func slidingSync() -> MatrixRustSDK.SlidingSyncBuilder { fatalError() } + + func startSync(timelineLimit: UInt16?) { } +} diff --git a/UnitTests/Sources/MediaProvider/MediaProviderTests.swift b/UnitTests/Sources/MediaProvider/MediaProviderTests.swift index 246b3d222..17c3034df 100644 --- a/UnitTests/Sources/MediaProvider/MediaProviderTests.swift +++ b/UnitTests/Sources/MediaProvider/MediaProviderTests.swift @@ -20,7 +20,7 @@ import XCTest @MainActor final class MediaProviderTests: XCTestCase { - private let mediaProxy = MockMediaProxy() + private let mediaLoader = MockMediaLoader() private let fileCache = MockFileCache() private var imageCache: MockImageCache! private var backgroundTaskService = MockBackgroundTaskService() @@ -29,14 +29,14 @@ final class MediaProviderTests: XCTestCase { override func setUp() { imageCache = MockImageCache(name: "Test") - mediaProvider = MediaProvider(mediaProxy: mediaProxy, + mediaProvider = MediaProvider(mediaLoader: mediaLoader, imageCache: imageCache, fileCache: fileCache, backgroundTaskService: backgroundTaskService) } func test_whenImageFromSourceWithSourceNil_nilReturned() throws { - let image = mediaProvider.imageFromSource(nil, avatarSize: .room(on: .timeline)) + let image = mediaProvider.imageFromSource(nil, size: AvatarSize.room(on: .timeline).scaledSize) XCTAssertNil(image) } @@ -46,17 +46,18 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImagesInMemory[key] = imageForKey - let image = mediaProvider.imageFromSource(MediaSourceProxy(url: url), avatarSize: avatarSize) + let image = mediaProvider.imageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) XCTAssertEqual(image, imageForKey) } func test_whenImageFromSourceWithSourceNotNilAndImageNotCached_nilReturned() throws { - let image = mediaProvider.imageFromSource(MediaSourceProxy(url: URL.picturesDirectory), avatarSize: .room(on: .timeline)) + let image = mediaProvider.imageFromSource(MediaSourceProxy(url: URL.picturesDirectory), + size: AvatarSize.room(on: .timeline).scaledSize) XCTAssertNil(image) } func test_whenImageFromURLStringWithURLStringNil_nilReturned() throws { - let image = mediaProvider.imageFromURL(nil, avatarSize: .room(on: .timeline)) + let image = mediaProvider.imageFromURL(nil, size: AvatarSize.room(on: .timeline).scaledSize) XCTAssertNil(image) } @@ -66,12 +67,12 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImagesInMemory[key] = imageForKey - let image = mediaProvider.imageFromURL(url, avatarSize: avatarSize) + let image = mediaProvider.imageFromURL(url, size: avatarSize.scaledSize) XCTAssertEqual(image, imageForKey) } func test_whenImageFromURLStringWithURLStringNotNilAndImageNotCached_nilReturned() throws { - let image = mediaProvider.imageFromURL(URL.picturesDirectory, avatarSize: .room(on: .timeline)) + let image = mediaProvider.imageFromURL(URL.picturesDirectory, size: AvatarSize.room(on: .timeline).scaledSize) XCTAssertNil(image) } @@ -81,7 +82,7 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImagesInMemory[key] = imageForKey - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), avatarSize: avatarSize) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) XCTAssertEqual(Result.success(imageForKey), result) } @@ -91,15 +92,15 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImages[key] = imageForKey - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), avatarSize: avatarSize) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) XCTAssertEqual(Result.success(imageForKey), result) } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFails_imageThumbnailIsLoaded() async throws { let avatarSize = AvatarSize.room(on: .timeline) let expectedImage = try loadTestImage() - mediaProxy.mediaThumbnailData = expectedImage.pngData() - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), avatarSize: avatarSize) + mediaLoader.mediaThumbnailData = expectedImage.pngData() + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), size: avatarSize.scaledSize) switch result { case .success(let image): XCTAssertEqual(image.pngData(), expectedImage.pngData()) @@ -113,16 +114,16 @@ final class MediaProviderTests: XCTestCase { let url = URL.picturesDirectory let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let expectedImage = try loadTestImage() - mediaProxy.mediaThumbnailData = expectedImage.pngData() - _ = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), avatarSize: avatarSize) + mediaLoader.mediaThumbnailData = expectedImage.pngData() + _ = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) let storedImage = try XCTUnwrap(imageCache.storedImages[key]) XCTAssertEqual(expectedImage.pngData(), storedImage.pngData()) } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSize_imageContentIsLoaded() async throws { let expectedImage = try loadTestImage() - mediaProxy.mediaContentData = expectedImage.pngData() - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), avatarSize: nil) + mediaLoader.mediaContentData = expectedImage.pngData() + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), size: nil) switch result { case .success(let image): XCTAssertEqual(image.pngData(), expectedImage.pngData()) @@ -132,7 +133,8 @@ final class MediaProviderTests: XCTestCase { } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndLoadImageThumbnailFails_errorIsThrown() async throws { - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), avatarSize: AvatarSize.room(on: .timeline)) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), + size: AvatarSize.room(on: .timeline).scaledSize) switch result { case .success: XCTFail("Should fail") @@ -142,7 +144,7 @@ final class MediaProviderTests: XCTestCase { } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSizeAndLoadImageContentFails_errorIsThrown() async throws { - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), avatarSize: nil) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), size: nil) switch result { case .success: XCTFail("Should fail") @@ -152,8 +154,9 @@ final class MediaProviderTests: XCTestCase { } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndImageThumbnailIsLoadedWithCorruptedData_errorIsThrown() async throws { - mediaProxy.mediaThumbnailData = Data() - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), avatarSize: AvatarSize.room(on: .timeline)) + mediaLoader.mediaThumbnailData = Data() + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), + size: AvatarSize.room(on: .timeline).scaledSize) switch result { case .success: XCTFail("Should fail") @@ -187,31 +190,31 @@ final class MediaProviderTests: XCTestCase { func test_whenLoadFileFromSourceAndNoFileFromSourceExists_mediaLoadedFromSource() async throws { let expectedURL = URL(filePath: "/some/file/path") let expectedResult: Result = .success(expectedURL) - mediaProxy.mediaContentData = try loadTestImage().pngData() + mediaLoader.mediaContentData = try loadTestImage().pngData() fileCache.storeURLToReturn = expectedURL let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") XCTAssertEqual(result, expectedResult) - XCTAssertEqual(mediaProxy.mediaContentData, fileCache.storedData) + XCTAssertEqual(mediaLoader.mediaContentData, fileCache.storedData) XCTAssertEqual("test1", fileCache.storedFileKey) XCTAssertEqual("png", fileCache.storedFileExtension) } func test_whenLoadFileFromSourceAndNoFileFromSourceExistsAndLoadContentSourceFails_failureIsReturned() async throws { let expectedResult: Result = .failure(.failedRetrievingImage) - mediaProxy.mediaContentData = nil + mediaLoader.mediaContentData = nil let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") XCTAssertEqual(result, expectedResult) } func test_whenLoadFileFromSourceAndNoFileFromSourceExistsAndStoreDataFails_failureIsReturned() async throws { let expectedResult: Result = .failure(.failedRetrievingImage) - mediaProxy.mediaContentData = try loadTestImage().pngData() + mediaLoader.mediaContentData = try loadTestImage().pngData() let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") XCTAssertEqual(result, expectedResult) } func test_whenFileFromURLStringAndURLIsNil_nilIsReturned() async throws { - mediaProxy.mediaContentData = try loadTestImage().pngData() + mediaLoader.mediaContentData = try loadTestImage().pngData() let url = mediaProvider.fileFromURL(nil, fileExtension: "png") XCTAssertNil(url) } diff --git a/UnitTests/Sources/MediaProvider/MockMediaProxy.swift b/UnitTests/Sources/MediaProvider/MockMediaLoader.swift similarity index 87% rename from UnitTests/Sources/MediaProvider/MockMediaProxy.swift rename to UnitTests/Sources/MediaProvider/MockMediaLoader.swift index a096d16c8..13181c92f 100644 --- a/UnitTests/Sources/MediaProvider/MockMediaProxy.swift +++ b/UnitTests/Sources/MediaProvider/MockMediaLoader.swift @@ -16,11 +16,11 @@ @testable import ElementX import Foundation -enum MockMediaProxyError: Error { +enum MockMediaLoaderError: Error { case someError } -class MockMediaProxy: MediaProxyProtocol { +class MockMediaLoader: MediaLoaderProtocol { var mediaContentData: Data? var mediaThumbnailData: Data? @@ -32,7 +32,7 @@ class MockMediaProxy: MediaProxyProtocol { if let mediaContentData { return mediaContentData } else { - throw MockMediaProxyError.someError + throw MockMediaLoaderError.someError } } @@ -40,7 +40,7 @@ class MockMediaProxy: MediaProxyProtocol { if let mediaThumbnailData { return mediaThumbnailData } else { - throw MockMediaProxyError.someError + throw MockMediaLoaderError.someError } } } diff --git a/UnitTests/Sources/SettingsViewModelTests.swift b/UnitTests/Sources/SettingsViewModelTests.swift index b9f070d3a..c84190647 100644 --- a/UnitTests/Sources/SettingsViewModelTests.swift +++ b/UnitTests/Sources/SettingsViewModelTests.swift @@ -19,14 +19,14 @@ import XCTest @testable import ElementX @MainActor -class SettingsViewModelTests: XCTestCase { - var viewModel: SettingsViewModelProtocol! - var context: SettingsViewModelType.Context! +class SettingsScreenViewModelTests: XCTestCase { + var viewModel: SettingsScreenViewModelProtocol! + var context: SettingsScreenViewModelType.Context! @MainActor override func setUpWithError() throws { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""), mediaProvider: MockMediaProvider()) - viewModel = SettingsViewModel(withUserSession: userSession) + viewModel = SettingsScreenViewModel(withUserSession: userSession) context = viewModel.context }