diff --git a/DesignKit/Sources/Buttons/ElementActionButtonStyle.swift b/DesignKit/Sources/Buttons/ElementActionButtonStyle.swift index bea14a6dc..93b1e6f56 100644 --- a/DesignKit/Sources/Buttons/ElementActionButtonStyle.swift +++ b/DesignKit/Sources/Buttons/ElementActionButtonStyle.swift @@ -34,12 +34,14 @@ public struct ElementActionButtonStyle: ButtonStyle { public var size: ElementControlSize public var color: Color - private var verticalPadding: CGFloat { size == .xLarge ? 12 : 4 } + private var cornerRadius: CGFloat { size == .xLarge ? 14 : 8 } + private var verticalPadding: CGFloat { size == .xLarge ? 14 : 4 } private var maxWidth: CGFloat? { size == .xLarge ? .infinity : nil } private var fontColor: Color { // Always white unless disabled with a dark theme. - .white.opacity(colorScheme == .dark && !isEnabled ? 0.3 : 1.0) + Color.element.systemPrimaryBackground + .opacity(colorScheme == .dark && !isEnabled ? 0.3 : 1.0) } public init(size: ElementControlSize = .regular, color: Color = .element.accent) { @@ -53,9 +55,9 @@ public struct ElementActionButtonStyle: ButtonStyle { .padding(.vertical, verticalPadding) .frame(maxWidth: maxWidth) .foregroundColor(fontColor) - .font(.element.body) + .font(.element.bodyBold) .background(color.opacity(backgroundOpacity(when: configuration.isPressed))) - .cornerRadius(8.0) + .cornerRadius(cornerRadius) } private func backgroundOpacity(when isPressed: Bool) -> CGFloat { diff --git a/DesignKit/Sources/Buttons/ElementCapsuleButtonStyle.swift b/DesignKit/Sources/Buttons/ElementCapsuleButtonStyle.swift new file mode 100644 index 000000000..086046e2b --- /dev/null +++ b/DesignKit/Sources/Buttons/ElementCapsuleButtonStyle.swift @@ -0,0 +1,87 @@ +// +// 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 + +public extension ButtonStyle where Self == ElementCapsuleButtonStyle { + /// A button style that uses a capsule shape with a regular appearance. + static var elementCapsule: ElementCapsuleButtonStyle { + ElementCapsuleButtonStyle(isProminent: false) + } + + /// A button style that uses a capsule shape with a prominent appearance. + static var elementCapsuleProminent: ElementCapsuleButtonStyle { + ElementCapsuleButtonStyle(isProminent: true) + } +} + +public struct ElementCapsuleButtonStyle: ButtonStyle { + let isProminent: Bool + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(7) + .frame(maxWidth: .infinity) + .font(.element.footnote) + .foregroundColor(fontColor) + .multilineTextAlignment(.center) + .background(background) + .opacity(configuration.isPressed ? 0.6 : 1) + } + + @ViewBuilder + var background: some View { + if isProminent { + Capsule() + .foregroundColor(Color.element.accent) + } else { + Capsule() + .stroke(Color.element.accent) + } + } + + var fontColor: Color { + isProminent ? .element.systemPrimaryBackground : .element.systemPrimaryLabel + } +} + +struct ElementCapsuleButtonStyle_Previews: PreviewProvider { + public static var states: some View { + VStack { + Button("Enabled") { /* preview */ } + .buttonStyle(.elementCapsuleProminent) + + Button("Disabled") { /* preview */ } + .buttonStyle(.elementCapsuleProminent) + .disabled(true) + + Button("Enabled") { /* preview */ } + .buttonStyle(.elementCapsule) + + Button("Disabled") { /* preview */ } + .buttonStyle(.elementCapsule) + .disabled(true) + } + .padding() + } + + public static var previews: some View { + states + .preferredColorScheme(.light) + states + .preferredColorScheme(.dark) + } +} diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f3e5eefe0..2489417eb 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 01CB8ACFA5E143E89C168CA8 /* TimelineItemContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */; }; 01F4A40C1EDCEC8DC4EC9CFA /* WeakDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109C0201D8CB3F947340DC80 /* WeakDictionary.swift */; }; 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; - 03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */; }; 03CB204C52F18E24A5C3D219 /* UITestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967873B9E11828B67F64C89A /* UITestsAppCoordinator.swift */; }; 03D684A3AE85A23B3DA3B43F /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26747B3154A5DBC3A7E24A5 /* Image.swift */; }; 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422724361B6555364C43281E /* RoomHeaderView.swift */; }; @@ -27,6 +26,7 @@ 07240B7159A3990C4C2E8FFC /* LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D256FEE2F1AF1E51D39B622 /* LoginTests.swift */; }; 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */; }; 09AAF04B27732046C755D914 /* SoftLogoutViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */; }; + 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */; }; 0B1F80C2BF7D223159FBA82C /* ImageAnonymizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6045E825AE900A92D61FEFF0 /* ImageAnonymizerTests.swift */; }; 0C38C3E771B472E27295339D /* SessionVerificationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BB9A17AC512A7EF4B106E5 /* SessionVerificationModels.swift */; }; 0E8C480700870BB34A2A360F /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 4346F63D53A346271577FD9C /* AppAuth */; }; @@ -53,16 +53,15 @@ 191161FE9E0DA89704301F37 /* Untranslated.strings in Resources */ = {isa = PBXBuildFile; fileRef = D2F7194F440375338F8E2487 /* Untranslated.strings */; }; 1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */; }; 19839F3526CE8C35AAF241AD /* ServerSelectionViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F52BF30D12BA3BD3D3DBB8F /* ServerSelectionViewModelProtocol.swift */; }; - 1999ECC6777752A2616775CF /* MemberDetailsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A152791A2F56BD193BFE986 /* MemberDetailsProvider.swift */; }; 1A70A2199394B5EC660934A5 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = A678E40E917620059695F067 /* MatrixRustSDK */; }; 1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */; }; 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05707BF550D770168A406DB /* LoginViewModelTests.swift */; }; 1F3232BD368DF430AB433907 /* DesignKit in Frameworks */ = {isa = PBXBuildFile; productRef = A5A56C4F47C368EBE5C5E870 /* DesignKit */; }; 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; }; 214C6B416609E58CCBF6DCEE /* SoftLogoutModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC77FC5C4F2000133047AA27 /* SoftLogoutModels.swift */; }; - 224A55EEAEECF5336B14A4A5 /* EmoteRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */; }; 2276870A19F34B3FFFDA690F /* SoftLogoutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AEA20A6B4883E60469ACF8F /* SoftLogoutCoordinator.swift */; }; 22DADD537401E79D66132134 /* NavigationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4173A48FD8542CD4AD3645C /* NavigationRouter.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 */; }; 24906A1E82D0046655958536 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CF12478983A5EB390FB26 /* MessageComposer.swift */; }; @@ -72,13 +71,11 @@ 28410F3DE89C2C44E4F75C92 /* MockBugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E7BF8F7BB1021F889C6483 /* MockBugReportService.swift */; }; 290FDB0FFDC2F1DDF660343E /* TestMeasurementParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */; }; 297CD0A27C87B0C50FF192EE /* RoomTimelineViewFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE384418EB1FEDFA62C9CD0 /* RoomTimelineViewFactoryProtocol.swift */; }; - 29AEE68A604940180AB9EBFF /* MockRoomSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BDAC8895AB2B77B47703AE /* MockRoomSummary.swift */; }; 29EE1791E0AFA1ABB7F23D2F /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = A981A4CA233FB5C13B9CA690 /* SwiftyBeaver */; }; 2A90D9F91A836E30B7D78838 /* MXLogObjcWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E438DBCBDC7A41B95DDDD9 /* MXLogObjcWrapper.m */; }; 2BA59D0AEFB4B82A2EC2A326 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50009897F60FAE7D63EF5E5B /* Kingfisher */; }; 2BAA5B222856068158D0B3C6 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = B1E8B697DF78FE7F61FC6CA4 /* MatrixRustSDK */; }; 2C0CE61E5DC177938618E0B1 /* RootRouterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90733775209F4D4D366A268F /* RootRouterType.swift */; }; - 2E59008365E01F0AFB3A6B24 /* ImageRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF686BA36D0C2FA3C63DFDF /* ImageRoomMessage.swift */; }; 2F1CF90A3460C153154427F0 /* RoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086B997409328F091EBA43CE /* RoomScreenUITests.swift */; }; 2F30EFEB7BD39242D1AD96F3 /* LoginViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E1FB768A24FDD2A5CA16E3C /* LoginViewModelProtocol.swift */; }; 2F94054F50E312AF30BE07F3 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B21E611DADDEF00307E7AC /* String.swift */; }; @@ -87,7 +84,6 @@ 308BD9343B95657FAA583FB7 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = AD2AC190E55B2BD4D0F1D4A7 /* SwiftyBeaver */; }; 3097A0A867D2B19CE32DAE58 /* UIKitBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1FFC3336EB23374BBBFCC /* UIKitBackgroundTaskService.swift */; }; 313382FC5D38064EAAA35CB2 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D1CC633517D695FEC54208 /* FileManager.swift */; }; - 33B4E59D408AE6E02323EE41 /* NoticeRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDA364DFFC3AC71C4771251 /* NoticeRoomMessage.swift */; }; 33CAC1226DFB8B5D8447D286 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; 344AF4CBB6D8786214878642 /* NavigationRouterStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9D5F812E5AD6DC786DBC9B /* NavigationRouterStoreProtocol.swift */; }; 34966D4C1C2C6D37FE3F7F50 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */; }; @@ -105,15 +101,14 @@ 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 */; }; - 3D325A1147F6281C57BFCDF6 /* EventBrief.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4411C0DA0087A1CB143E96FA /* EventBrief.swift */; }; 3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DC38E64A5ED3FDB201029A /* BugReportService.swift */; }; 3ED2725734568F6B8CC87544 /* AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */; }; 3F2148F11164C7C5609984EB /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 19CD5B074D7DD44AF4C58BB6 /* SwiftState */; }; 407DCE030E0F9B7C9861D38A /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; - 418B4AEFD03DC7A6D2C9D5C8 /* EventBriefFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36322DD0D4E29D31B0945ADC /* EventBriefFactory.swift */; }; 41DFDD212D1BE57CA50D783B /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = FD43A50D9B75C9D6D30F006B /* SwiftyBeaver */; }; 438FB9BC535BC95948AA5F34 /* SettingsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2F9D5C39A4494D19F33E38 /* SettingsViewModelProtocol.swift */; }; 43FD77998F33C32718C51450 /* TemplateCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD460ED7ED1E03B85DEA25C /* TemplateCoordinator.swift */; }; + 447E8580A0A2569E32529E17 /* MockRoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */; }; 462813B93C39DF93B1249403 /* RoundedToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFABDF2E19D349DAAAC18C65 /* RoundedToastView.swift */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; 4669804D0369FBED4E8625D1 /* ToastViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4470B8CB654B097D807AA713 /* ToastViewPresenter.swift */; }; @@ -154,6 +149,7 @@ 62BBF5BE7B905222F0477FF2 /* MediaSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8210612D17A39369480FC183 /* MediaSource.swift */; }; 63C9AF0FB8278AF1C0388A0C /* TemplateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB10E673916D2B8D21FD197 /* TemplateModels.swift */; }; 64FF5CB4E35971255872E1BB /* AuthenticationServiceProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0CB536D1C3CC15AA740CC6 /* AuthenticationServiceProxyProtocol.swift */; }; + 663E198678778F7426A9B27D /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FAFE1C2149E6AC8156ED2B /* Collection.swift */; }; 6647430A45B4A8E692909A8F /* EmoteRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */; }; 67C05C50AD734283374605E3 /* MatrixEntityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */; }; 67E391A2E00709FB41903B36 /* MockMediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6920A4869821BF72FFC58842 /* MockMediaProvider.swift */; }; @@ -228,6 +224,7 @@ 9738F894DB1BD383BE05767A /* ElementSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1027BB9A852F445B7623897F /* ElementSettings.swift */; }; 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */; }; 97CECF91D68235F1D13598D7 /* AnalyticsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77B3D4950F1707E66E4A45A /* AnalyticsConfiguration.swift */; }; + 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */; }; 989029A28C9E2F828AD6658A /* AppIcon.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */; }; 992F5E750F5030C4BA2D0D03 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01C4C7DB37597D7D8379511A /* Assets.xcassets */; }; 99ED42B8F8D6BFB1DBCF4C45 /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = D661CAB418C075A94306A792 /* AnalyticsEvents */; }; @@ -245,19 +242,20 @@ 9E8AE387FD03E4F1C1B8815A /* SessionVerificationStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06FCD42EEFEFC220F14EAC5 /* SessionVerificationStateMachine.swift */; }; A00DFC1DD3567B1EDC9F8D16 /* SplashScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325A2B3278875554DDEB8A9B /* SplashScreenUITests.swift */; }; A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */; }; + A0A26E4F713596856470EF1A /* RoomTimelineProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABAECB0CA5FF8F8E6F10DD7 /* RoomTimelineProviderItem.swift */; }; A32517FB1CA0BBCE2BC75249 /* BugReportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6C07DA7D3FF193F7419F55 /* BugReportCoordinator.swift */; }; A371629728E597C5FCA3C2B2 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FC861755C6388F62B9280A /* Analytics.swift */; }; A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287FC98AF2664EAD79C0D902 /* UIDevice.swift */; }; A4E885358D7DD5A072A06824 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCE5BF78B125320CBF3BB834 /* PostHog */; }; A50849766F056FD1DB942DEA /* AlertInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EEB64CC6F3DF5B68736A6B4 /* AlertInfo.swift */; }; A5C8F013ED9FB8AA6FEE18A7 /* InfoPlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A901D95158B02CA96C79C7F /* InfoPlist.swift */; }; - A5EC21A071F58FC1229C20D0 /* MemberDetailsProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09747989908EC5E4AA29F844 /* MemberDetailsProviderProtocol.swift */; }; A636D4900E0D98ED91536482 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3EDF23226895776553F04A /* AppCoordinator.swift */; }; A663FE6704CB500EBE782AE1 /* AnalyticsPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4DE1CF8F5EFD353B1A5E36F /* AnalyticsPromptCoordinator.swift */; }; A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; }; A8EC7C9D886244DAE9433E37 /* SessionVerificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C18FAAD59AE7F1462D817E /* SessionVerificationViewModel.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 */; }; AB4C5D62A21AD712811CE8CD /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68232D336E2B546AD95B78B5 /* XCUIElement.swift */; }; @@ -281,11 +279,11 @@ BB6B0B91CE11E06330017000 /* SessionVerificationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */; }; BCC3EDB7AD0902797CB4BBC2 /* MXLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = EF188681D6B6068CFAEAFC3F /* MXLogger.m */; }; BCEC41FB1F2BB663183863E4 /* LoginServerInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D379E13DD9D987470A3C70C /* LoginServerInfoSection.swift */; }; - BE3237142FA6E1A13C0E7D11 /* RoomSummaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */; }; BEEC06EFD30BFCA02F0FD559 /* UserIndicatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8EA85D4F10D7445BB6368A /* UserIndicatorTests.swift */; }; BF35062D06888FA80BD139FF /* Presentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB7F9D6FC121204D59E18DF /* Presentable.swift */; }; C052A8CDC7A8E7A2D906674F /* UserIndicatorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAA6438B00FDB130F404E31 /* UserIndicatorStore.swift */; }; C2CF93B067FD935E4F82FE44 /* SplashScreenPageIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850064FF8D7DB9C875E7AA1A /* SplashScreenPageIndicator.swift */; }; + C35CF4DAB1467FE1BBDC204B /* MessageTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */; }; C4180F418235DAD9DD173951 /* TemplateScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9873076F224E4CE09D8BD47D /* TemplateScreenUITests.swift */; }; C4F69156C31A447FEFF2A47C /* DTHTMLElement+AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */; }; C55A44C99F64A479ABA85B46 /* RoomScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */; }; @@ -303,7 +301,6 @@ CE7A715947ABAB1DEB5C21D7 /* SplashScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F7A812F160E75B69A9181A2 /* SplashScreenCoordinator.swift */; }; CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41FABA2B0AEF4389986495 /* LoginMode.swift */; }; CF82143AA4A4F7BD11D22946 /* RoomTimelineViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB6C5E4950B6C9842F35A38 /* RoomTimelineViewProvider.swift */; }; - D013E70C8E28E43497820444 /* TextRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4110685D9CA159F3FD2D6BA1 /* TextRoomMessage.swift */; }; D034A195A3494E38BF060485 /* MockSessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A9CCCF53495CF3D7B19FCE /* MockSessionVerificationControllerProxy.swift */; }; D0619D2E6B9C511190FBEB95 /* RoomMessageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607974D08BD2AF83725D817A /* RoomMessageProtocol.swift */; }; D5EA4C6C80579279770D5804 /* ImageRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */; }; @@ -322,6 +319,7 @@ E01373F2043E76393A0CE073 /* AnalyticsPromptViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B74ACE8D71747E1044A9C /* AnalyticsPromptViewModel.swift */; }; E0A4DCA633D174EB43AD599F /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */; }; E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; }; + E290C78E7F09F47FD2662986 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; }; E3CA565A4B9704F191B191F0 /* JoinedRoomSize+MemberCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF9AEA706926DD0DA2B954C /* JoinedRoomSize+MemberCount.swift */; }; E47CD939D8480657D4B706C6 /* AnalyticsPromptCheckmarkItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA7B2E9CC5DC3B76ADC35A43 /* AnalyticsPromptCheckmarkItem.swift */; }; E481C8FDCB6C089963C95344 /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 527578916BD388A09F5A8036 /* DTCoreText */; }; @@ -346,7 +344,7 @@ 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 */; }; - F78C57B197DA74735FEBB42C /* EventBriefFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B61C243325DC76D3086494 /* EventBriefFactoryProtocol.swift */; }; + F764BE976EAB76D63E7C1678 /* RoundedCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8023C7413A426FBF0A52B684 /* RoundedCorner.swift */; }; F99FB21EFC6D99D247FE7CBE /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = D82E84F90358CC1118E6034B /* Introspect */; }; FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */; }; FC6B7436C3A5B3D0565227D5 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF05352F28D4E7336228E9F4 /* ActivityIndicatorView.swift */; }; @@ -394,7 +392,6 @@ 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 = ""; }; - 09747989908EC5E4AA29F844 /* MemberDetailsProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailsProviderProtocol.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 = ""; }; 0B869438A1B52836F912A702 /* MockSoftLogoutScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftLogoutScreenState.swift; sourceTree = ""; }; @@ -412,6 +409,7 @@ 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = ""; }; 105D16E7DB0CCE9526612BDD /* bn-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-IN"; path = "bn-IN.lproj/Localizable.strings"; sourceTree = ""; }; 109C0201D8CB3F947340DC80 /* WeakDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionary.swift; sourceTree = ""; }; + 10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderProtocol.swift; sourceTree = ""; }; 111B698739E3410E2CDB7144 /* MXLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MXLog.swift; sourceTree = ""; }; 113356152C099951A6D17D85 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; 1215A4FC53D2319E81AE8970 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -470,7 +468,6 @@ 33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 351E89CE2ED9B73C5CC47955 /* TimelineReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReactionsView.swift; sourceTree = ""; }; 35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - 36322DD0D4E29D31B0945ADC /* EventBriefFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBriefFactory.swift; sourceTree = ""; }; 3747C96188856006F784BF49 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ko; path = ko.lproj/Localizable.stringsdict; sourceTree = ""; }; 3782C506F4FF1AADF61B6212 /* tlh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tlh; path = tlh.lproj/Localizable.strings; sourceTree = ""; }; 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadableFrameModifier.swift; sourceTree = ""; }; @@ -492,11 +489,9 @@ 3FAA6438B00FDB130F404E31 /* UserIndicatorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorStore.swift; sourceTree = ""; }; 3FEE631F3A4AFDC6652DD9DA /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = ""; }; 40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; - 4110685D9CA159F3FD2D6BA1 /* TextRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomMessage.swift; sourceTree = ""; }; 4112D04077F6709C5CA0A13E /* FullscreenLoadingViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenLoadingViewPresenter.swift; sourceTree = ""; }; 422724361B6555364C43281E /* RoomHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHeaderView.swift; sourceTree = ""; }; 434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; - 4411C0DA0087A1CB143E96FA /* EventBrief.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBrief.swift; sourceTree = ""; }; 4470B8CB654B097D807AA713 /* ToastViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewPresenter.swift; sourceTree = ""; }; 4488F5F92A64A137665C96CD /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = pa.lproj/Localizable.strings; sourceTree = ""; }; 44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = ""; }; @@ -572,7 +567,6 @@ 68232D336E2B546AD95B78B5 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; 68706A66BBA04268F7747A2F /* ActivityIndicatorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorPresenter.swift; sourceTree = ""; }; 6920A4869821BF72FFC58842 /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = ""; }; - 6A152791A2F56BD193BFE986 /* MemberDetailsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailsProvider.swift; sourceTree = ""; }; 6A1AAC8EB2992918D01874AC /* rue */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = rue; path = rue.lproj/Localizable.strings; sourceTree = ""; }; 6A6C4BE591FE5C38CE9C7EF3 /* UserProperties+Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProperties+Element.swift"; sourceTree = ""; }; 6A901D95158B02CA96C79C7F /* InfoPlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPlist.swift; sourceTree = ""; }; @@ -607,6 +601,7 @@ 7E154FEA1E6FE964D3DF7859 /* fy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fy; path = fy.lproj/Localizable.strings; sourceTree = ""; }; 7E532D95330139D118A9BF88 /* BugReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModel.swift; sourceTree = ""; }; 7FFCC48E7F701B6C24484593 /* WeakDictionaryKeyReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionaryKeyReference.swift; sourceTree = ""; }; + 8023C7413A426FBF0A52B684 /* RoundedCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorner.swift; sourceTree = ""; }; 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = ""; }; 8140010A796DB2C7977B6643 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 8166F121C79C7B62BF01D508 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = pt; path = pt.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -626,16 +621,15 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.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 = ""; }; - 8BF686BA36D0C2FA3C63DFDF /* ImageRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomMessage.swift; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; - 8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailProviderManager.swift; sourceTree = ""; }; 8C37FB986891D90BEAA93EAE /* UserSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStore.swift; sourceTree = ""; }; + 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 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 = ""; }; + 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = ""; }; 90733775209F4D4D366A268F /* RootRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootRouterType.swift; sourceTree = ""; }; - 92B61C243325DC76D3086494 /* EventBriefFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBriefFactoryProtocol.swift; sourceTree = ""; }; 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = ""; }; 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = ""; }; 938BD1FCD9E6FF3FCFA7AB4C /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-CN"; path = "zh-CN.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -655,6 +649,7 @@ 997783054A2E95F9E624217E /* kaa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kaa; path = kaa.lproj/Localizable.strings; sourceTree = ""; }; 99DE232F24EAD72A3DF7EF1A /* kab */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = kab; path = kab.lproj/Localizable.stringsdict; sourceTree = ""; }; 9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportServiceProtocol.swift; sourceTree = ""; }; + 9ABAECB0CA5FF8F8E6F10DD7 /* RoomTimelineProviderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderItem.swift; sourceTree = ""; }; 9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMeasurementParser.swift; sourceTree = ""; }; 9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = ""; }; 9C7F7DE62D33C6A26CBFCD72 /* IntegrationTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = IntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -669,6 +664,7 @@ A1ED7E89865201EE7D53E6DA /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = ""; }; A2B6433F516F1E6DFA0E2D89 /* vls */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vls; path = vls.lproj/Localizable.strings; sourceTree = ""; }; A30A1758E2B73EF38E7C42F8 /* ServerSelectionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionModels.swift; sourceTree = ""; }; + A40C19719687984FD9478FBE /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; A436057DBEA1A23CA8CB1FD7 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = ""; }; A443FAE2EE820A5790C35C8D /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = ""; }; @@ -680,6 +676,7 @@ A8D1CC633517D695FEC54208 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; A8F48EB9B52E70285A4BCB07 /* ur */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ur; path = ur.lproj/Localizable.strings; sourceTree = ""; }; A9873374E72AA53260AE90A2 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; + A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = ""; }; AA8BA82CF99D843FEF680E91 /* AnalyticsPromptModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptModels.swift; sourceTree = ""; }; AAC9344689121887B74877AF /* UnitTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -710,7 +707,6 @@ B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = ""; }; B64F3A3D0DF86ED5A241AB05 /* ActivityIndicatorView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActivityIndicatorView.xib; sourceTree = ""; }; B695D0D12086158BAD1D9859 /* UserIndicatorPresenterSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorPresenterSpy.swift; sourceTree = ""; }; - B6BDAC8895AB2B77B47703AE /* MockRoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummary.swift; sourceTree = ""; }; B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; B7E035C6AC137C9392D98814 /* lv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lv; path = lv.lproj/Localizable.strings; sourceTree = ""; }; B80D1901BA0B095E27793EDE /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; @@ -728,7 +724,6 @@ C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = ""; }; C06FCD42EEFEFC220F14EAC5 /* SessionVerificationStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationStateMachine.swift; sourceTree = ""; }; C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; - C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProtocol.swift; sourceTree = ""; }; C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = ""; }; C483956FA3D665E3842E319A /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = ""; }; @@ -744,12 +739,12 @@ CACA846B3E3E9A521D98B178 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; CBA95E52C4C6EE8769A63E57 /* eo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eo; path = eo.lproj/Localizable.strings; sourceTree = ""; }; CBBCC6E74774E79B599625D0 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - CBDA364DFFC3AC71C4771251 /* NoticeRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomMessage.swift; sourceTree = ""; }; CBF9AEA706926DD0DA2B954C /* JoinedRoomSize+MemberCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JoinedRoomSize+MemberCount.swift"; sourceTree = ""; }; CC680E0E79D818706CB28CF8 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; CC7CCC6DE5FA623E31BA8546 /* RoomTimelineControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerProtocol.swift; sourceTree = ""; }; CCA431E6EDD71F7067B5F9E7 /* UITestsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsRootView.swift; sourceTree = ""; }; CCF86010A0A719A9A50EEC59 /* SessionVerificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationCoordinator.swift; sourceTree = ""; }; + CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; CDE3F3911FF7CC639BDE5844 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; CED34C87277BA3CCC6B6EC7A /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; CF3EDF23226895776553F04A /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; @@ -796,10 +791,12 @@ E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = ""; }; E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = ""; }; EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; + ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTimelineItem.swift; sourceTree = ""; }; EEE384418EB1FEDFA62C9CD0 /* RoomTimelineViewFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactoryProtocol.swift; sourceTree = ""; }; EF1593DD87F974F8509BB619 /* ElementAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementAnimations.swift; sourceTree = ""; }; EF188681D6B6068CFAEAFC3F /* MXLogger.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MXLogger.m; sourceTree = ""; }; @@ -822,7 +819,6 @@ FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = ""; }; FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = ""; }; FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = ""; }; - FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomMessage.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1009,6 +1005,7 @@ isa = PBXGroup; children = ( 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */, + 8023C7413A426FBF0A52B684 /* RoundedCorner.swift */, ); path = Views; sourceTree = ""; @@ -1024,16 +1021,6 @@ path = Generated; sourceTree = ""; }; - 33996F58948B54839D653EC1 /* Members */ = { - isa = PBXGroup; - children = ( - 8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */, - 6A152791A2F56BD193BFE986 /* MemberDetailsProvider.swift */, - 09747989908EC5E4AA29F844 /* MemberDetailsProviderProtocol.swift */, - ); - path = Members; - sourceTree = ""; - }; 3510020809E49EFA146296AD /* ServerSelection */ = { isa = PBXGroup; children = ( @@ -1090,7 +1077,6 @@ 96F37AB24AF5A006521D38D1 /* RoomMessageFactoryProtocol.swift */, A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */, 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */, - 33996F58948B54839D653EC1 /* Members */, 4658A940E89BC42EE3346A97 /* Messages */, 70DABA39C844CA931B829395 /* RoomSummary */, ); @@ -1101,8 +1087,10 @@ isa = PBXGroup; children = ( B6E89E530A8E92EC44301CA1 /* Bundle.swift */, + A9FAFE1C2149E6AC8156ED2B /* Collection.swift */, E26747B3154A5DBC3A7E24A5 /* Image.swift */, 40B21E611DADDEF00307E7AC /* String.swift */, + A40C19719687984FD9478FBE /* Task.swift */, 287FC98AF2664EAD79C0D902 /* UIDevice.swift */, 227AC5D71A4CE43512062243 /* URL.swift */, ); @@ -1120,11 +1108,7 @@ 4658A940E89BC42EE3346A97 /* Messages */ = { isa = PBXGroup; children = ( - FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */, - 8BF686BA36D0C2FA3C63DFDF /* ImageRoomMessage.swift */, - CBDA364DFFC3AC71C4771251 /* NoticeRoomMessage.swift */, 607974D08BD2AF83725D817A /* RoomMessageProtocol.swift */, - 4110685D9CA159F3FD2D6BA1 /* TextRoomMessage.swift */, ); path = Messages; sourceTree = ""; @@ -1186,6 +1170,7 @@ isa = PBXGroup; children = ( B902EA6CD3296B0E10EE432B /* HomeScreen.swift */, + ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */, ); path = View; sourceTree = ""; @@ -1228,6 +1213,7 @@ isa = PBXGroup; children = ( F15BE37BE2FB86E00C8D150A /* AggregratedReaction.swift */, + EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */, ); path = TimeLineItemContent; sourceTree = ""; @@ -1288,12 +1274,10 @@ 70DABA39C844CA931B829395 /* RoomSummary */ = { isa = PBXGroup; children = ( - 4411C0DA0087A1CB143E96FA /* EventBrief.swift */, - 36322DD0D4E29D31B0945ADC /* EventBriefFactory.swift */, - 92B61C243325DC76D3086494 /* EventBriefFactoryProtocol.swift */, - B6BDAC8895AB2B77B47703AE /* MockRoomSummary.swift */, + 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */, 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */, - C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */, + CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */, + 10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */, ); path = RoomSummary; sourceTree = ""; @@ -1868,9 +1852,11 @@ isa = PBXGroup; children = ( 61ADFB893DEF81E58DF3FAB9 /* MockRoomTimelineController.swift */, + 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */, 24B0C97D2F560BCB72BE73B1 /* RoomTimelineController.swift */, CC7CCC6DE5FA623E31BA8546 /* RoomTimelineControllerProtocol.swift */, 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */, + 9ABAECB0CA5FF8F8E6F10DD7 /* RoomTimelineProviderItem.swift */, 095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */, 5A7A7D6D373D411C8C48B881 /* TimeLineItemContent */, 95BE1C7CB2C80344FF0BE724 /* TimelineItems */, @@ -2351,6 +2337,7 @@ 6A0E7551E0D1793245F34CDD /* ClientError.swift in Sources */, 1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */, 24BDDD09A90B8BFE3793F3AA /* ClientProxyProtocol.swift in Sources */, + 663E198678778F7426A9B27D /* Collection.swift in Sources */, DCB781BD227CA958809AFADF /* Coordinator.swift in Sources */, C4F69156C31A447FEFF2A47C /* DTHTMLElement+AttributedStringBuilder.swift in Sources */, EE8491AD81F47DF3C192497B /* DecorationTimelineItemProtocol.swift in Sources */, @@ -2359,13 +2346,9 @@ 9738F894DB1BD383BE05767A /* ElementSettings.swift in Sources */, D8CFF02C2730EE5BC4F17ABF /* ElementToggleStyle.swift in Sources */, 7C1A7B594B2F8143F0DD0005 /* ElementXAttributeScope.swift in Sources */, - 224A55EEAEECF5336B14A4A5 /* EmoteRoomMessage.swift in Sources */, 6647430A45B4A8E692909A8F /* EmoteRoomTimelineItem.swift in Sources */, 68AC3C84E2B438036B174E30 /* EmoteRoomTimelineView.swift in Sources */, 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */, - 3D325A1147F6281C57BFCDF6 /* EventBrief.swift in Sources */, - 418B4AEFD03DC7A6D2C9D5C8 /* EventBriefFactory.swift in Sources */, - F78C57B197DA74735FEBB42C /* EventBriefFactoryProtocol.swift in Sources */, 313382FC5D38064EAAA35CB2 /* FileManager.swift in Sources */, A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */, 85AFBB433AD56704A880F8A0 /* FramePreferenceKey.swift in Sources */, @@ -2373,11 +2356,11 @@ 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */, 8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */, 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */, + 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */, DE4F8C4E0F1DB4832F09DE97 /* HomeScreenViewModel.swift in Sources */, 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */, 03D684A3AE85A23B3DA3B43F /* Image.swift in Sources */, 6EA61FCA55D950BDE326A1A7 /* ImageAnonymizer.swift in Sources */, - 2E59008365E01F0AFB3A6B24 /* ImageRoomMessage.swift in Sources */, DDB80FD2753FEAAE43CC2AAE /* ImageRoomTimelineItem.swift in Sources */, D5EA4C6C80579279770D5804 /* ImageRoomTimelineView.swift in Sources */, A5C8F013ED9FB8AA6FEE18A7 /* InfoPlist.swift in Sources */, @@ -2401,18 +2384,17 @@ EA1E7949533E19C6D862680A /* MediaProvider.swift in Sources */, 7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */, 62BBF5BE7B905222F0477FF2 /* MediaSource.swift in Sources */, - 03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */, - 1999ECC6777752A2616775CF /* MemberDetailsProvider.swift in Sources */, - A5EC21A071F58FC1229C20D0 /* MemberDetailsProviderProtocol.swift in Sources */, 24906A1E82D0046655958536 /* MessageComposer.swift in Sources */, 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */, + C35CF4DAB1467FE1BBDC204B /* MessageTimelineItem.swift in Sources */, 152AE2B8650FB23AFD2E28B9 /* MockAuthenticationServiceProxy.swift in Sources */, 28410F3DE89C2C44E4F75C92 /* MockBugReportService.swift in Sources */, EE4F5601356228FF72FC56B6 /* MockClientProxy.swift in Sources */, 67E391A2E00709FB41903B36 /* MockMediaProvider.swift in Sources */, 51DB67C5B5BC68B0A6FF54D4 /* MockRoomProxy.swift in Sources */, - 29AEE68A604940180AB9EBFF /* MockRoomSummary.swift in Sources */, + 2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */, E81EEC1675F2371D12A880A3 /* MockRoomTimelineController.swift in Sources */, + 447E8580A0A2569E32529E17 /* MockRoomTimelineProvider.swift in Sources */, 9BE7A9CF6C593251D734B461 /* MockServerSelectionScreenState.swift in Sources */, D034A195A3494E38BF060485 /* MockSessionVerificationControllerProxy.swift in Sources */, C74EE50257ED925C2B8EFCE6 /* MockSoftLogoutScreenState.swift in Sources */, @@ -2422,7 +2404,6 @@ 12F70C493FB69F4D7E9A37EA /* NavigationRouterStore.swift in Sources */, 344AF4CBB6D8786214878642 /* NavigationRouterStoreProtocol.swift in Sources */, F56261126E368C831B3DE976 /* NavigationRouterType.swift in Sources */, - 33B4E59D408AE6E02323EE41 /* NoticeRoomMessage.swift in Sources */, 8BBD3AA589DEE02A1B0923B2 /* NoticeRoomTimelineItem.swift in Sources */, 368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */, 563A05B43207D00A6B698211 /* OIDCService.swift in Sources */, @@ -2445,7 +2426,8 @@ 7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */, 617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */, 59C41313AED7566C3AC51163 /* RoomSummary.swift in Sources */, - BE3237142FA6E1A13C0E7D11 /* RoomSummaryProtocol.swift in Sources */, + 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */, + AA050DF4AEE54A641BA7CA22 /* RoomSummaryProviderProtocol.swift in Sources */, 78B71D53C1FC55FB7A9B75F0 /* RoomTimelineController.swift in Sources */, 9B8DE1D424E37581C7D99CCC /* RoomTimelineControllerProtocol.swift in Sources */, 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */, @@ -2453,12 +2435,14 @@ C8E82786DE1B6A400DA9BA25 /* RoomTimelineItemProperties.swift in Sources */, 1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */, 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */, + A0A26E4F713596856470EF1A /* RoomTimelineProviderItem.swift in Sources */, 77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */, 5D430CDE11EAC3E8E6B80A66 /* RoomTimelineViewFactory.swift in Sources */, 297CD0A27C87B0C50FF192EE /* RoomTimelineViewFactoryProtocol.swift in Sources */, CF82143AA4A4F7BD11D22946 /* RoomTimelineViewProvider.swift in Sources */, 7F19E97E7985F518C9018B83 /* RootRouter.swift in Sources */, 2C0CE61E5DC177938618E0B1 /* RootRouterType.swift in Sources */, + F764BE976EAB76D63E7C1678 /* RoundedCorner.swift in Sources */, 462813B93C39DF93B1249403 /* RoundedToastView.swift in Sources */, CC736DA1AA8F8B9FD8785009 /* ScreenshotDetector.swift in Sources */, 1281625B25371BE53D36CB3A /* SeparatorRoomTimelineItem.swift in Sources */, @@ -2498,13 +2482,13 @@ 2F94054F50E312AF30BE07F3 /* String.swift in Sources */, A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */, 066A1E9B94723EE9F3038044 /* Strings.swift in Sources */, + E290C78E7F09F47FD2662986 /* Task.swift in Sources */, 43FD77998F33C32718C51450 /* TemplateCoordinator.swift in Sources */, 63C9AF0FB8278AF1C0388A0C /* TemplateModels.swift in Sources */, 1555A7643D85187D4851040C /* TemplateScreen.swift in Sources */, 75EA4ABBFAA810AFF289D6F4 /* TemplateViewModel.swift in Sources */, 5F1FDE49DFD0C680386E48F9 /* TemplateViewModelProtocol.swift in Sources */, D85D4FA590305180B4A41795 /* Tests.swift in Sources */, - D013E70C8E28E43497820444 /* TextRoomMessage.swift in Sources */, 7963F98CDFDEAC75E072BD81 /* TextRoomTimelineItem.swift in Sources */, 5E0F2E612718BB4397A6D40A /* TextRoomTimelineView.swift in Sources */, 157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */, @@ -3165,7 +3149,7 @@ repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = "1.0.13-alpha"; + version = "0.0.4-demo"; }; }; 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6f7bebedc..259c162f7 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-rust-components-swift", "state" : { - "revision" : "1358c9c2a85cfb5fc1bfadce13565e02618725d4", - "version" : "1.0.13-alpha" + "revision" : "b10003bc8537f95da0197627fda977cb9dec98f9", + "version" : "0.0.4-demo" } }, { diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 3e21aa254..5c38f923c 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -2,10 +2,12 @@ "untranslated" = "Untranslated"; "action_confirm" = "Confirm"; +"action_match" = "Match"; "screenshot_detected_title" = "You took a screenshot"; "screenshot_detected_message" = "Would you like to submit a bug report?"; +"settings_appearance" = "Appearance"; "settings_timeline_style" = "Timeline Style"; "room_timeline_style_plain_long_description" = "Plain Timeline"; "room_timeline_style_bubbled_long_description" = "Bubbled Timeline"; @@ -14,6 +16,13 @@ "room_timeline_replying_to" = "Replying to %@"; +"session_verification_banner_title" = "Help keep your messages secure"; +"session_verification_banner_message" = "Looks like you’re using a new device. Verify its you."; +"session_verification_screen_emojis_title" = "Lets check if these"; +"session_verification_screen_emojis_message" = "Open Element on one of your other sessions to compare."; + +"home_screen_all_chats" = "All Chats"; + // MARK: - Authentication "authentication_login_title" = "Welcome back!"; diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index 967b78020..d0f03c941 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -52,8 +52,6 @@ class AppCoordinator: Coordinator { } } - private let memberDetailProviderManager: MemberDetailProviderManager - private let bugReportService: BugReportServiceProtocol private let screenshotDetector: ScreenshotDetector private let backgroundTaskService: BackgroundTaskServiceProtocol @@ -71,15 +69,16 @@ class AppCoordinator: Coordinator { bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL) splashViewController = SplashViewController() + mainNavigationController = ElementNavigationController(rootViewController: splashViewController) + mainNavigationController.navigationBar.prefersLargeTitles = true + window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = mainNavigationController window.tintColor = .element.accent navigationRouter = NavigationRouter(navigationController: mainNavigationController) - memberDetailProviderManager = MemberDetailProviderManager() - ServiceLocator.serviceLocator = ServiceLocator(userIndicatorPresenter: UserIndicatorTypePresenter(presentingViewController: mainNavigationController)) guard let bundleIdentifier = Bundle.main.bundleIdentifier else { @@ -115,7 +114,7 @@ class AppCoordinator: Coordinator { // This exposes the full Rust side tracing subscriber filter for more flexibility. // We can filter by level, crate and even file. See more details here: // https://docs.rs/tracing-subscriber/0.2.7/tracing_subscriber/filter/struct.EnvFilter.html#examples - setupTracing(configuration: "info,hyper=warn,sled=warn,matrix_sdk_sled=warn") + setupTracing(configuration: "warn,hyper=warn,sled=warn,matrix_sdk_sled=warn") loggerConfiguration.logLevel = .debug #else @@ -131,7 +130,7 @@ class AppCoordinator: Coordinator { MXLog.configure(loggerConfiguration) } - // swiftlint:disable cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length private func setupStateMachine() { stateMachine.addTransitionHandler { [weak self] context in guard let self = self else { return } @@ -171,12 +170,7 @@ class AppCoordinator: Coordinator { case (.remoteSigningOut(let isSoft), .completedSigningOut, .signedOut): self.presentSplashScreen(isSoftLogout: isSoft) self.hideLoadingIndicator() - - case (.homeScreen, .showSettingsScreen, .settingsScreen): - self.presentSettingsScreen() - case (.settingsScreen, .dismissedSettingsScreen, .homeScreen): - self.tearDownDismissedSettingsScreen() - + case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen): self.presentSessionVerification() case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen): @@ -191,8 +185,6 @@ class AppCoordinator: Coordinator { fatalError("Failed transition with context: \(context)") } } - - // swiftlint:enable cyclomatic_complexity function_body_length private func restoreUserSession() { Task { @@ -265,21 +257,23 @@ class AppCoordinator: Coordinator { } private func tearDownUserSession(isSoftLogout: Bool = false) { - Task { - deobserveUserSessionChanges() - - if !isSoftLogout { + userSession.clientProxy.stopSync() + + deobserveUserSessionChanges() + + if !isSoftLogout { + Task { // first log out from the server _ = await userSession.clientProxy.logout() - + // regardless of the result, clear user data userSessionStore.logout(userSession: userSession) userSession = nil } - - // complete logging out - stateMachine.processEvent(.completedSigningOut) } + + // complete logging out + stateMachine.processEvent(.completedSigningOut) } private func presentSplashScreen(isSoftLogout: Bool = false) { @@ -297,9 +291,10 @@ class AppCoordinator: Coordinator { } private func presentHomeScreen() { + userSession.clientProxy.startSync() + let parameters = HomeScreenCoordinatorParameters(userSession: userSession, - attributedStringBuilder: AttributedStringBuilder(), - memberDetailProviderManager: memberDetailProviderManager) + attributedStringBuilder: AttributedStringBuilder()) let coordinator = HomeScreenCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in @@ -309,7 +304,7 @@ class AppCoordinator: Coordinator { case .presentRoom(let roomIdentifier): self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier)) case .presentSettings: - self.stateMachine.processEvent(.showSettingsScreen) + self.presentSettingsScreen() case .presentBugReport: self.presentBugReportScreen() case .verifySession: @@ -353,31 +348,29 @@ class AppCoordinator: Coordinator { // MARK: Rooms private func presentRoomWithIdentifier(_ roomIdentifier: String) { - guard let roomProxy = userSession.clientProxy.rooms.first(where: { $0.id == roomIdentifier }) else { + guard let roomProxy = userSession.clientProxy.roomForIdentifier(roomIdentifier) else { MXLog.error("Invalid room identifier: \(roomIdentifier)") return } let userId = userSession.clientProxy.userIdentifier - let memberDetailProvider = memberDetailProviderManager.memberDetailProviderForRoomProxy(roomProxy) - - let timelineItemFactory = RoomTimelineItemFactory(mediaProvider: userSession.mediaProvider, - memberDetailProvider: memberDetailProvider, + let timelineItemFactory = RoomTimelineItemFactory(userID: userId, + mediaProvider: userSession.mediaProvider, + roomProxy: roomProxy, attributedStringBuilder: AttributedStringBuilder()) - + let timelineController = RoomTimelineController(userId: userId, roomId: roomIdentifier, - timelineProvider: RoomTimelineProvider(roomProxy: roomProxy), + timelineProvider: roomProxy.timelineProvider, timelineItemFactory: timelineItemFactory, mediaProvider: userSession.mediaProvider, - memberDetailProvider: memberDetailProvider) - + roomProxy: roomProxy) + let parameters = RoomScreenCoordinatorParameters(timelineController: timelineController, roomName: roomProxy.displayName ?? roomProxy.name, - roomAvatar: userSession.mediaProvider.imageFromURLString(roomProxy.avatarURL), - roomEncryptionBadge: roomProxy.encryptionBadgeImage) + roomAvatar: userSession.mediaProvider.imageFromURLString(roomProxy.avatarURL, size: MediaProviderDefaultAvatarSize)) let coordinator = RoomScreenCoordinator(parameters: parameters) - + add(childCoordinator: coordinator) navigationRouter.push(coordinator) { [weak self] in guard let self = self else { return } @@ -396,32 +389,39 @@ class AppCoordinator: Coordinator { // MARK: Settings private func presentSettingsScreen() { - let parameters = SettingsCoordinatorParameters(navigationRouter: navigationRouter, + let navController = ElementNavigationController() + let newNavigationRouter = NavigationRouter(navigationController: navController) + + let parameters = SettingsCoordinatorParameters(navigationRouter: newNavigationRouter, userSession: userSession, bugReportService: bugReportService) let coordinator = SettingsCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in guard let self = self else { return } switch action { + case .dismiss: + self.dismissSettingsScreen() case .logout: + self.dismissSettingsScreen() self.stateMachine.processEvent(.signOut) } } add(childCoordinator: coordinator) coordinator.start() - navigationRouter.push(coordinator) { [weak self] in - guard let self = self else { return } - - self.stateMachine.processEvent(.dismissedSettingsScreen) - } + navController.viewControllers = [coordinator.toPresentable()] + navigationRouter.present(navController, animated: true) } - - private func tearDownDismissedSettingsScreen() { - guard let coordinator = childCoordinators.last as? SettingsCoordinator else { - fatalError("Invalid coordinator hierarchy: \(childCoordinators)") + + @objc + private func dismissSettingsScreen() { + MXLog.debug("dismissSettingsScreen") + + guard let coordinator = childCoordinators.first(where: { $0 is SettingsCoordinator }) else { + return } + navigationRouter.dismissModule() remove(childCoordinator: coordinator) } diff --git a/ElementX/Sources/AppCoordinatorStateMachine.swift b/ElementX/Sources/AppCoordinatorStateMachine.swift index fbd6c02f2..be3b16e46 100644 --- a/ElementX/Sources/AppCoordinatorStateMachine.swift +++ b/ElementX/Sources/AppCoordinatorStateMachine.swift @@ -35,10 +35,7 @@ class AppCoordinatorStateMachine { /// Showing the session verification flows case sessionVerificationScreen - - /// Showing the settings screen - case settingsScreen - + /// Processing a sign out request case signingOut @@ -77,11 +74,6 @@ class AppCoordinatorStateMachine { case showSessionVerificationScreen /// Session verification has finished case dismissedSessionVerificationScreen - - /// Request settings screen presentation - case showSettingsScreen - /// The settings screen has been dismissed - case dismissedSettingsScreen } private let stateMachine: StateMachine @@ -97,9 +89,6 @@ class AppCoordinatorStateMachine { machine.addRoutes(event: .signOut, transitions: [.any => .signingOut]) machine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut]) - - machine.addRoutes(event: .showSettingsScreen, transitions: [.homeScreen => .settingsScreen]) - machine.addRoutes(event: .dismissedSettingsScreen, transitions: [.settingsScreen => .homeScreen]) machine.addRoutes(event: .showSessionVerificationScreen, transitions: [.homeScreen => .sessionVerificationScreen]) machine.addRoutes(event: .dismissedSessionVerificationScreen, transitions: [.sessionVerificationScreen => .homeScreen]) diff --git a/ElementX/Sources/BuildSettings.swift b/ElementX/Sources/BuildSettings.swift index 7a190abd8..29bdd23cd 100644 --- a/ElementX/Sources/BuildSettings.swift +++ b/ElementX/Sources/BuildSettings.swift @@ -20,6 +20,7 @@ final class BuildSettings { // MARK: - Servers static let defaultHomeserverAddress = "matrix.org" + static let slidingSyncProxyBaseURL = URL(staticString: "https://slidingsync.lab.element.dev") // MARK: - Bug report diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index e4acfdab5..dbfcf0461 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -12,6 +12,8 @@ import Foundation extension ElementL10n { /// Confirm public static let actionConfirm = ElementL10n.tr("Untranslated", "action_confirm") + /// Match + public static let actionMatch = ElementL10n.tr("Untranslated", "action_match") /// Forgot password public static let authenticationLoginForgotPassword = ElementL10n.tr("Untranslated", "authentication_login_forgot_password") /// Welcome back! @@ -20,6 +22,8 @@ extension ElementL10n { public static let authenticationServerInfoMatrixDescription = ElementL10n.tr("Untranslated", "authentication_server_info_matrix_description") /// Choose your server to store your data public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title") + /// All Chats + public static let homeScreenAllChats = ElementL10n.tr("Untranslated", "home_screen_all_chats") /// Mobile public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device") /// Tablet @@ -48,6 +52,16 @@ extension ElementL10n { public static let serverSelectionServerUrl = ElementL10n.tr("Untranslated", "server_selection_server_url") /// Choose your server public static let serverSelectionTitle = ElementL10n.tr("Untranslated", "server_selection_title") + /// Looks like you’re using a new device. Verify its you. + public static let sessionVerificationBannerMessage = ElementL10n.tr("Untranslated", "session_verification_banner_message") + /// Help keep your messages secure + public static let sessionVerificationBannerTitle = ElementL10n.tr("Untranslated", "session_verification_banner_title") + /// Open Element on one of your other sessions to compare. + public static let sessionVerificationScreenEmojisMessage = ElementL10n.tr("Untranslated", "session_verification_screen_emojis_message") + /// Lets check if these + public static let sessionVerificationScreenEmojisTitle = ElementL10n.tr("Untranslated", "session_verification_screen_emojis_title") + /// Appearance + public static let settingsAppearance = ElementL10n.tr("Untranslated", "settings_appearance") /// Timeline Style public static let settingsTimelineStyle = ElementL10n.tr("Untranslated", "settings_timeline_style") /// Untranslated diff --git a/ElementX/Sources/Services/Room/RoomSummary/EventBriefFactoryProtocol.swift b/ElementX/Sources/Other/Extensions/Collection.swift similarity index 73% rename from ElementX/Sources/Services/Room/RoomSummary/EventBriefFactoryProtocol.swift rename to ElementX/Sources/Other/Extensions/Collection.swift index e0bf0ab31..710e4dc21 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/EventBriefFactoryProtocol.swift +++ b/ElementX/Sources/Other/Extensions/Collection.swift @@ -16,7 +16,9 @@ import Foundation -@MainActor -protocol EventBriefFactoryProtocol { - func buildEventBriefFor(message: RoomMessageProtocol?) async -> EventBrief? +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } } diff --git a/ElementX/Sources/Other/Extensions/String.swift b/ElementX/Sources/Other/Extensions/String.swift index f7a3bf475..f809172c7 100644 --- a/ElementX/Sources/Other/Extensions/String.swift +++ b/ElementX/Sources/Other/Extensions/String.swift @@ -39,4 +39,16 @@ extension String { let detector = try? NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive) return detector?.numberOfMatches(in: self, range: range) ?? 0 == 1 } + + /// Calculates a numeric hash same as Element Web + /// See original function here https://github.com/matrix-org/matrix-react-sdk/blob/321dd49db4fbe360fc2ff109ac117305c955b061/src/utils/FormattingUtils.js#L47 + var hashCode: Int32 { + var hash: Int32 = 0 + + for character in self { + let shiftedHash = hash << 5 + hash = shiftedHash.subtractingReportingOverflow(hash).partialValue + Int32(character.unicodeScalars[character.unicodeScalars.startIndex].value) + } + return abs(hash) + } } diff --git a/ElementX/Sources/Other/Extensions/Task.swift b/ElementX/Sources/Other/Extensions/Task.swift new file mode 100644 index 000000000..108af136e --- /dev/null +++ b/ElementX/Sources/Other/Extensions/Task.swift @@ -0,0 +1,50 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension Task where Failure == Never { + static func dispatched(on queue: DispatchQueue, + priority: TaskPriority? = nil, + operation: @escaping @Sendable () -> Success) -> Task { + Task.detached(priority: priority) { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: operation()) + } + } + } + } +} + +extension Task where Failure == Error { + static func dispatched(on queue: DispatchQueue, + priority: TaskPriority? = nil, + operation: @escaping @Sendable () throws -> Success) -> Task { + Task.detached(priority: priority) { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + let result = try operation() + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/RoundedCorner.swift b/ElementX/Sources/Other/SwiftUI/Views/RoundedCorner.swift new file mode 100644 index 000000000..a4753c984 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/RoundedCorner.swift @@ -0,0 +1,48 @@ +// +// 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 RoundedCorner: Shape { + let radius: CGFloat + let corners: UIRectCorner + + init(radius: CGFloat, corners: UIRectCorner) { + self.radius = radius + self.corners = corners + } + + init(radius: CGFloat, inGroupState: TimelineItemInGroupState) { + self.init(radius: radius, corners: inGroupState.roundedCorners) + } + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } + + func cornerRadius(_ radius: CGFloat, inGroupState: TimelineItemInGroupState) -> some View { + clipShape(RoundedCorner(radius: radius, inGroupState: inGroupState)) + } +} diff --git a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift index a5d94e3f6..8509f12e4 100644 --- a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift @@ -14,7 +14,6 @@ // limitations under the License. // -import MatrixRustSDK import UIKit @MainActor diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift index 9fce0253e..98c328546 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift @@ -15,7 +15,6 @@ // import AppAuth -import MatrixRustSDK import SwiftUI struct LoginCoordinatorParameters { diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift index cfbfb889f..a8bbd448f 100644 --- a/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift @@ -15,7 +15,6 @@ // import AppAuth -import MatrixRustSDK import SwiftUI struct SoftLogoutCoordinatorParameters { diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index 858ccbf7d..73b9ae31a 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -20,7 +20,6 @@ import SwiftUI struct HomeScreenCoordinatorParameters { let userSession: UserSessionProtocol let attributedStringBuilder: AttributedStringBuilderProtocol - let memberDetailProviderManager: MemberDetailProviderManager } enum HomeScreenCoordinatorAction { @@ -40,8 +39,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable { private let hostingController: UIViewController private var viewModel: HomeScreenViewModelProtocol - private var roomSummaries: [RoomSummary] = [] - private var cancellables = Set() // MARK: Public @@ -55,7 +52,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable { init(parameters: HomeScreenCoordinatorParameters) { self.parameters = parameters - viewModel = HomeScreenViewModel(attributedStringBuilder: parameters.attributedStringBuilder) + viewModel = HomeScreenViewModel(userSession: parameters.userSession, + attributedStringBuilder: parameters.attributedStringBuilder) let view = HomeScreen(context: viewModel.context) hostingController = UIHostingController(rootView: view) @@ -72,42 +70,10 @@ final class HomeScreenCoordinator: Coordinator, Presentable { self.callback?(.verifySession) } } - - parameters.userSession.clientProxy - .callbacks - .receive(on: DispatchQueue.main) - .sink { [weak self] callback in - if case .updatedRoomsList = callback { - self?.updateRoomsList() - } - }.store(in: &cancellables) - - parameters.userSession.callbacks - .receive(on: DispatchQueue.main) - .sink { [weak self] callback in - switch callback { - case .sessionVerificationNeeded: - self?.viewModel.showSessionVerificationBanner() - case .didVerifySession: - self?.viewModel.hideSessionVerificationBanner() - default: - break - } - }.store(in: &cancellables) - - updateRoomsList() - - Task { - if case let .success(userAvatarURLString) = await parameters.userSession.clientProxy.loadUserAvatarURLString() { - if case let .success(avatar) = await parameters.userSession.mediaProvider.loadImageFromURLString(userAvatarURLString) { - self.viewModel.updateWithUserAvatar(avatar) - } - } - } } // MARK: - Public - + func start() { } func toPresentable() -> UIViewController { @@ -116,26 +82,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable { // MARK: - Private - func updateRoomsList() { - roomSummaries = parameters.userSession.clientProxy.rooms.compactMap { roomProxy in - guard roomProxy.isJoined, !roomProxy.isSpace, !roomProxy.isTombstoned else { - return nil - } - - if let summary = self.roomSummaries.first(where: { $0.id == roomProxy.id }) { - return summary - } - - let memberDetailProvider = parameters.memberDetailProviderManager.memberDetailProviderForRoomProxy(roomProxy) - - return RoomSummary(roomProxy: roomProxy, - mediaProvider: parameters.userSession.mediaProvider, - eventBriefFactory: EventBriefFactory(memberDetailProvider: memberDetailProvider)) - } - - viewModel.updateWithRoomSummaries(roomSummaries) - } - private func processUserMenuAction(_ action: HomeScreenViewUserMenuAction) { switch action { case .settings: diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 0e7d71594..62ab0ed99 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -35,6 +35,7 @@ enum HomeScreenViewAction { case selectRoom(roomIdentifier: String) case userMenu(action: HomeScreenViewUserMenuAction) case verifySession + case skipSessionVerification } struct HomeScreenViewState: BindableState { @@ -44,24 +45,16 @@ struct HomeScreenViewState: BindableState { var rooms: [HomeScreenRoom] = [] - var isLoadingRooms = false - - var visibleDMs: [HomeScreenRoom] { - searchFilteredRooms.filter(\.isDirect) + var isLoadingRooms: Bool { + rooms.isEmpty } - + var visibleRooms: [HomeScreenRoom] { - searchFilteredRooms.filter { !$0.isDirect } - } - - private var searchFilteredRooms: LazyFilterSequence.Elements> { - guard !bindings.searchQuery.isEmpty else { - // This extra filter is fine for now as there are always downstream filters - // but if that changes, this approach should be reconsidered. - return rooms.lazy.filter { _ in true } + if bindings.searchQuery.isEmpty { + return rooms } - return rooms.lazy.filter { $0.displayName?.localizedStandardContains(bindings.searchQuery) ?? false } + return rooms.lazy.filter { $0.name.localizedStandardContains(bindings.searchQuery) } } var bindings = HomeScreenViewStateBindings() @@ -74,17 +67,15 @@ struct HomeScreenViewStateBindings { struct HomeScreenRoom: Identifiable, Equatable { let id: String - var displayName: String? + let name: String - var topic: String? - var lastMessage: String? + let hasUnreads: Bool + + let timestamp: String? + + var lastMessage: AttributedString? var avatar: UIImage? - - let isDirect: Bool - let isEncrypted: Bool - let isSpace: Bool - let isTombstoned: Bool } extension MutableCollection where Element == HomeScreenRoom { diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index e55ef63d8..15deeff31 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -20,29 +20,54 @@ import SwiftUI typealias HomeScreenViewModelType = StateStoreViewModel class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol { + private let userSession: UserSessionProtocol + private let roomSummaryProvider: RoomSummaryProviderProtocol private let attributedStringBuilder: AttributedStringBuilderProtocol - private var roomUpdateListeners = Set() - private var roomsUpdateTask: Task? { - willSet { - roomsUpdateTask?.cancel() - } - } - - private var roomSummaries: [RoomSummaryProtocol]? { - didSet { - state.isLoadingRooms = (roomSummaries?.count ?? 0 == 0) - } - } - var callback: ((HomeScreenViewModelAction) -> Void)? // MARK: - Setup - init(attributedStringBuilder: AttributedStringBuilderProtocol) { + init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) { + self.userSession = userSession + roomSummaryProvider = userSession.clientProxy.roomSummaryProvider self.attributedStringBuilder = attributedStringBuilder - super.init(initialViewState: HomeScreenViewState(isLoadingRooms: true)) + super.init(initialViewState: HomeScreenViewState()) + + userSession.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + switch callback { + case .sessionVerificationNeeded: + self?.state.showSessionVerificationBanner = true + case .didVerifySession: + self?.state.showSessionVerificationBanner = false + default: + break + } + }.store(in: &cancellables) + + roomSummaryProvider.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + switch callback { + case .updatedRoomSummaries: + Task { + await self?.updateRooms() + } + } + }.store(in: &cancellables) + + Task { + if case let .success(userAvatarURLString) = await userSession.clientProxy.loadUserAvatarURLString() { + if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURLString(userAvatarURLString, size: MediaProviderDefaultAvatarSize) { + state.userAvatar = avatar + } + } + + await updateRooms() + } } // MARK: - Public @@ -50,124 +75,63 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol override func process(viewAction: HomeScreenViewAction) async { switch viewAction { case .loadRoomData(let roomIdentifier): - loadRoomDataForIdentifier(roomIdentifier) + loadDataForRoomIdentifier(roomIdentifier) case .selectRoom(let roomIdentifier): callback?(.selectRoom(roomIdentifier: roomIdentifier)) case .userMenu(let action): callback?(.userMenu(action: action)) case .verifySession: callback?(.verifySession) + case .skipSessionVerification: + state.showSessionVerificationBanner = false } } - func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) { - roomsUpdateTask = Task { - await updateWithRoomSummaries(roomSummaries) - } - } - - private func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) async { - var rooms = [HomeScreenRoom]() - for summary in roomSummaries { - if Task.isCancelled { - return - } - - rooms.append(await buildOrUpdateRoomForSummary(summary)) - } - - if Task.isCancelled { - return - } - - state.rooms = rooms - self.roomSummaries = roomSummaries - - roomUpdateListeners.removeAll() - roomSummaries.forEach { roomSummary in - roomSummary.callbacks - .receive(on: DispatchQueue.main) - .sink { [weak self] callback in - guard let self = self else { - return - } - - Task { - if let index = self.state.rooms.firstIndex(where: { $0.id == roomSummary.id }) { - switch callback { - case .updatedLastMessage: - var room = self.state.rooms[index] - room.lastMessage = await self.lastMessageFromEventBrief(roomSummary.lastMessage) - self.state.rooms[index] = room - default: - self.state.rooms[index] = await self.buildOrUpdateRoomForSummary(roomSummary) - } - } - } - } - .store(in: &roomUpdateListeners) - } - } - - func updateWithUserAvatar(_ avatar: UIImage) { - state.userAvatar = avatar - } - - func showSessionVerificationBanner() { - state.showSessionVerificationBanner = true - } - - func hideSessionVerificationBanner() { - state.showSessionVerificationBanner = false - } - // MARK: - Private - private func loadRoomDataForIdentifier(_ roomIdentifier: String) { - guard let roomSummary = roomSummaries?.first(where: { $0.id == roomIdentifier }) else { - MXLog.error("Invalid room identifier") + private func loadDataForRoomIdentifier(_ identifier: String) { + guard let summary = roomSummaryProvider.roomSummaries.first(where: { $0.id == identifier }), + var room = state.rooms.first(where: { $0.id == identifier }) else { return } - Task { await roomSummary.loadDetails() } - } - - private func buildOrUpdateRoomForSummary(_ roomSummary: RoomSummaryProtocol) async -> HomeScreenRoom { - guard var room = state.rooms.first(where: { $0.id == roomSummary.id }) else { - return HomeScreenRoom(id: roomSummary.id, - displayName: roomSummary.displayName, - topic: roomSummary.topic, - lastMessage: await lastMessageFromEventBrief(roomSummary.lastMessage), - avatar: roomSummary.avatar, - isDirect: roomSummary.isDirect, - isEncrypted: roomSummary.isEncrypted, - isSpace: roomSummary.isSpace, - isTombstoned: roomSummary.isTombstoned) + if room.avatar != nil { + return } - room.avatar = roomSummary.avatar - room.displayName = roomSummary.displayName - room.topic = roomSummary.topic + if let avatarURLString = summary.avatarURLString { + Task { + if case let .success(image) = await userSession.mediaProvider.loadImageFromURLString(avatarURLString, size: MediaProviderDefaultAvatarSize) { + if let index = state.rooms.firstIndex(of: room) { + room.avatar = image + state.rooms[index] = room + } + } + } + } + } + + private func updateRooms() async { + state.rooms = await Task.detached { + var rooms = [HomeScreenRoom]() + + for summary in self.roomSummaryProvider.roomSummaries { + let avatarImage = await self.userSession.mediaProvider.imageFromURLString(summary.avatarURLString, size: MediaProviderDefaultAvatarSize) - return room - } - - private func lastMessageFromEventBrief(_ eventBrief: EventBrief?) async -> String? { - guard let eventBrief = eventBrief else { - return nil - } - - let senderDisplayName = senderDisplayNameForBrief(eventBrief) - - if let htmlBody = eventBrief.htmlBody, - let lastMessageAttributedString = await attributedStringBuilder.fromHTML(htmlBody) { - return "\(senderDisplayName): \(String(lastMessageAttributedString.characters))" - } else { - return "\(senderDisplayName): \(eventBrief.body)" - } - } - - private func senderDisplayNameForBrief(_ brief: EventBrief) -> String { - brief.senderDisplayName ?? brief.senderId + var timestamp: String? + if let lastMessageTimestamp = summary.lastMessageTimestamp { + timestamp = lastMessageTimestamp.formatted(date: .omitted, time: .shortened) + } + + rooms.append(HomeScreenRoom(id: summary.id, + name: summary.name, + hasUnreads: summary.unreadNotificationCount > 0, + timestamp: timestamp, + lastMessage: summary.lastMessage, + avatar: avatarImage)) + } + + return rooms + }.value } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift index 8a155838b..f8ea2a024 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModelProtocol.swift @@ -22,10 +22,4 @@ protocol HomeScreenViewModelProtocol { var callback: ((HomeScreenViewModelAction) -> Void)? { get set } var context: HomeScreenViewModelType.Context { get } - - func updateWithUserAvatar(_ avatar: UIImage) - func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) - - func showSessionVerificationBanner() - func hideSessionVerificationBanner() } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index 11a88c751..14e4b32bb 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -29,36 +29,20 @@ struct HomeScreen: View { ProgressView() } } else { - if context.viewState.showSessionVerificationBanner { - HStack { - Text(ElementL10n.verificationVerifyDevice) - Spacer() - Button(ElementL10n.startVerification) { - context.send(viewAction: .verifySession) - } - } - .padding() - .background(Color.element.quaternaryContent) - .padding(.top, 1) - } - - List { - Section(ElementL10n.rooms) { - ForEach(context.viewState.visibleRooms) { room in - RoomCell(room: room, context: context) - .listRowBackground(Color.clear) - } + ScrollView { + if context.viewState.showSessionVerificationBanner { + sessionVerificationBanner } - Section(ElementL10n.bottomActionPeople) { - ForEach(context.viewState.visibleDMs) { room in - RoomCell(room: room, context: context) - .listRowBackground(Color.clear) + LazyVStack { + ForEach(context.viewState.visibleRooms) { room in + HomeScreenRoomCell(room: room, context: context) } } + .searchable(text: $context.searchQuery) + .animation(.default, value: context.viewState.visibleRooms) + .padding(.horizontal) } - .listStyle(.plain) - .searchable(text: $context.searchQuery) } Spacer() @@ -66,7 +50,7 @@ struct HomeScreen: View { .transition(.slide) .animation(.elementDefault, value: context.viewState.showSessionVerificationBanner) .ignoresSafeArea(.all, edges: .bottom) - .navigationBarTitleDisplayMode(.inline) + .navigationTitle(ElementL10n.homeScreenAllChats) .toolbar { ToolbarItem(placement: .navigationBarLeading) { userMenuButton @@ -119,6 +103,37 @@ struct HomeScreen: View { return .empty } } + + private var sessionVerificationBanner: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text(ElementL10n.sessionVerificationBannerTitle) + .font(.element.subheadlineBold) + .foregroundColor(.element.systemPrimaryLabel) + Text(ElementL10n.sessionVerificationBannerMessage) + .font(.element.footnote) + .foregroundColor(.element.systemSecondaryLabel) + } + + HStack(spacing: 16) { + Button(ElementL10n.actionSkip) { + context.send(viewAction: .skipSessionVerification) + } + .frame(maxWidth: .infinity) + .buttonStyle(.elementCapsule) + + Button(ElementL10n.continue) { + context.send(viewAction: .verifySession) + } + .frame(maxWidth: .infinity) + .buttonStyle(.elementCapsuleProminent) + } + } + .padding(16) + .background(Color.element.systemSecondaryBackground) + .cornerRadius(14) + .padding(.horizontal, 16) + } private func settings() { context.send(viewAction: .userMenu(action: .settings)) @@ -137,62 +152,6 @@ struct HomeScreen: View { } } -struct RoomCell: View { - @ScaledMetric private var avatarSize = 32.0 - - let room: HomeScreenRoom - let context: HomeScreenViewModel.Context - - var body: some View { - Button { - context.send(viewAction: .selectRoom(roomIdentifier: room.id)) - } label: { - HStack(spacing: 16.0) { - if let avatar = room.avatar { - Image(uiImage: avatar) - .resizable() - .scaledToFill() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - } else { - PlaceholderAvatarImage(text: room.displayName ?? room.id) - .clipShape(Circle()) - .frame(width: avatarSize, height: avatarSize) - } - - VStack(alignment: .leading, spacing: 2.0) { - Text(roomName(room)) - .foregroundStyle(.primary) - - if let roomTopic = room.topic, roomTopic.count > 0 { - Text(roomTopic) - .font(.footnote.weight(.semibold)) - .lineLimit(1) - .foregroundStyle(.secondary) - } - - if let lastMessage = room.lastMessage { - Text(lastMessage) - .font(.callout) - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.top, 2) - } - } - } - .animation(.elementDefault, value: room) - .frame(minHeight: 60.0) - .task { - context.send(viewAction: .loadRoomData(roomIdentifier: room.id)) - } - } - } - - private func roomName(_ room: HomeScreenRoom) -> String { - room.displayName ?? room.id + (room.isEncrypted ? "🛡" : "") - } -} - // MARK: - Previews struct HomeScreen_Previews: PreviewProvider { @@ -202,28 +161,13 @@ struct HomeScreen_Previews: PreviewProvider { body.preferredColorScheme(.dark) .tint(.element.accent) } - + static var body: some View { - let viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder()) + let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "John Doe"), + mediaProvider: MockMediaProvider()) - let eventBrief = EventBrief(eventId: "id", - senderId: "senderId", - senderDisplayName: "Sender", - body: "Some message", - htmlBody: nil, - date: .now) - - let roomSummaries = [MockRoomSummary(displayName: "Alpha", topic: "Topic"), - MockRoomSummary(displayName: "Beta"), - MockRoomSummary(displayName: "Omega", lastMessage: eventBrief)] - - viewModel.updateWithRoomSummaries(roomSummaries) - - if let avatarImage = UIImage(systemName: "person.fill") { - viewModel.updateWithUserAvatar(avatarImage) - } - - viewModel.showSessionVerificationBanner() + let viewModel = HomeScreenViewModel(userSession: userSession, + attributedStringBuilder: AttributedStringBuilder()) return NavigationView { HomeScreen(context: viewModel.context) diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift new file mode 100644 index 000000000..f33920a70 --- /dev/null +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift @@ -0,0 +1,123 @@ +// +// 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 HomeScreenRoomCell: View { + @ScaledMetric private var avatarSize = 44.0 + + let room: HomeScreenRoom + let context: HomeScreenViewModel.Context + + var body: some View { + Button { + context.send(viewAction: .selectRoom(roomIdentifier: room.id)) + } label: { + HStack(spacing: 16.0) { + if let avatar = room.avatar { + Image(uiImage: avatar) + .resizable() + .scaledToFill() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + PlaceholderAvatarImage(text: room.name, contentId: room.id) + .clipShape(Circle()) + .frame(width: avatarSize, height: avatarSize) + } + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2.0) { + Text(room.name) + .font(.element.callout.bold()) + .foregroundColor(.element.primaryContent) + .lineLimit(1) + + if let lastMessage = room.lastMessage { + Text(lastMessage) + .font(lastMessageFont) + .foregroundColor(lastMessageForegroundColor) + .lineLimit(2) + .multilineTextAlignment(.leading) + .padding(.top, 2) + .animation(nil, value: UUID()) // Text animations look ugly + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 3.0) { + if let timestamp = room.timestamp { + Text(timestamp) + .font(.element.caption1) + .foregroundColor(.element.secondaryContent) + } + + if room.hasUnreads { + Rectangle() + .frame(width: 12, height: 12) + .foregroundColor(.element.primaryContent) + .clipShape(Circle()) + } + } + } + } + .frame(minHeight: 64.0) + .task { + context.send(viewAction: .loadRoomData(roomIdentifier: room.id)) + } + } + } + + var lastMessageFont: Font { + if room.hasUnreads { + return .element.subheadline.bold() + } else { + return .element.subheadline + } + } + + var lastMessageForegroundColor: Color { + if room.hasUnreads { + return .element.primaryContent + } else { + return .element.secondaryContent + } + } +} + +struct HomeScreenRoomCell_Previews: PreviewProvider { + static var previews: some View { + body.preferredColorScheme(.light) + .tint(.element.accent) + body.preferredColorScheme(.dark) + .tint(.element.accent) + } + + static var body: some View { + let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "John Doe"), + mediaProvider: MockMediaProvider()) + + let viewModel = HomeScreenViewModel(userSession: userSession, + attributedStringBuilder: AttributedStringBuilder()) + + return VStack { + ForEach(viewModel.context.viewState.rooms) { room in + HomeScreenRoomCell(room: room, context: viewModel.context) + } + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 397117b10..20d8335ed 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -20,7 +20,6 @@ struct RoomScreenCoordinatorParameters { let timelineController: RoomTimelineControllerProtocol let roomName: String? let roomAvatar: UIImage? - let roomEncryptionBadge: UIImage? } final class RoomScreenCoordinator: Coordinator, Presentable { @@ -45,8 +44,7 @@ final class RoomScreenCoordinator: Coordinator, Presentable { let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, timelineViewFactory: RoomTimelineViewFactory(), roomName: parameters.roomName, - roomAvatar: parameters.roomAvatar, - roomEncryptionBadge: parameters.roomEncryptionBadge) + roomAvatar: parameters.roomAvatar) let view = RoomScreen(context: viewModel.context) roomScreenViewModel = viewModel diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index e8e32922d..69af08ffc 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -43,6 +43,7 @@ enum RoomScreenViewAction { } struct RoomScreenViewState: BindableState { + var roomId: String var roomTitle = "" var roomAvatar: UIImage? var roomEncryptionBadge: UIImage? @@ -54,7 +55,6 @@ struct RoomScreenViewState: BindableState { var composerMode: RoomScreenComposerMode = .default - var messageComposerDisabled = false // Remove this when we have local echoes var sendButtonDisabled: Bool { bindings.composerText.count == 0 } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 466d22878..353088120 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -20,7 +20,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel Void var body: some View { - HStack(alignment: .bottom) { - let rect = RoundedRectangle(cornerRadius: 8.0) - VStack(alignment: .leading, spacing: 2.0) { - if case let .reply(_, displayName) = type { - MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction) - } + let rect = RoundedRectangle(cornerRadius: borderRadius) + VStack(alignment: .leading, spacing: 4.0) { + if case let .reply(_, displayName) = type { + MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction) + } + HStack(alignment: .center) { MessageComposerTextField(placeholder: "Send a message", text: $text, focused: $focused, maxHeight: 300) + + Button { + sendAction() + } label: { + Image(systemName: "paperplane") + .font(.element.title3) + .foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground) + .padding(8.0) + .background( + Circle() + .foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground) + ) + } + .disabled(sendingDisabled) + .animation(.elementDefault, value: sendingDisabled) + .keyboardShortcut(.return, modifiers: [.command]) + .padding(4.0) } - .padding(4.0) - .frame(minHeight: 44.0) - .clipShape(rect) - .overlay(rect.stroke(borderColor, lineWidth: borderWidth)) - .animation(.elementDefault, value: type) - .animation(.elementDefault, value: borderWidth) - - Button { - sendAction() - } label: { - Image(uiImage: Asset.Images.timelineComposerSendMessage.image) - .background(Circle() - .foregroundColor(.global.white) - ) - } - .padding(.bottom, 6.0) - .disabled(sendingDisabled) - .opacity(sendingDisabled ? 0.5 : 1.0) - .animation(.elementDefault, value: sendingDisabled) - .keyboardShortcut(.return, modifiers: [.command]) } + .padding(.leading, 12.0) + .background(.thinMaterial) + .clipShape(rect) + .animation(.elementDefault, value: type) } - private var borderColor: Color { - .element.accent - } - - private var borderWidth: CGFloat { - focused ? 2.0 : 1.0 + private var borderRadius: CGFloat { + switch type { + case .default: + return 28.0 + case .reply: + return 12.0 + } } } @@ -83,8 +85,9 @@ private struct MessageComposerReplyHeader: View { Button { action() } label: { - Image(systemName: "xmark") - .font(.element.caption2) + Image(systemName: "x.circle") + .font(.element.callout) + .foregroundColor(.element.secondaryContent) .padding(4.0) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift index a4bb123e9..2b93c48e3 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift @@ -71,6 +71,7 @@ private struct UITextViewWrapper: UIViewRepresentable { let textView = UITextView() textView.delegate = context.coordinator + textView.textColor = .element.primaryContent textView.isEditable = true textView.font = UIFont.preferredFont(forTextStyle: .body) textView.isSelectable = true @@ -92,9 +93,8 @@ private struct UITextViewWrapper: UIViewRepresentable { UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight) - if focused, textView.window != nil, !textView.isFirstResponder { - // Avoid cycle detected through attribute warnings - DispatchQueue.main.async { + DispatchQueue.main.async { // Avoid cycle detected through attribute warnings + if focused, textView.window != nil, !textView.isFirstResponder { textView.becomeFirstResponder() } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift index e9b3d6457..b8026de35 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift @@ -52,7 +52,8 @@ struct RoomHeaderView: View { .scaledToFill() .accessibilityIdentifier("roomAvatarImage") } else { - PlaceholderAvatarImage(text: context.viewState.roomTitle) + PlaceholderAvatarImage(text: context.viewState.roomTitle, + contentId: context.viewState.roomId) .accessibilityIdentifier("roomAvatarPlaceholderImage") } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 373695651..f6db3ce66 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -33,7 +33,6 @@ struct RoomScreen: View { context.send(viewAction: .cancelReply) } .padding() - .opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0) } .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 3e9fa902e..ec6d9b2c7 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -24,25 +24,30 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder let content: () -> Content @Environment(\.colorScheme) private var colorScheme - @ScaledMetric private var minBubbleWidth = 44 + @Environment(\.timelineWidth) private var timelineWidth + @ScaledMetric private var senderNameVerticalPadding = 3 + private let bubbleWidthPercentIncoming = 0.72 // 281/390 + private let bubbleWidthPercentOutgoing = 0.68 // 267/390 var body: some View { - VStack(alignment: alignment, spacing: -5) { + VStack(alignment: alignment, spacing: -12) { if !timelineItem.isOutgoing { header .zIndex(1) } - if timelineItem.isOutgoing { - HStack { - Spacer() - styledContentWithReactions - } - .padding(.trailing, 16) - .padding(.leading, 51) - } else { - styledContentWithReactions + VStack(alignment: alignment) { + if timelineItem.isOutgoing { + HStack { + Spacer() + styledContentWithReactions + } + .padding(.trailing, 16) .padding(.leading, 16) - .padding(.trailing, 51) + } else { + styledContentWithReactions + .padding(.leading, 24) + .padding(.trailing, 24) + } } } } @@ -56,10 +61,10 @@ struct TimelineItemBubbledStylerView: View { HStack(alignment: .top, spacing: 4) { TimelineSenderAvatarView(timelineItem: timelineItem) Text(timelineItem.senderDisplayName ?? timelineItem.senderId) - .font(.body) + .font(.element.footnoteBold) .foregroundColor(.element.primaryContent) - .fontWeight(.semibold) .lineLimit(1) + .padding(.vertical, senderNameVerticalPadding) } } } @@ -73,9 +78,10 @@ struct TimelineItemBubbledStylerView: View { if !timelineItem.properties.reactions.isEmpty { TimelineReactionsView(reactions: timelineItem.properties.reactions, - alignment: alignment) { key in + alignment: .leading) { key in context.send(viewAction: .sendReaction(key: key, eventID: timelineItem.id)) } + .frame(width: bubbleWidth - 24) .padding(.horizontal, 12) } } @@ -83,24 +89,27 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var styledContent: some View { + if timelineItem.isOutgoing { + styledContentOutgoing + } else { + styledContentIncoming + } + } + + @ViewBuilder + var styledContentOutgoing: some View { + if timelineItem.inGroupState == .single || timelineItem.inGroupState == .beginning { + Spacer() + .frame(height: 8) + } if shouldAvoidBubbling { - ZStack(alignment: .bottomTrailing) { - content() - .clipped() - .cornerRadius(8) - Text(timelineItem.timestamp) - .foregroundColor(.global.white) - .font(.element.caption2) - .padding(4) - .background(Color(white: 0, opacity: 0.7)) - .clipped() - .cornerRadius(8) - .offset(x: -8, y: -8) - } + content() + .frame(width: bubbleWidth) + .cornerRadius(12, inGroupState: timelineItem.inGroupState) } else { VStack(alignment: .trailing, spacing: 4) { content() - .frame(minWidth: minBubbleWidth, alignment: .leading) + .frame(width: bubbleWidth - 24, alignment: .leading) if timelineItem.properties.isEdited { Text(ElementL10n.editedSuffix) @@ -108,25 +117,50 @@ struct TimelineItemBubbledStylerView: View { .foregroundColor(.element.tertiaryContent) } } - .padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) - .clipped() - .background(bubbleColor) - .cornerRadius(12) + .padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .background(Color.element.systemGray5) + .cornerRadius(12, inGroupState: timelineItem.inGroupState) + } + } + + @ViewBuilder + var styledContentIncoming: some View { + if shouldAvoidBubbling { + content() + .frame(width: bubbleWidth) + .cornerRadius(12, inGroupState: timelineItem.inGroupState) + } else { + VStack(alignment: .trailing, spacing: 4) { + content() + .frame(width: bubbleWidth - 24, alignment: .leading) + + if timelineItem.properties.isEdited { + Text(ElementL10n.editedSuffix) + .font(.element.caption2) + .foregroundColor(.element.tertiaryContent) + } + } + .padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .background(Color.element.systemGray6) // Demo time! + .cornerRadius(12, inGroupState: timelineItem.inGroupState) // Demo time! +// .overlay( +// RoundedCorner(radius: 18, inGroupState: timelineItem.inGroupState) +// .stroke(Color.element.systemGray5) +// ) } } private var shouldAvoidBubbling: Bool { timelineItem is ImageRoomTimelineItem } - - private var bubbleColor: Color { - let opacity = colorScheme == .light ? 0.06 : 0.15 - return timelineItem.isOutgoing ? .element.accent.opacity(opacity) : .element.system - } private var alignment: HorizontalAlignment { timelineItem.isOutgoing ? .trailing : .leading } + + private var bubbleWidth: CGFloat { + timelineWidth * (timelineItem.isOutgoing ? bubbleWidthPercentOutgoing : bubbleWidthPercentIncoming) + } } struct TimelineItemBubbledStylerView_Previews: PreviewProvider { @@ -144,6 +178,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { } } .timelineStyle(.bubbles) + .timelineWidth(390) .padding(.horizontal, 8) .previewLayout(.sizeThatFits) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyle.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyle.swift index 0b1c02f86..332918995 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyle.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyle.swift @@ -50,3 +50,22 @@ extension View { environment(\.timelineStyle, style) } } + +// MARK: - Timeline Width + +private struct TimelineWidthKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + var timelineWidth: CGFloat { + get { self[TimelineWidthKey.self] } + set { self[TimelineWidthKey.self] = newValue } + } +} + +extension View { + func timelineWidth(_ width: CGFloat) -> some View { + environment(\.timelineWidth, width) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift index 696bfaa26..7099ef7e2 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift @@ -25,9 +25,9 @@ struct EmoteRoomTimelineView: View { HStack(alignment: .top) { Image(systemName: "face.dashed").padding(.top, 1.0) if let attributedComponents = timelineItem.attributedComponents { - FormattedBodyText(attributedComponents: attributedComponents) + FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents) } else { - FormattedBodyText(text: timelineItem.text) + FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text) } } } @@ -59,6 +59,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider { text: text, timestamp: timestamp, shouldShowSenderDetails: true, + inGroupState: .single, isOutgoing: false, senderId: senderId) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift index 442136a00..ab6a68137 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift @@ -18,19 +18,32 @@ import Foundation import SwiftUI struct FormattedBodyText: View { + #warning("this is a dirty fix for demo, should be refactored after new timeline api") + let isOutgoing: Bool let attributedComponents: [AttributedStringBuilderComponent] var body: some View { VStack(alignment: .leading, spacing: 8.0) { ForEach(attributedComponents, id: \.self) { component in if component.isBlockquote { - HStack(spacing: 4.0) { - Rectangle() - .foregroundColor(Color.red) - .frame(width: 4.0) + if isOutgoing { Text(component.attributedString) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.element.primaryContent) + .padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .clipped() + .background(Color.element.systemGray4) + .cornerRadius(13) + } else { + Text(component.attributedString) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.element.primaryContent) + .padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + .clipped() + .overlay( + RoundedRectangle(cornerRadius: 13) + .stroke(Color.element.systemGray5) + ) } } else { Text(component.attributedString) @@ -44,7 +57,8 @@ struct FormattedBodyText: View { } extension FormattedBodyText { - init(text: String) { + init(isOutgoing: Bool, text: String) { + self.isOutgoing = isOutgoing attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)] } } @@ -86,11 +100,11 @@ struct FormattedBodyText_Previews: PreviewProvider { let attributedString = attributedStringBuilder.fromHTML(htmlString) if let components = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) { - FormattedBodyText(attributedComponents: components) + FormattedBodyText(isOutgoing: true, attributedComponents: components) .fixedSize() } } - FormattedBodyText(text: "Some plain text that's not an attributed component.") + FormattedBodyText(isOutgoing: true, text: "Some plain text that's not an attributed component.") } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 1fa7b3d6c..5acf8a1a8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -21,39 +21,32 @@ struct ImageRoomTimelineView: View { let timelineItem: ImageRoomTimelineItem var body: some View { - if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes - TimelineStyler(timelineItem: timelineItem) { - if let image = timelineItem.image { - if let aspectRatio = timelineItem.aspectRatio { - Image(uiImage: image) - .resizable() - .aspectRatio(aspectRatio, contentMode: .fit) - } else { - Image(uiImage: image) - .resizable() - .scaledToFit() - } - } 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) - } - } - .id(timelineItem.id) - .animation(.elementDefault, value: timelineItem.image) - .frame(maxHeight: 1000.0) - } else { - TimelineStyler(timelineItem: timelineItem) { - HStack { - Spacer() + 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("Loading") - Spacer() + .frame(maxWidth: .infinity) } + .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } - .id(timelineItem.id) } + .id(timelineItem.id) + .animation(.elementDefault, value: timelineItem.image) + .frame(maxHeight: 1000.0) } } @@ -70,6 +63,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { text: "Some image", timestamp: "Now", shouldShowSenderDetails: false, + inGroupState: .single, isOutgoing: false, senderId: "Bob", source: nil, @@ -79,6 +73,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { text: "Some other image", timestamp: "Now", shouldShowSenderDetails: false, + inGroupState: .single, isOutgoing: false, senderId: "Bob", source: nil, @@ -88,6 +83,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { text: "Blurhashed image", timestamp: "Now", shouldShowSenderDetails: false, + inGroupState: .single, isOutgoing: false, senderId: "Bob", source: nil, diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index 5f67c7b9e..fa4cf27b0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -25,9 +25,9 @@ struct NoticeRoomTimelineView: View { HStack(alignment: .top) { Image(systemName: "exclamationmark.bubble").padding(.top, 2.0) if let attributedComponents = timelineItem.attributedComponents { - FormattedBodyText(attributedComponents: attributedComponents) + FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents) } else { - FormattedBodyText(text: timelineItem.text) + FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text) } } } @@ -60,6 +60,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider { text: text, timestamp: timestamp, shouldShowSenderDetails: true, + inGroupState: .single, isOutgoing: false, senderId: senderId) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift index 27dd26168..73b11f309 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PlaceholderAvatarImage.swift @@ -18,22 +18,32 @@ import SwiftUI struct PlaceholderAvatarImage: View { private let textForImage: String + private let contentId: String? var body: some View { ZStack { - Color.element.accent + bgColor Text(textForImage) .padding(4) - .foregroundColor(.white) - // Make the text resizable (i.e. Make it large and then allow it to scale down) - .font(.system(size: 200).weight(.semibold)) - .minimumScaleFactor(0.001) + .foregroundColor(.element.background) + .font(.title2.bold()) } .aspectRatio(1, contentMode: .fill) } - init(text: String) { + init(text: String, contentId: String? = nil) { textForImage = text.first?.uppercased() ?? "" + self.contentId = contentId + } + + private var bgColor: Color { + guard let contentId = contentId else { + return .element.accent + } + + let colors = Color.element.contentAndAvatars + let colorIndex = Int(contentId.hashCode % Int32(colors.count)) + return Color.element.contentAndAvatars[colorIndex % colors.count] } } @@ -45,7 +55,7 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider { @ViewBuilder static var body: some View { - PlaceholderAvatarImage(text: "X") + PlaceholderAvatarImage(text: "X", contentId: "@userid:matrix.org") .clipShape(Circle()) .frame(width: 150, height: 100) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift index fdca08b8e..cdfbcda0d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift @@ -21,33 +21,12 @@ struct SeparatorRoomTimelineView: View { let timelineItem: SeparatorRoomTimelineItem var body: some View { - LabelledDivider(label: timelineItem.text) + Text(timelineItem.text) + .font(.element.footnote) + .foregroundColor(.element.secondaryContent) .id(timelineItem.id) .padding(.vertical, 8) - } -} - -struct LabelledDivider: View { - let label: String - let color: Color - - init(label: String, color: Color = Color.element.secondaryContent) { - self.label = label - self.color = color - } - - var body: some View { - HStack { - line - Text(label) - .foregroundColor(color) - .fixedSize() - line - } - } - - var line: some View { - VStack { Divider().background(color) } + .frame(maxWidth: .infinity) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index 418df3892..9e31ae812 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -23,9 +23,9 @@ struct TextRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { if let attributedComponents = timelineItem.attributedComponents { - FormattedBodyText(attributedComponents: attributedComponents) + FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents) } else { - FormattedBodyText(text: timelineItem.text) + FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text) } } .id(timelineItem.id) @@ -75,6 +75,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider { text: text, timestamp: timestamp, shouldShowSenderDetails: shouldShowSenderDetails, + inGroupState: .single, isOutgoing: isOutgoing, senderId: senderId) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift index acb5cf640..ec0e13ced 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -28,26 +28,26 @@ struct TimelineItemList: View { let bottomVisiblePublisher: PassthroughSubject let scrollToBottomPublisher: PassthroughSubject - + + @State private var viewFrame: CGRect = .zero + var body: some View { // The observer behaves differently when not in an reader ScrollViewReader { _ in List { - HStack { - Spacer() - ProgressView() - .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) - .animation(.elementDefault, value: context.viewState.isBackPaginating) - Spacer() - } - .listRowBackground(Color.clear) + ProgressView() + .frame(maxWidth: .infinity) + .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) + .animation(.elementDefault, value: context.viewState.isBackPaginating) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) // No idea why previews don't work otherwise ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in timelineItem - .contextMenu(menuItems: { + .contextMenu { context.viewState.contextMenuBuilder?(timelineItem.id) - }) + } .opacity(opacityForItem(timelineItem)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -65,6 +65,8 @@ struct TimelineItemList: View { } } .listStyle(.plain) + .background(ViewFrameReader(frame: $viewFrame)) + .environment(\.timelineWidth, viewFrame.width) .timelineStyle(settings.timelineStyle) .environment(\.defaultMinListRowHeight, 0.0) .introspectTableView { tableView in @@ -81,24 +83,24 @@ struct TimelineItemList: View { // Check if there are enough items. Otherwise ask for more attemptBackPagination() } - .onAppear(perform: { + .onAppear { if timelineItems != context.viewState.items { timelineItems = context.viewState.items } - }) - .onReceive(scrollToBottomPublisher, perform: { + } + .onReceive(scrollToBottomPublisher) { tableViewObserver.scrollToBottom(animated: true) - }) - .onReceive(tableViewObserver.scrollViewTopVisiblePublisher, perform: { isTopVisible in + } + .onReceive(tableViewObserver.scrollViewTopVisiblePublisher) { isTopVisible in if !isTopVisible || context.viewState.isBackPaginating { return } attemptBackPagination() - }) - .onReceive(tableViewObserver.scrollViewBottomVisiblePublisher, perform: { isBottomVisible in + } + .onReceive(tableViewObserver.scrollViewBottomVisiblePublisher) { isBottomVisible in bottomVisiblePublisher.send(isBottomVisible) - }) + } .onChange(of: context.viewState.items) { _ in // Don't update the list while moving if tableViewObserver.isDecelerating || tableViewObserver.isTracking { @@ -109,7 +111,7 @@ struct TimelineItemList: View { tableViewObserver.saveCurrentOffset() timelineItems = context.viewState.items } - .onReceive(tableViewObserver.scrollViewDidRestPublisher, perform: { + .onReceive(tableViewObserver.scrollViewDidRestPublisher) { if hasPendingChanges == false { return } @@ -117,13 +119,13 @@ struct TimelineItemList: View { tableViewObserver.saveCurrentOffset() timelineItems = context.viewState.items hasPendingChanges = false - }) - .onChange(of: timelineItems, perform: { _ in + } + .onChange(of: timelineItems) { _ in tableViewObserver.restoreSavedOffset() // Check if there are enough items. Otherwise ask for more attemptBackPagination() - }) + } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift index 1f0ab2e57..88c6bb505 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineSenderAvatarView.swift @@ -20,7 +20,7 @@ import SwiftUI struct TimelineSenderAvatarView: View { let timelineItem: EventBasedTimelineItemProtocol - @ScaledMetric private var avatarSize = 26 + @ScaledMetric private var avatarSize = 32 var body: some View { ZStack(alignment: .center) { @@ -30,14 +30,15 @@ struct TimelineSenderAvatarView: View { .scaledToFill() .overlay(Circle().stroke(Color.element.accent)) } else { - PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId) + PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId, + contentId: timelineItem.senderId) } } .clipShape(Circle()) .frame(width: avatarSize, height: avatarSize) .overlay( Circle() - .stroke(Color.element.background, lineWidth: 2) + .stroke(Color.element.background, lineWidth: 3) ) .animation(.elementDefault, value: timelineItem.senderAvatar) diff --git a/ElementX/Sources/Screens/SessionVerification/SessionVerificationModels.swift b/ElementX/Sources/Screens/SessionVerification/SessionVerificationModels.swift index ce39a9c2f..a64cbfff9 100644 --- a/ElementX/Sources/Screens/SessionVerification/SessionVerificationModels.swift +++ b/ElementX/Sources/Screens/SessionVerification/SessionVerificationModels.swift @@ -27,12 +27,34 @@ enum SessionVerificationViewModelAction { struct SessionVerificationViewState: BindableState { var verificationState: SessionVerificationStateMachine.State = .initial - var shouldDisableDismissButton: Bool { - verificationState != .verified + var title: String? { + switch verificationState { + case .showingChallenge: + return ElementL10n.sessionVerificationScreenEmojisTitle + default: + return nil + } } - var shouldDisableCancelButton: Bool { - verificationState == .verified + var message: String { + switch verificationState { + case .initial: + return ElementL10n.verificationOpenOtherToVerify + case .requestingVerification: + return ElementL10n.verificationRequestWaiting + case .acceptingChallenge: + return ElementL10n.verificationRequestWaiting + case .decliningChallenge: + return ElementL10n.verificationRequestWaiting + case .cancelling: + return ElementL10n.verificationRequestWaiting + case .showingChallenge: + return ElementL10n.sessionVerificationScreenEmojisMessage + case .verified: + return ElementL10n.verificationConclusionOkSelfNotice + case .cancelled: + return ElementL10n.verificationCancelled + } } } @@ -41,6 +63,5 @@ enum SessionVerificationViewAction { case restart case accept case decline - case dismiss - case cancel + case close } diff --git a/ElementX/Sources/Screens/SessionVerification/SessionVerificationStateMachine.swift b/ElementX/Sources/Screens/SessionVerification/SessionVerificationStateMachine.swift index d0476d7a0..490f8d8b6 100644 --- a/ElementX/Sources/Screens/SessionVerification/SessionVerificationStateMachine.swift +++ b/ElementX/Sources/Screens/SessionVerification/SessionVerificationStateMachine.swift @@ -66,22 +66,10 @@ class SessionVerificationStateMachine { stateMachine.state } - // swiftlint:disable cyclomatic_complexity init() { stateMachine = StateMachine(state: .initial) { machine in machine.addRoutes(event: .requestVerification, transitions: [.initial => .requestingVerification]) machine.addRoutes(event: .didFail, transitions: [.requestingVerification => .initial]) - - machine.addRoutes(event: .cancel, transitions: [.requestingVerification => .cancelling]) - machine.addRoutes(event: .didCancel, transitions: [.requestingVerification => .cancelled]) - - // Cancellation request from the other party should either take us from `.cancelling` - // to `.cancelled` or keep us in `.cancelled` if already there. There is more `.didCancel` - // handling in `addRouteMapping` for states containing associated values - machine.addRoutes(event: .didCancel, transitions: [.cancelling => .cancelled]) - machine.addRoutes(event: .didCancel, transitions: [.cancelled => .cancelled]) - machine.addRoutes(event: .didFail, transitions: [.cancelled => .cancelled]) - machine.addRoutes(event: .restart, transitions: [.cancelled => .initial]) // Transitions with associated values need to be handled through `addRouteMapping` @@ -103,18 +91,9 @@ class SessionVerificationStateMachine { case (.didFail, .decliningChallenge(let emojis)): return .showingChallenge(emojis: emojis) - case (.cancel, .showingChallenge): + case (.cancel, _): return .cancelling - case (.cancel, .acceptingChallenge): - return .cancelling - case (.cancel, .decliningChallenge): - return .cancelling - - case (.didCancel, .showingChallenge): - return .cancelled - case (.didCancel, .acceptingChallenge): - return .cancelled - case (.didCancel, .decliningChallenge): + case (.didCancel, _): return .cancelled default: @@ -123,8 +102,6 @@ class SessionVerificationStateMachine { } } } - - // swiftlint:enable cyclomatic_complexity /// Attempt to move the state machine to another state through an event /// It will either invoke the `transitionHandler` or the `errorHandler` depending on its current state diff --git a/ElementX/Sources/Screens/SessionVerification/SessionVerificationViewModel.swift b/ElementX/Sources/Screens/SessionVerification/SessionVerificationViewModel.swift index e34ab03ab..e1b5313c0 100644 --- a/ElementX/Sources/Screens/SessionVerification/SessionVerificationViewModel.swift +++ b/ElementX/Sources/Screens/SessionVerification/SessionVerificationViewModel.swift @@ -74,9 +74,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer stateMachine.processEvent(.requestVerification) case .restart: stateMachine.processEvent(.restart) - case .dismiss: - callback?(.finished) - case .cancel: + case .close: guard stateMachine.state == .initial || stateMachine.state == .verified || stateMachine.state == .cancelled else { @@ -115,7 +113,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer } stateMachine.addErrorHandler { context in - fatalError("Failed transition with context: \(context)") + MXLog.error("Failed transition with context: \(context)") } } diff --git a/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift b/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift index 06972b5f8..2db92cdec 100644 --- a/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift +++ b/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift @@ -14,7 +14,6 @@ // limitations under the License. // -import MatrixRustSDK import SwiftUI struct SessionVerificationScreen: View { @@ -24,127 +23,132 @@ struct SessionVerificationScreen: View { var body: some View { NavigationView { - VStack(spacing: 32.0) { - Text(heading) - .font(.body) - .multilineTextAlignment(.center) - .foregroundColor(.element.primaryContent) - .accessibilityIdentifier("titleLabel") - - switch context.viewState.verificationState { - case .initial: - StateIcon(systemName: "lock.shield") - Button(ElementL10n.startVerification) { - context.send(viewAction: .start) - } - .buttonStyle(.elementAction(.regular)) - .accessibilityIdentifier("startButton") - - case .cancelled: - StateIcon(systemName: "xmark.shield") - .accessibilityIdentifier("sessionVerificationFailedIcon") - - Button(ElementL10n.globalRetry) { - context.send(viewAction: .restart) - } - .buttonStyle(.elementAction(.regular)) - .accessibilityIdentifier("restartButton") - - case .requestingVerification: - ProgressView() - .accessibilityIdentifier("requestingVerificationProgressView") - case .cancelling: - ProgressView() - .accessibilityIdentifier("cancellingVerificationProgressView") - case .acceptingChallenge: - ProgressView() - .accessibilityIdentifier("acceptingChallengeProgressView") - case .decliningChallenge: - ProgressView() - .accessibilityIdentifier("decliningChallengeProgressView") - - case .showingChallenge(let emojis): - HStack(spacing: 8.0) { - ForEach(emojis.prefix(4), id: \.self) { emoji in - EmojiView(emoji: emoji) - } - } - HStack(spacing: 8.0) { - ForEach(emojis.suffix(from: 4), id: \.self) { emoji in - EmojiView(emoji: emoji) - } + ScrollView { + VStack(spacing: 32.0) { + if let title = context.viewState.title { + Text(title) + .font(.element.headlineBold) + .foregroundColor(.element.systemPrimaryLabel) + .multilineTextAlignment(.center) } - actionButtons - case .verified: - StateIcon(systemName: "checkmark.shield") - .accessibilityIdentifier("sessionVerificationSucceededIcon") - } - - Spacer() - } - .padding() - .padding(.top, 64) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(ElementL10n.verificationVerifyDevice) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(ElementL10n.done) { - context.send(viewAction: .dismiss) - } - .disabled(context.viewState.shouldDisableDismissButton) - .accessibilityIdentifier("dismissButton") - } - ToolbarItem(placement: .cancellationAction) { - Button(ElementL10n.actionCancel) { - context.send(viewAction: .cancel) - } - .disabled(context.viewState.shouldDisableCancelButton) - .accessibilityIdentifier("cancelButton") + Text(context.viewState.message) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.element.systemPrimaryLabel) + .accessibilityIdentifier("titleLabel") + + mainContent } + .padding() + .padding(.top, 64) + .frame(maxWidth: .infinity) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(ElementL10n.verificationProfileVerify) + .toolbar { toolbarContent } } + .background(Color.element.systemSecondaryBackground) + .safeAreaInset(edge: .bottom) { actionButtons.padding() } } - .navigationViewStyle(StackNavigationViewStyle()) + .navigationViewStyle(.stack) } // MARK: - Private - private var heading: String { + @ViewBuilder + private var mainContent: some View { switch context.viewState.verificationState { case .initial: - return ElementL10n.verificationOpenOtherToVerify - case .requestingVerification: - return ElementL10n.verificationRequestWaiting - case .acceptingChallenge: - return ElementL10n.verificationRequestWaiting - case .decliningChallenge: - return ElementL10n.verificationRequestWaiting - case .cancelling: - return ElementL10n.verificationRequestWaiting - case .showingChallenge: - return ElementL10n.verificationEmojiNotice - case .verified: - return ElementL10n.verificationConclusionOkSelfNotice + StateIcon(systemName: "lock.shield") + case .cancelled: - return ElementL10n.verificationCancelled + StateIcon(systemName: "xmark.shield") + .accessibilityIdentifier("sessionVerificationFailedIcon") + + case .requestingVerification: + ProgressView() + .accessibilityIdentifier("requestingVerificationProgressView") + case .cancelling: + ProgressView() + .accessibilityIdentifier("cancellingVerificationProgressView") + case .acceptingChallenge: + ProgressView() + .accessibilityIdentifier("acceptingChallengeProgressView") + case .decliningChallenge: + ProgressView() + .accessibilityIdentifier("decliningChallengeProgressView") + + case .showingChallenge(let emojis): + HStack(spacing: 16) { + ForEach(emojis.prefix(4), id: \.self) { emoji in + EmojiView(emoji: emoji) + } + } + HStack(spacing: 16) { + ForEach(emojis.suffix(from: 4), id: \.self) { emoji in + EmojiView(emoji: emoji) + } + } + case .verified: + StateIcon(systemName: "checkmark.shield") + .accessibilityIdentifier("sessionVerificationSucceededIcon") } } + @ViewBuilder private var actionButtons: some View { - HStack(spacing: 16.0) { - Button(ElementL10n.verificationSasDoNotMatch) { - context.send(viewAction: .decline) + switch context.viewState.verificationState { + case .initial: + Button(ElementL10n.startVerification) { + context.send(viewAction: .start) } - .buttonStyle(.elementAction(.regular, color: .red)) - .accessibilityLabel("challengeDeclineButton") - - Button(ElementL10n.verificationSasMatch) { - context.send(viewAction: .accept) + .buttonStyle(.elementAction(.xLarge)) + .accessibilityIdentifier("startButton") + + case .cancelled: + Button(ElementL10n.globalRetry) { + context.send(viewAction: .restart) } - .buttonStyle(.elementAction(.regular)) - .accessibilityLabel("challengeAcceptButton") + .buttonStyle(.elementAction(.xLarge)) + .accessibilityIdentifier("restartButton") + + case .showingChallenge: + VStack(spacing: 30) { + Button { context.send(viewAction: .accept) } label: { + Label(ElementL10n.actionMatch, systemImage: "checkmark") + } + .buttonStyle(.elementAction(.xLarge)) + .accessibilityLabel("challengeAcceptButton") + + Button(ElementL10n.no) { + context.send(viewAction: .decline) + } + .font(.element.bodyBold) + .accessibilityLabel("challengeDeclineButton") + } + + case .verified: + Button(ElementL10n.finish) { + context.send(viewAction: .close) + } + .buttonStyle(.elementAction(.xLarge)) + .accessibilityIdentifier("finishButton") + + default: + EmptyView() + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { context.send(viewAction: .close) } label: { + Image(systemName: "xmark") + } + .font(.element.bodyBold) + .foregroundColor(.element.systemSecondaryLabel) + .accessibilityIdentifier("closeButton") } - .padding(32.0) } struct EmojiView: View { @@ -153,9 +157,10 @@ struct SessionVerificationScreen: View { var body: some View { VStack(spacing: 16.0) { Text(emoji.symbol) - .font(.largeTitle) + .font(.element.largeTitleBold) Text(emoji.description) - .font(.body) + .font(.element.caption2) + .foregroundColor(.element.systemSecondaryLabel) } .padding(8.0) } @@ -167,6 +172,7 @@ struct SessionVerificationScreen: View { var body: some View { Image(systemName: systemName) .resizable() + .font(.element.body.weight(.light)) .scaledToFit() .foregroundColor(.element.accent) .frame(width: 100, height: 100) @@ -181,23 +187,24 @@ struct SessionVerification_Previews: PreviewProvider { body.preferredColorScheme(.light) body.preferredColorScheme(.dark) } - + @ViewBuilder static var body: some View { Group { sessionVerificationScreen(state: .initial) sessionVerificationScreen(state: .requestingVerification) sessionVerificationScreen(state: .cancelled) - + sessionVerificationScreen(state: .showingChallenge(emojis: MockSessionVerificationControllerProxy.emojis)) sessionVerificationScreen(state: .verified) } + .tint(Color.element.accent) } - + static func sessionVerificationScreen(state: SessionVerificationStateMachine.State) -> some View { let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy(), initialState: SessionVerificationViewState(verificationState: state)) - + return SessionVerificationScreen(context: viewModel.context) } } diff --git a/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift index 67bed04d1..b9873b4a2 100644 --- a/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift @@ -23,6 +23,7 @@ struct SettingsCoordinatorParameters { } enum SettingsCoordinatorAction { + case dismiss case logout } @@ -38,6 +39,8 @@ final class SettingsCoordinator: Coordinator, Presentable { private var indicatorPresenter: UserIndicatorTypePresenterProtocol private var loadingIndicator: UserIndicator? private var statusIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } // MARK: Public @@ -50,7 +53,7 @@ final class SettingsCoordinator: Coordinator, Presentable { init(parameters: SettingsCoordinatorParameters) { self.parameters = parameters - let viewModel = SettingsViewModel() + let viewModel = SettingsViewModel(withUserSession: parameters.userSession) let view = SettingsScreen(context: viewModel.context) settingsViewModel = viewModel settingsHostingController = UIHostingController(rootView: view) @@ -61,6 +64,8 @@ final class SettingsCoordinator: Coordinator, Presentable { guard let self = self else { return } MXLog.debug("SettingsViewModel did complete with result: \(result).") switch result { + case .close: + self.callback?(.dismiss) case .toggleAnalytics: self.toggleAnalytics() case .reportBug: @@ -68,7 +73,7 @@ final class SettingsCoordinator: Coordinator, Presentable { case .crash: self.parameters.bugReportService.crash() case .logout: - self.callback?(.logout) + self.confirmSignOut() } } } @@ -106,12 +111,25 @@ final class SettingsCoordinator: Coordinator, Presentable { add(childCoordinator: coordinator) coordinator.start() - parameters.navigationRouter.push(coordinator, animated: true) { [weak self] in + navigationRouter.push(coordinator, animated: true) { [weak self] in guard let self = self else { return } self.remove(childCoordinator: coordinator) } } + + private func confirmSignOut() { + let alert = UIAlertController(title: ElementL10n.actionSignOut, + message: ElementL10n.actionSignOutConfirmationSimple, + preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel)) + alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { [weak self] _ in + self?.callback?(.logout) + }) + + navigationRouter.present(alert, animated: true) + } /// Show an activity indicator whilst loading. /// - Parameters: diff --git a/ElementX/Sources/Screens/Settings/SettingsModels.swift b/ElementX/Sources/Screens/Settings/SettingsModels.swift index ae1ead79f..1a1c2936e 100644 --- a/ElementX/Sources/Screens/Settings/SettingsModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsModels.swift @@ -15,12 +15,14 @@ // import Foundation +import UIKit // MARK: - Coordinator // MARK: View model enum SettingsViewModelAction { + case close case toggleAnalytics case reportBug case crash @@ -31,6 +33,9 @@ enum SettingsViewModelAction { struct SettingsViewState: BindableState { var bindings: SettingsViewStateBindings + var userID: String + var userAvatar: UIImage? + var userDisplayName: String? } struct SettingsViewStateBindings { @@ -38,6 +43,7 @@ struct SettingsViewStateBindings { } enum SettingsViewAction { + case close case toggleAnalytics case reportBug case crash diff --git a/ElementX/Sources/Screens/Settings/SettingsViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsViewModel.swift index 86c2d90e6..db407a374 100644 --- a/ElementX/Sources/Screens/Settings/SettingsViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsViewModel.swift @@ -25,21 +25,38 @@ class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol { // MARK: Private + private let userSession: UserSessionProtocol + // MARK: Public var callback: ((SettingsViewModelAction) -> Void)? // MARK: - Setup - init() { + init(withUserSession userSession: UserSessionProtocol) { + self.userSession = userSession let bindings = SettingsViewStateBindings() - super.init(initialViewState: .init(bindings: bindings)) + super.init(initialViewState: .init(bindings: bindings, userID: userSession.userID)) + + Task { + if case let .success(userAvatarURLString) = await userSession.clientProxy.loadUserAvatarURLString() { + if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURLString(userAvatarURLString, size: MediaProviderDefaultAvatarSize) { + state.userAvatar = avatar + } + } + + if case let .success(userDisplayName) = await self.userSession.clientProxy.loadUserDisplayName() { + state.userDisplayName = userDisplayName + } + } } // MARK: - Public override func process(viewAction: SettingsViewAction) async { switch viewAction { + case .close: + callback?(.close) case .toggleAnalytics: callback?(.toggleAnalytics) case .reportBug: diff --git a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift index 47a35eebf..44cb2cc60 100644 --- a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift @@ -19,9 +19,12 @@ import SwiftUI struct SettingsScreen: View { // MARK: Private - @State private var showingLogoutConfirmation = false @Environment(\.colorScheme) private var colorScheme @ObservedObject private var settings = ElementSettings.shared + + @ScaledMetric private var avatarSize = 60.0 + @ScaledMetric private var menuIconSize = 30.0 + private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) // MARK: Public @@ -31,6 +34,12 @@ struct SettingsScreen: View { var body: some View { Form { + userSection + .listRowBackground(rowBackgroundColor) + + appearanceSection + .listRowBackground(rowBackgroundColor) + analyticsSection .listRowBackground(rowBackgroundColor) @@ -44,7 +53,13 @@ struct SettingsScreen: View { tableView.backgroundColor = .clear } .navigationTitle(ElementL10n.settings) + .navigationBarTitleDisplayMode(.inline) .background(backgroundColor, ignoresSafeAreaEdges: .all) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + closeButton + } + } } private var versionText: some View { @@ -52,25 +67,85 @@ struct SettingsScreen: View { } private var backgroundColor: Color { - colorScheme == .light ? .element.system : .element.background + .element.systemGray6 } private var rowBackgroundColor: Color { colorScheme == .light ? .element.background : .element.system } + + private var userSection: some View { + Section { + HStack(spacing: 13) { + userAvatar + VStack(alignment: .leading, spacing: 4) { + Text(context.viewState.userDisplayName ?? "") + .font(.title3) + Text(context.viewState.userID) + .font(.subheadline) + } + } + .listRowInsets(listRowInsets) + } + } + + private var appearanceSection: some View { + Section { + Button(action: appearance) { + HStack { + Image(systemName: "paintpalette") + .foregroundColor(.element.systemGray) + .padding(4) + .background(Color.element.systemGray6) + .clipShape(Circle()) + .frame(width: menuIconSize, height: menuIconSize) + Text(ElementL10n.settingsAppearance) + Spacer() + Image(systemName: "chevron.forward") + .foregroundColor(.element.tertiaryContent) + } + } + .listRowInsets(listRowInsets) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("appearanceButton") + } + } + + @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(ElementL10n.settingsAnalytics) { + Section { Button { context.send(viewAction: .reportBug) } label: { - Text(ElementL10n.sendBugReport) + HStack { + Text(ElementL10n.sendBugReport) + Spacer() + Image(systemName: "chevron.forward") + .foregroundColor(.element.tertiaryContent) + } } + .listRowInsets(listRowInsets) + .listRowSeparator(.hidden) .foregroundColor(.element.primaryContent) .accessibilityIdentifier("reportBugButton") if BuildSettings.settingsCrashButtonVisible { - Button("Crash the app", + Button("Crash app", role: .destructive) { context.send(viewAction: .crash) } + .listRowInsets(listRowInsets) .accessibilityIdentifier("crashButton") } } @@ -79,13 +154,14 @@ struct SettingsScreen: View { @ViewBuilder private var userInterfaceSection: some View { if BuildSettings.settingsShowTimelineStyle { - Section(ElementL10n.settingsUserInterface) { + Section { Picker(ElementL10n.settingsTimelineStyle, selection: $settings.timelineStyle) { ForEach(TimelineStyle.allCases, id: \.self) { style in Text(style.description) .tag(style) } } + .listRowInsets(listRowInsets) .accessibilityIdentifier("timelineStylePicker") } } @@ -93,23 +169,52 @@ struct SettingsScreen: View { private var logoutSection: some View { Section { - Button { showingLogoutConfirmation = true } label: { - Text(ElementL10n.actionSignOut) - } - .frame(maxWidth: .infinity) - .foregroundColor(.element.primaryContent) - .accessibilityIdentifier("logoutButton") - .confirmationDialog(ElementL10n.actionSignOutConfirmationSimple, - isPresented: $showingLogoutConfirmation, - titleVisibility: .visible) { - Button(ElementL10n.actionSignOut, - role: .destructive) { context.send(viewAction: .logout) + Button(action: logout) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + .foregroundColor(.element.systemGray) + .padding(4) + .background(Color.element.systemGray6) + .clipShape(Circle()) + .frame(width: menuIconSize, height: menuIconSize) + Text(ElementL10n.actionSignOut) + Spacer() + Image(systemName: "chevron.forward") + .foregroundColor(.element.tertiaryContent) } } + .listRowInsets(listRowInsets) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("logoutButton") } footer: { versionText + .frame(maxWidth: .infinity) } } + + private var closeButton: some View { + Button(action: close) { + HStack { + Image(systemName: "xmark") + .font(.title3.bold()) + .foregroundColor(.element.secondaryContent) + .padding(4) + } + } + .accessibilityIdentifier("closeButton") + } + + private func appearance() { + #warning("Not implemented") + } + + private func close() { + context.send(viewAction: .close) + } + + private func logout() { + context.send(viewAction: .logout) + } } extension TimelineStyle: CustomStringConvertible { @@ -133,8 +238,13 @@ struct Settings_Previews: PreviewProvider { @ViewBuilder static var body: some View { - let viewModel = SettingsViewModel() - SettingsScreen(context: viewModel.context) - .previewInterfaceOrientation(.portrait) + let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@userid:example.com"), + mediaProvider: MockMediaProvider()) + let viewModel = SettingsViewModel(withUserSession: userSession) + + return NavigationView { + SettingsScreen(context: viewModel.context) + .previewInterfaceOrientation(.portrait) + } } } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift index 9133604ec..a7c4b1d35 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift @@ -108,13 +108,10 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol { Benchmark.startTrackingForIdentifier("Login", message: "Started new login") let loginTask: Task = Task.detached { - #warning("Use new api on next SDK release.") - return try self.authenticationService.login(username: username, - password: password) -// try self.authenticationService.login(username: username, -// password: password, -// initialDeviceName: initialDeviceName, -// deviceId: deviceId) + try self.authenticationService.login(username: username, + password: password, + initialDeviceName: initialDeviceName, + deviceId: deviceId) } switch await loginTask.result { diff --git a/ElementX/Sources/Services/BugReport/BugReportService.swift b/ElementX/Sources/Services/BugReport/BugReportService.swift index eb634a183..db68e6549 100644 --- a/ElementX/Sources/Services/BugReport/BugReportService.swift +++ b/ElementX/Sources/Services/BugReport/BugReportService.swift @@ -16,7 +16,6 @@ import Foundation import GZIP -import MatrixRustSDK import Sentry import UIKit diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index c4b6918ea..c52e9b12a 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -19,64 +19,91 @@ import Foundation import MatrixRustSDK import UIKit -private class WeakClientProxyWrapper: ClientDelegate { +private class WeakClientProxyWrapper: ClientDelegate, SlidingSyncObserver { private weak var clientProxy: ClientProxy? init(clientProxy: ClientProxy) { self.clientProxy = clientProxy } - func didReceiveSyncUpdate() { - clientProxy?.didReceiveSyncUpdate() - } + // MARK: - ClientDelegate + + func didReceiveSyncUpdate() { } func didReceiveAuthError(isSoftLogout: Bool) { - clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout) + Task { + await clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout) + } } func didUpdateRestoreToken() { - clientProxy?.didUpdateRestoreToken() + Task { + await clientProxy?.didUpdateRestoreToken() + } + } + + // MARK: - SlidingSyncDelegate + + func didReceiveSyncUpdate(summary: UpdateSummary) { + Task { + await self.clientProxy?.didReceiveSlidingSyncUpdate(summary: summary) + } } } class ClientProxy: ClientProxyProtocol { /// The maximum number of timeline events required during a sync request. - static let syncLimit: UInt16 = 20 + static let syncLimit: UInt16 = 50 - private let client: Client + private let client: ClientProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol private var sessionVerificationControllerProxy: SessionVerificationControllerProxy? - private(set) var rooms: [RoomProxy] = [] { - didSet { - callbacks.send(.updatedRoomsList) - } - } + private var slidingSyncObserverToken: StoppableSpawn? + private let slidingSync: SlidingSync + + private var roomProxies = [String: RoomProxyProtocol]() + + let roomSummaryProvider: RoomSummaryProviderProtocol deinit { + // These need to be inlined instead of using stopSync() + // as we can't call async methods safely from deinit client.setDelegate(delegate: nil) + slidingSyncObserverToken?.cancel() + slidingSync.setObserver(observer: nil) } let callbacks = PassthroughSubject() - init(client: Client, + init(client: ClientProtocol, backgroundTaskService: BackgroundTaskServiceProtocol) { self.client = client self.backgroundTaskService = backgroundTaskService + do { + let slidingSyncBuilder = try client.slidingSync().homeserver(url: BuildSettings.slidingSyncProxyBaseURL.absoluteString) + + let slidingSyncView = try SlidingSyncViewBuilder() + .timelineLimit(limit: 10) + .requiredState(requiredState: [RequiredState(key: "m.room.avatar", value: "")]) + .name(name: "HomeScreenView") + .syncMode(mode: .fullSync) + .build() + + slidingSync = try slidingSyncBuilder + .addView(view: slidingSyncView) +// .withCommonExtensions() + .build() + + roomSummaryProvider = RoomSummaryProvider(slidingSyncController: slidingSync, + slidingSyncView: slidingSyncView, + roomMessageFactory: RoomMessageFactory()) + } catch { + fatalError("Failed configuring sliding sync") + } + client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self)) - - #warning("Use isSoftLogout() api on next SDK release.") - Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.") - client.startSync(timelineLimit: ClientProxy.syncLimit) - - Task { await updateRooms() } -// if !client.isSoftLogout() { -// Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.") -// client.startSync(timelineLimit: ClientProxy.syncLimit) -// -// Task { await updateRooms() } -// } } var userIdentifier: String { @@ -89,9 +116,7 @@ class ClientProxy: ClientProxyProtocol { } var isSoftLogout: Bool { - #warning("Use isSoftLogout() api on next SDK release.") - return false -// client.isSoftLogout() + client.isSoftLogout() } var deviceId: String? { @@ -116,6 +141,46 @@ class ClientProxy: ClientProxyProtocol { } } + func startSync() { + guard !client.isSoftLogout() else { + return + } + + slidingSync.setObserver(observer: WeakClientProxyWrapper(clientProxy: self)) + slidingSyncObserverToken = slidingSync.sync() + } + + func stopSync() { + client.setDelegate(delegate: nil) + + slidingSyncObserverToken?.cancel() + slidingSync.setObserver(observer: nil) + } + + func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? { + if let roomProxy = roomProxies[identifier] { + return roomProxy + } + + do { + guard let slidingSyncRoom = try slidingSync.getRoom(roomId: identifier), + let room = slidingSyncRoom.fullRoom() else { + MXLog.error("Failed retrieving room with identifier: \(identifier)") + return nil + } + + let roomProxy = RoomProxy(slidingSyncRoom: slidingSyncRoom, + room: room, + backgroundTaskService: backgroundTaskService) + roomProxies[identifier] = roomProxy + + return roomProxy + } catch { + MXLog.error("Failed retrieving room with identifier: \(identifier)") + return nil + } + } + func loadUserDisplayName() async -> Result { await Task.detached { do { @@ -152,9 +217,18 @@ class ClientProxy: ClientProxyProtocol { MatrixRustSDK.mediaSourceFromUrl(url: urlString) } - func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data { - let bytes = try client.getMediaContent(source: source) - return Data(bytes: bytes, count: bytes.count) + func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data { + try await Task.detached { + let bytes = try self.client.getMediaContent(source: source) + return Data(bytes: bytes, count: bytes.count) + }.value + } + + func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data { + try await Task.detached { + let bytes = try self.client.getMediaThumbnail(source: source, width: UInt64(width), height: UInt64(height)) + return Data(bytes: bytes, count: bytes.count) + }.value } func sessionVerificationControllerProxy() async -> Result { @@ -171,8 +245,7 @@ class ClientProxy: ClientProxyProtocol { func logout() async { do { - #warning("Use logout() api on next SDK release.") -// try client.logout() + try client.logout() } catch { MXLog.error("Failed logging out with error: \(error)") } @@ -180,16 +253,6 @@ class ClientProxy: ClientProxyProtocol { // MARK: Private - fileprivate func didReceiveSyncUpdate() { - Benchmark.logElapsedDurationForIdentifier("ClientSync", message: "Received sync update") - - callbacks.send(.receivedSyncUpdate) - - Task.detached { - await self.updateRooms() - } - } - fileprivate func didReceiveAuthError(isSoftLogout: Bool) { callbacks.send(.receivedAuthError(isSoftLogout: isSoftLogout)) } @@ -198,32 +261,9 @@ class ClientProxy: ClientProxyProtocol { callbacks.send(.updatedRestoreToken) } - private func updateRooms() async { - var currentRooms = rooms - Benchmark.startTrackingForIdentifier("ClientRooms", message: "Fetching available rooms") - let sdkRooms = client.rooms() - Benchmark.endTrackingForIdentifier("ClientRooms", message: "Retrieved \(sdkRooms.count) rooms") + fileprivate func didReceiveSlidingSyncUpdate(summary: UpdateSummary) { + roomSummaryProvider.updateRoomsWithIdentifiers(summary.rooms) - Benchmark.startTrackingForIdentifier("ProcessingRooms", message: "Started processing \(sdkRooms.count) rooms") - let diff = sdkRooms.map { $0.id() }.difference(from: currentRooms.map(\.id)) - - for change in diff { - switch change { - case .insert(_, let id, _): - guard let sdkRoom = sdkRooms.first(where: { $0.id() == id }) else { - MXLog.error("Failed retrieving sdk room with id: \(id)") - break - } - currentRooms.append(RoomProxy(room: sdkRoom, - roomMessageFactory: RoomMessageFactory(), - backgroundTaskService: backgroundTaskService)) - case .remove(_, let id, _): - currentRooms.removeAll { $0.id == id } - } - } - - Benchmark.endTrackingForIdentifier("ProcessingRooms", message: "Finished processing \(sdkRooms.count) rooms") - - rooms = currentRooms +// callbacks.send(.receivedSyncUpdate) } } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index cd94c69c2..12c55b867 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -19,7 +19,6 @@ import Foundation import MatrixRustSDK enum ClientProxyCallback { - case updatedRoomsList case receivedSyncUpdate case receivedAuthError(isSoftLogout: Bool) case updatedRestoreToken @@ -34,6 +33,7 @@ enum ClientProxyError: Error { case failedLoadingMedia } +@MainActor protocol ClientProxyProtocol { var callbacks: PassthroughSubject { get } @@ -47,7 +47,13 @@ protocol ClientProxyProtocol { var restoreToken: String? { get } - var rooms: [RoomProxy] { get } + var roomSummaryProvider: RoomSummaryProviderProtocol { get } + + func startSync() + + func stopSync() + + func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? func loadUserDisplayName() async -> Result @@ -59,7 +65,9 @@ protocol ClientProxyProtocol { func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource - func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data + func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data + + func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data func sessionVerificationControllerProxy() async -> Result diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 1a7f1b060..be7b2879b 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -26,10 +26,18 @@ struct MockClientProxy: ClientProxyProtocol { let homeserver = "" let restoreToken: String? = nil - let rooms = [RoomProxy]() + let roomSummaryProvider: RoomSummaryProviderProtocol = MockRoomSummaryProvider() + + func startSync() { } + + func stopSync() { } + + func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? { + nil + } func loadUserDisplayName() async -> Result { - .failure(.failedRetrievingDisplayName) + .success("User display name") } func loadUserAvatarURLString() async -> Result { @@ -48,7 +56,11 @@ struct MockClientProxy: ClientProxyProtocol { MatrixRustSDK.mediaSourceFromUrl(url: urlString) } - func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data { + func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data { + throw ClientProxyError.failedLoadingMedia + } + + func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data { throw ClientProxyError.failedLoadingMedia } diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift index 061bc87f1..780357ee2 100644 --- a/ElementX/Sources/Services/Media/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -21,7 +21,6 @@ struct MediaProvider: MediaProviderProtocol { private let clientProxy: ClientProxyProtocol private let imageCache: Kingfisher.ImageCache private let backgroundTaskService: BackgroundTaskServiceProtocol - private let processingQueue: DispatchQueue init(clientProxy: ClientProxyProtocol, imageCache: Kingfisher.ImageCache, @@ -29,31 +28,30 @@ struct MediaProvider: MediaProviderProtocol { self.clientProxy = clientProxy self.imageCache = imageCache self.backgroundTaskService = backgroundTaskService - processingQueue = DispatchQueue(label: "MediaProviderProcessingQueue", attributes: .concurrent) } - func imageFromSource(_ source: MediaSource?) -> UIImage? { + func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? { guard let source = source else { return nil } - - return imageCache.retrieveImageInMemoryCache(forKey: source.underlyingSource.url(), options: nil) + let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), size: size) + return imageCache.retrieveImageInMemoryCache(forKey: cacheKey, options: nil) } - func imageFromURLString(_ urlString: String?) -> UIImage? { + func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? { guard let urlString = urlString else { return nil } - return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString))) + return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size) } - func loadImageFromURLString(_ urlString: String) async -> Result { - await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString))) + func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result { + await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size) } - func loadImageFromSource(_ source: MediaSource) async -> Result { - if let image = imageFromSource(source) { + func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result { + if let image = imageFromSource(source, size: size) { return .success(image) } @@ -62,21 +60,30 @@ struct MediaProvider: MediaProviderProtocol { loadImageBgTask?.stop() } + let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), size: size) + return await Task.detached { () -> Result in - if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: source.underlyingSource.url()), + if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: cacheKey), let image = cacheResult.image { return .success(image) } do { - let imageData = try clientProxy.loadMediaContentForSource(source.underlyingSource) + let imageData = try await Task.detached { () -> Data in + if let size = size { + return try await clientProxy.loadMediaThumbnailForSource(source.underlyingSource, width: UInt(size.width), height: UInt(size.height)) + } else { + return try await clientProxy.loadMediaContentForSource(source.underlyingSource) + } + + }.value guard let image = UIImage(data: imageData) else { MXLog.error("Invalid image data") return .failure(.invalidImageData) } - imageCache.store(image, forKey: source.underlyingSource.url()) + imageCache.store(image, forKey: cacheKey) return .success(image) } catch { @@ -86,6 +93,16 @@ struct MediaProvider: MediaProviderProtocol { } .value } + + // MARK: - Private + + private func cacheKeyForURLString(_ urlString: String, size: CGSize?) -> String { + if let size = size { + return "\(urlString){\(size.width),\(size.height)}" + } else { + return urlString + } + } } private extension ImageCache { diff --git a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift index 7bc7fb445..512aae5e3 100644 --- a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift +++ b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift @@ -22,13 +22,33 @@ enum MediaProviderError: Error { case invalidImageData } +let MediaProviderDefaultAvatarSize = CGSize(width: 44.0, height: 44.0) + @MainActor protocol MediaProviderProtocol { - func imageFromSource(_ source: MediaSource?) -> UIImage? + func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? - func loadImageFromSource(_ source: MediaSource) async -> Result + @discardableResult func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result - func imageFromURLString(_ urlString: String?) -> UIImage? + func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? - func loadImageFromURLString(_ urlString: String) async -> Result + @discardableResult func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result +} + +extension MediaProviderProtocol { + func imageFromSource(_ source: MediaSource?) -> UIImage? { + imageFromSource(source, size: nil) + } + + @discardableResult func loadImageFromSource(_ source: MediaSource) async -> Result { + await loadImageFromSource(source, size: nil) + } + + func imageFromURLString(_ urlString: String?) -> UIImage? { + imageFromURLString(urlString, size: nil) + } + + @discardableResult func loadImageFromURLString(_ urlString: String) async -> Result { + await loadImageFromURLString(urlString, size: nil) + } } diff --git a/ElementX/Sources/Services/Media/MockMediaProvider.swift b/ElementX/Sources/Services/Media/MockMediaProvider.swift index 9e66aceff..8bd68b887 100644 --- a/ElementX/Sources/Services/Media/MockMediaProvider.swift +++ b/ElementX/Sources/Services/Media/MockMediaProvider.swift @@ -18,19 +18,23 @@ import Foundation import UIKit struct MockMediaProvider: MediaProviderProtocol { - func imageFromSource(_ source: MediaSource?) -> UIImage? { + func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? { nil } - func loadImageFromSource(_ source: MediaSource) async -> Result { + func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result { .failure(.failedRetrievingImage) } - func imageFromURLString(_ urlString: String?) -> UIImage? { - nil + func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? { + if urlString != nil { + return UIImage(systemName: "photo") + } + + return nil } - func loadImageFromURLString(_ urlString: String) async -> Result { + func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result { .failure(.failedRetrievingImage) } } diff --git a/ElementX/Sources/Services/Room/Members/MemberDetailProviderManager.swift b/ElementX/Sources/Services/Room/Members/MemberDetailProviderManager.swift deleted file mode 100644 index a6bd26861..000000000 --- a/ElementX/Sources/Services/Room/Members/MemberDetailProviderManager.swift +++ /dev/null @@ -1,32 +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 Foundation - -@MainActor -class MemberDetailProviderManager { - private var memberDetailProviders: [String: MemberDetailProviderProtocol] = [:] - - func memberDetailProviderForRoomProxy(_ roomProxy: RoomProxyProtocol) -> MemberDetailProviderProtocol { - if let memberDetailProvider = memberDetailProviders[roomProxy.id] { - return memberDetailProvider - } - - let memberDetailProvider = MemberDetailProvider(roomProxy: roomProxy) - memberDetailProviders[roomProxy.id] = memberDetailProvider - return memberDetailProvider - } -} diff --git a/ElementX/Sources/Services/Room/Members/MemberDetailsProvider.swift b/ElementX/Sources/Services/Room/Members/MemberDetailsProvider.swift deleted file mode 100644 index 34859aa22..000000000 --- a/ElementX/Sources/Services/Room/Members/MemberDetailsProvider.swift +++ /dev/null @@ -1,63 +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 Foundation - -class MemberDetailProvider: MemberDetailProviderProtocol { - private let roomProxy: RoomProxyProtocol - private var memberAvatars = [String: String]() - private var memberDisplayNames = [String: String]() - - init(roomProxy: RoomProxyProtocol) { - self.roomProxy = roomProxy - } - - func avatarURLStringForUserId(_ userId: String) -> String? { - memberAvatars[userId] - } - - func loadAvatarURLStringForUserId(_ userId: String) async -> Result { - if let avatarURL = avatarURLStringForUserId(userId) { - return .success(avatarURL) - } - - switch await roomProxy.loadAvatarURLForUserId(userId) { - case .success(let avatarURL): - memberAvatars[userId] = avatarURL - return .success(avatarURL) - case .failure: - return .failure(.failedRetrievingUserAvatarURL) - } - } - - func displayNameForUserId(_ userId: String) -> String? { - memberDisplayNames[userId] - } - - func loadDisplayNameForUserId(_ userId: String) async -> Result { - if let displayName = displayNameForUserId(userId) { - return .success(displayName) - } - - switch await roomProxy.loadDisplayNameForUserId(userId) { - case .success(let displayName): - memberDisplayNames[userId] = displayName - return .success(displayName) - case .failure: - return .failure(.failedRetrievingUserDisplayName) - } - } -} diff --git a/ElementX/Sources/Services/Room/Members/MemberDetailsProviderProtocol.swift b/ElementX/Sources/Services/Room/Members/MemberDetailsProviderProtocol.swift deleted file mode 100644 index bd614d0a2..000000000 --- a/ElementX/Sources/Services/Room/Members/MemberDetailsProviderProtocol.swift +++ /dev/null @@ -1,32 +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 Foundation - -enum MemberDetailProviderError: Error { - case invalidRoomProxy - case failedRetrievingUserAvatarURL - case failedRetrievingUserDisplayName -} - -@MainActor -protocol MemberDetailProviderProtocol { - func avatarURLStringForUserId(_ userId: String) -> String? - func loadAvatarURLStringForUserId(_ userId: String) async -> Result - - func displayNameForUserId(_ userId: String) -> String? - func loadDisplayNameForUserId(_ userId: String) async -> Result -} diff --git a/ElementX/Sources/Services/Room/Messages/EmoteRoomMessage.swift b/ElementX/Sources/Services/Room/Messages/EmoteRoomMessage.swift deleted file mode 100644 index 545d80afd..000000000 --- a/ElementX/Sources/Services/Room/Messages/EmoteRoomMessage.swift +++ /dev/null @@ -1,47 +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 Foundation -import MatrixRustSDK -import UIKit - -struct EmoteRoomMessage: RoomMessageProtocol { - private let message: MatrixRustSDK.EmoteMessage - - init(message: MatrixRustSDK.EmoteMessage) { - self.message = message - } - - var id: String { - message.baseMessage().id() - } - - var body: String { - message.baseMessage().body() - } - - var htmlBody: String? { - message.htmlBody() - } - - var sender: String { - message.baseMessage().sender() - } - - var originServerTs: Date { - Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs())) - } -} diff --git a/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift b/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift deleted file mode 100644 index cca14d9a8..000000000 --- a/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift +++ /dev/null @@ -1,66 +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 MatrixRustSDK -import UIKit - -struct ImageRoomMessage: RoomMessageProtocol { - private let message: MatrixRustSDK.ImageMessage - - init(message: MatrixRustSDK.ImageMessage) { - self.message = message - } - - var id: String { - message.baseMessage().id() - } - - var body: String { - message.baseMessage().body() - } - - var sender: String { - message.baseMessage().sender() - } - - var originServerTs: Date { - Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs())) - } - - var source: MediaSource? { - MediaSource(source: message.source()) - } - - var width: CGFloat? { - guard let width = message.width() else { - return nil - } - - return CGFloat(width) - } - - var height: CGFloat? { - guard let height = message.height() else { - return nil - } - - return CGFloat(height) - } - - var blurhash: String? { - message.blurhash() - } -} diff --git a/ElementX/Sources/Services/Room/Messages/TextRoomMessage.swift b/ElementX/Sources/Services/Room/Messages/TextRoomMessage.swift deleted file mode 100644 index 050d3b9b2..000000000 --- a/ElementX/Sources/Services/Room/Messages/TextRoomMessage.swift +++ /dev/null @@ -1,47 +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 Foundation -import MatrixRustSDK -import UIKit - -struct TextRoomMessage: RoomMessageProtocol { - private let message: MatrixRustSDK.TextMessage - - init(message: MatrixRustSDK.TextMessage) { - self.message = message - } - - var id: String { - message.baseMessage().id() - } - - var body: String { - message.baseMessage().body() - } - - var htmlBody: String? { - message.htmlBody() - } - - var sender: String { - message.baseMessage().sender() - } - - var originServerTs: Date { - Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs())) - } -} diff --git a/ElementX/Sources/Services/Room/MockRoomProxy.swift b/ElementX/Sources/Services/Room/MockRoomProxy.swift index 0cba93153..44bb3daf7 100644 --- a/ElementX/Sources/Services/Room/MockRoomProxy.swift +++ b/ElementX/Sources/Services/Room/MockRoomProxy.swift @@ -15,8 +15,7 @@ // import Combine -import Foundation -import UIKit +import MatrixRustSDK struct MockRoomProxy: RoomProxyProtocol { let id = UUID().uuidString @@ -24,7 +23,6 @@ struct MockRoomProxy: RoomProxyProtocol { let displayName: String? let topic: String? = nil - let messages: [RoomMessageProtocol] = [] let avatarURL: String? = nil @@ -34,24 +32,34 @@ struct MockRoomProxy: RoomProxyProtocol { let isEncrypted = Bool.random() let isTombstoned = Bool.random() - var callbacks = PassthroughSubject() + let timelineProvider: RoomTimelineProviderProtocol = MockRoomTimelineProvider() func loadDisplayNameForUserId(_ userId: String) async -> Result { .failure(.failedRetrievingMemberDisplayName) } + func avatarURLStringForUserId(_ userId: String) -> String? { + nil + } + func loadAvatarURLForUserId(_ userId: String) async -> Result { .failure(.failedRetrievingMemberAvatarURL) } + func displayNameForUserId(_ userId: String) -> String? { + nil + } + func loadDisplayName() async -> Result { .failure(.failedRetrievingDisplayName) } func startLiveEventListener() { } + func addTimelineListener(listener: TimelineListener) { } + func paginateBackwards(count: UInt) async -> Result { - .failure(.backwardStreamNotAvailable) + .failure(.failedPaginatingBackwards) } func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result { diff --git a/ElementX/Sources/Services/Room/RoomMessageFactory.swift b/ElementX/Sources/Services/Room/RoomMessageFactory.swift index 660c71d57..5ce7467c6 100644 --- a/ElementX/Sources/Services/Room/RoomMessageFactory.swift +++ b/ElementX/Sources/Services/Room/RoomMessageFactory.swift @@ -17,18 +17,15 @@ import Foundation import MatrixRustSDK +struct SomeRoomMessage: RoomMessageProtocol { + let id: String + let body: String + let sender: String + let originServerTs: Date +} + struct RoomMessageFactory: RoomMessageFactoryProtocol { - func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol { - if let textMessage = message.textMessage() { - return TextRoomMessage(message: textMessage) - } else if let imageMessage = message.imageMessage() { - return ImageRoomMessage(message: imageMessage) - } else if let noticeMessage = message.noticeMessage() { - return NoticeRoomMessage(message: noticeMessage) - } else if let emoteMessage = message.emoteMessage() { - return EmoteRoomMessage(message: emoteMessage) - } else { - fatalError("One of these must exist") - } + func buildRoomMessageFrom(_ eventItem: EventTimelineItem) -> RoomMessageProtocol { + SomeRoomMessage(id: eventItem.id, body: eventItem.body ?? "", sender: eventItem.sender, originServerTs: eventItem.originServerTs) } } diff --git a/ElementX/Sources/Services/Room/RoomMessageFactoryProtocol.swift b/ElementX/Sources/Services/Room/RoomMessageFactoryProtocol.swift index 9dea18ea8..07003d714 100644 --- a/ElementX/Sources/Services/Room/RoomMessageFactoryProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMessageFactoryProtocol.swift @@ -18,5 +18,5 @@ import Foundation import MatrixRustSDK protocol RoomMessageFactoryProtocol { - func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol + func buildRoomMessageFrom(_ message: EventTimelineItem) -> RoomMessageProtocol } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 8fc883909..42890e4ca 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -20,55 +20,43 @@ import UIKit import MatrixRustSDK -private class WeakRoomProxyWrapper: RoomDelegate { - private weak var roomProxy: RoomProxy? - - init(roomProxy: RoomProxy) { - self.roomProxy = roomProxy - } - - // MARK: - RoomDelegate - - func didReceiveMessage(message: AnyMessage) { - roomProxy?.appendMessage(message) - } -} - class RoomProxy: RoomProxyProtocol { - private let room: Room - private let roomMessageFactory: RoomMessageFactoryProtocol + private let slidingSyncRoom: SlidingSyncRoomProtocol + private let room: RoomProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol - private var backwardStream: BackwardsStreamProtocol? + private let concurrentDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy", attributes: .concurrent) + private var sendMessageBgTask: BackgroundTaskProtocol? + private var memberAvatars = [String: String]() + private var memberDisplayNames = [String: String]() + private(set) var displayName: String? - let callbacks = PassthroughSubject() - - private(set) var messages: [RoomMessageProtocol] - - init(room: Room, - roomMessageFactory: RoomMessageFactoryProtocol, - backgroundTaskService: BackgroundTaskServiceProtocol) { - self.room = room - self.roomMessageFactory = roomMessageFactory - self.backgroundTaskService = backgroundTaskService - messages = [] - - room.setDelegate(delegate: WeakRoomProxyWrapper(roomProxy: self)) - - backwardStream = room.startLiveEventListener() - } + private var backPaginationOutcome: PaginationOutcome? + private(set) lazy var timelineProvider: RoomTimelineProviderProtocol = { + let provider = RoomTimelineProvider(roomProxy: self) + addTimelineListener(listener: WeakRoomTimelineProviderWrapper(timelineProvider: provider)) + return provider + }() deinit { - room.setDelegate(delegate: nil) + #warning("Should any timeline listeners be removed??") } + init(slidingSyncRoom: SlidingSyncRoomProtocol, + room: RoomProtocol, + backgroundTaskService: BackgroundTaskServiceProtocol) { + self.slidingSyncRoom = slidingSyncRoom + self.room = room + self.backgroundTaskService = backgroundTaskService + } + lazy var id: String = room.id() var name: String? { - room.name() + slidingSyncRoom.name() } var topic: String? { @@ -112,10 +100,15 @@ class RoomProxy: RoomProxyProtocol { return Asset.Images.encryptionTrusted.image } + func avatarURLStringForUserId(_ userId: String) -> String? { + memberAvatars[userId] + } + func loadAvatarURLForUserId(_ userId: String) async -> Result { await Task.detached { () -> Result in do { let avatarURL = try self.room.memberAvatarUrl(userId: userId) + await self.update(avatarURL: avatarURL, forUserId: userId) return .success(avatarURL) } catch { return .failure(.failedRetrievingMemberAvatarURL) @@ -124,10 +117,15 @@ class RoomProxy: RoomProxyProtocol { .value } + func displayNameForUserId(_ userId: String) -> String? { + memberDisplayNames[userId] + } + func loadDisplayNameForUserId(_ userId: String) async -> Result { await Task.detached { () -> Result in do { let displayName = try self.room.memberDisplayName(userId: userId) + await self.update(displayName: displayName, forUserId: userId) return .success(displayName) } catch { return .failure(.failedRetrievingMemberDisplayName) @@ -138,14 +136,13 @@ class RoomProxy: RoomProxyProtocol { func loadDisplayName() async -> Result { await Task.detached { () -> Result in - if let displayName = self.displayName { + if let displayName = await self.displayName { return .success(displayName) } do { let displayName = try self.room.displayName() - self.displayName = displayName - + await self.update(displayName: displayName) return .success(displayName) } catch { return .failure(.failedRetrievingDisplayName) @@ -154,23 +151,27 @@ class RoomProxy: RoomProxyProtocol { .value } + private func addTimelineListener(listener: TimelineListener) { + room.addTimelineListener(listener: listener) + } + func paginateBackwards(count: UInt) async -> Result { - await Task.detached { () -> Result in - guard let backwardStream = self.backwardStream else { - return .failure(RoomProxyError.backwardStreamNotAvailable) + guard backPaginationOutcome?.moreMessages != false else { + return .failure(.noMoreMessagesToBackPaginate) + } + + MXLog.debug("BackPagination") + return await Task.detached { + do { + let id = await self.id + + Benchmark.startTrackingForIdentifier("BackPagination \(id)", message: "Backpaginating \(count) message(s) in room \(id)") + await self.update(backPaginationOutcome: try self.room.paginateBackwards(limit: UInt16(count))) + Benchmark.endTrackingForIdentifier("BackPagination \(id)", message: "Finished backpaginating \(count) message(s) in room \(id)") + return .success(()) + } catch { + return .failure(.failedPaginatingBackwards) } - - Benchmark.startTrackingForIdentifier("BackPagination \(self.id)", message: "Backpaginating \(count) message(s) in room \(self.id)") - let sdkMessages = backwardStream.paginateBackwards(count: UInt64(count)) - Benchmark.endTrackingForIdentifier("BackPagination \(self.id)", message: "Finished backpaginating \(count) message(s) in room \(self.id)") - - let messages = sdkMessages.map { message in - self.roomMessageFactory.buildRoomMessageFrom(message) - }.reversed() - - self.messages.insert(contentsOf: messages, at: 0) - - return .success(()) } .value } @@ -180,48 +181,52 @@ class RoomProxy: RoomProxyProtocol { defer { sendMessageBgTask?.stop() } - + let transactionId = genTransactionId() - return await Task(priority: .high) { () -> Result in + return await Task.dispatched(on: concurrentDispatchQueue, operation: { do { - // Disabled until available in Rust - // if let inReplyToEventId = inReplyToEventId { - // #warning("Markdown support when available in Ruma") - // try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId) - // } else { - let messageContent = messageEventContentFromMarkdown(md: message) - try self.room.send(msg: messageContent, txnId: transactionId) - // } + if let inReplyToEventId = inReplyToEventId { + try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId) + } else { + let messageContent = messageEventContentFromMarkdown(md: message) + try self.room.send(msg: messageContent, txnId: transactionId) + } return .success(()) } catch { return .failure(.failedSendingMessage) } + }) + .value + } + + func redact(_ eventID: String) async -> Result { + let transactionID = genTransactionId() + + return await Task { + do { + try room.redact(eventId: eventID, reason: nil, txnId: transactionID) + return .success(()) + } catch { + return .failure(.failedRedactingEvent) + } } .value } - func redact(_ eventID: String) async -> Result { - #warning("Redactions to be enabled on next SDK release.") - return .failure(.failedRedactingEvent) -// let transactionID = genTransactionId() -// -// return await Task { -// do { -// try room.redact(eventId: eventID, reason: nil, txnId: transactionID) -// return .success(()) -// } catch { -// return .failure(.failedRedactingEvent) -// } -// } -// .value + func update(avatarURL: String?, forUserId userId: String) { + memberAvatars[userId] = avatarURL } - // MARK: - Private + func update(displayName: String?, forUserId userId: String) { + memberDisplayNames[userId] = displayName + } - fileprivate func appendMessage(_ message: AnyMessage) { - let message = roomMessageFactory.buildRoomMessageFrom(message) - messages.append(message) - callbacks.send(.updatedMessages) + func update(displayName: String) { + self.displayName = displayName + } + + func update(backPaginationOutcome: PaginationOutcome) { + self.backPaginationOutcome = backPaginationOutcome } } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 28bf207e5..a37708998 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -15,22 +15,20 @@ // import Combine -import UIKit +import MatrixRustSDK enum RoomProxyError: Error { case failedRetrievingDisplayName case failedRetrievingAvatar - case backwardStreamNotAvailable + case noMoreMessagesToBackPaginate + case failedPaginatingBackwards case failedRetrievingMemberAvatarURL case failedRetrievingMemberDisplayName case failedSendingMessage case failedRedactingEvent } -enum RoomProxyCallback { - case updatedMessages -} - +@MainActor protocol RoomProxyProtocol { var id: String { get } var isDirect: Bool { get } @@ -43,12 +41,17 @@ protocol RoomProxyProtocol { var displayName: String? { get } var topic: String? { get } - var messages: [RoomMessageProtocol] { get } var avatarURL: String? { get } + var timelineProvider: RoomTimelineProviderProtocol { get } + + func avatarURLStringForUserId(_ userId: String) -> String? + func loadAvatarURLForUserId(_ userId: String) async -> Result + func displayNameForUserId(_ userId: String) -> String? + func loadDisplayNameForUserId(_ userId: String) async -> Result func loadDisplayName() async -> Result @@ -58,8 +61,6 @@ protocol RoomProxyProtocol { func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result func redact(_ eventID: String) async -> Result - - var callbacks: PassthroughSubject { get } } extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/Room/RoomSummary/EventBrief.swift b/ElementX/Sources/Services/Room/RoomSummary/EventBrief.swift deleted file mode 100644 index 2f4e87571..000000000 --- a/ElementX/Sources/Services/Room/RoomSummary/EventBrief.swift +++ /dev/null @@ -1,26 +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 Foundation - -struct EventBrief { - let eventId: String - let senderId: String - let senderDisplayName: String? - let body: String - let htmlBody: String? - let date: Date -} diff --git a/ElementX/Sources/Services/Room/RoomSummary/EventBriefFactory.swift b/ElementX/Sources/Services/Room/RoomSummary/EventBriefFactory.swift deleted file mode 100644 index 99cbfac64..000000000 --- a/ElementX/Sources/Services/Room/RoomSummary/EventBriefFactory.swift +++ /dev/null @@ -1,67 +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 Foundation - -struct EventBriefFactory: EventBriefFactoryProtocol { - private let memberDetailProvider: MemberDetailProviderProtocol - - init(memberDetailProvider: MemberDetailProviderProtocol) { - self.memberDetailProvider = memberDetailProvider - } - - func buildEventBriefFor(message: RoomMessageProtocol?) async -> EventBrief? { - guard let message = message else { - return nil - } - - switch message { - case is ImageRoomMessage: - return nil - case let message as TextRoomMessage: - return await buildEventBrief(message: message, htmlBody: message.htmlBody) - case let message as NoticeRoomMessage: - return await buildEventBrief(message: message, htmlBody: message.htmlBody) - case let message as EmoteRoomMessage: - return await buildEventBrief(message: message, htmlBody: message.htmlBody) - default: - fatalError("Unknown room message.") - } - } - - // MARK: - Private - - private func buildEventBrief(message: RoomMessageProtocol, htmlBody: String?) async -> EventBrief? { - switch await memberDetailProvider.loadDisplayNameForUserId(message.sender) { - case .success(let displayName): - return EventBrief(eventId: message.id, - senderId: message.sender, - senderDisplayName: displayName, - body: message.body, - htmlBody: htmlBody, - date: message.originServerTs) - case .failure(let error): - MXLog.error("Failed fetching sender display name with error: \(error)") - - return EventBrief(eventId: message.id, - senderId: message.sender, - senderDisplayName: nil, - body: message.body, - htmlBody: htmlBody, - date: message.originServerTs) - } - } -} diff --git a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummary.swift b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummary.swift deleted file mode 100644 index be61ef91e..000000000 --- a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummary.swift +++ /dev/null @@ -1,44 +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 UIKit - -struct MockRoomSummary: RoomSummaryProtocol { - var id: String = UUID().uuidString - - var name: String? - - var displayName: String? - - var topic: String? - - var isDirect = false - - var isEncrypted = false - - var isSpace = false - - var isTombstoned = false - - var lastMessage: EventBrief? - - var avatar: UIImage? - - func loadDetails() async { } - - var callbacks = PassthroughSubject() -} diff --git a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift new file mode 100644 index 000000000..1ede403c9 --- /dev/null +++ b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift @@ -0,0 +1,49 @@ +// +// 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 + +class MockRoomSummaryProvider: RoomSummaryProviderProtocol { + var callbacks = PassthroughSubject() + var stateUpdatePublisher = CurrentValueSubject(.cold) + var countUpdatePublisher = CurrentValueSubject(0) + + var roomSummaries: [RoomSummary] = [ + RoomSummary(id: "1", name: "First room", + isDirect: true, + avatarURLString: nil, + lastMessage: AttributedString("Prosciutto beef ribs pancetta filet mignon kevin hamburger, chuck ham venison picanha. Beef ribs chislic turkey biltong tenderloin."), + lastMessageTimestamp: .now, + unreadNotificationCount: 4), + RoomSummary(id: "2", + name: "Second room", + isDirect: true, + avatarURLString: "mockImageURLString", + lastMessage: nil, + lastMessageTimestamp: nil, + unreadNotificationCount: 1), + RoomSummary(id: "3", + name: "Third room", + isDirect: true, + avatarURLString: nil, + lastMessage: try? AttributedString(markdown: "**@mock:client.com**: T-bone beef ribs bacon"), + lastMessageTimestamp: .now, + unreadNotificationCount: 0) + ] + + func updateRoomsWithIdentifiers(_ identifiers: [String]) { } +} diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift index f016f8ce2..e7a8411ec 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift @@ -14,147 +14,14 @@ // limitations under the License. // -import Combine import Foundation -import UIKit -class RoomSummary: RoomSummaryProtocol { - private let roomProxy: RoomProxyProtocol - private let mediaProvider: MediaProviderProtocol - private let eventBriefFactory: EventBriefFactoryProtocol - - private var hasLoadedData = false - private var roomUpdateListeners = Set() - - var id: String { - roomProxy.id - } - - var name: String? { - roomProxy.name - } - - var topic: String? { - roomProxy.topic - } - - var isDirect: Bool { - roomProxy.isDirect - } - - var isEncrypted: Bool { - roomProxy.isEncrypted - } - - var isSpace: Bool { - roomProxy.isSpace - } - - var isTombstoned: Bool { - roomProxy.isTombstoned - } - - private(set) var avatar: UIImage? { - didSet { - callbacks.send(.updatedAvatar) - } - } - - private(set) var displayName: String? { - didSet { - callbacks.send(.updatedDisplayName) - } - } - - private(set) var lastMessage: EventBrief? { - didSet { - callbacks.send(.updatedLastMessage) - } - } - - let callbacks = PassthroughSubject() - - init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol, eventBriefFactory: EventBriefFactoryProtocol) { - self.roomProxy = roomProxy - self.mediaProvider = mediaProvider - self.eventBriefFactory = eventBriefFactory - - Task { - lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last) - } - - roomProxy.callbacks - .receive(on: DispatchQueue.main) - .sink { [weak self] callback in - guard let self = self else { - return - } - - switch callback { - case .updatedMessages: - Task { - self.lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last) - } - } - } - .store(in: &roomUpdateListeners) - } - - func loadDetails() async { - if hasLoadedData { - return - } - - await withTaskGroup(of: Void.self) { group in - group.addTask { - await self.loadDisplayName() - } - group.addTask { - await self.loadAvatar() - } - group.addTask { - await self.loadLastMessage() - } - } - - hasLoadedData = true - } - - // MARK: - Private - - private func loadDisplayName() async { - switch await roomProxy.loadDisplayName() { - case .success(let displayName): - self.displayName = displayName - case .failure(let error): - MXLog.error("Failed fetching room display name with error: \(error)") - } - } - - private func loadAvatar() async { - guard let avatarURLString = roomProxy.avatarURL else { - return - } - - switch await mediaProvider.loadImageFromURLString(avatarURLString) { - case .success(let avatar): - self.avatar = avatar - case .failure(let error): - MXLog.error("Failed fetching room avatar with error: \(error)") - } - } - - private func loadLastMessage() async { - guard roomProxy.messages.last == nil else { - return - } - - // Pre-fill the room with some messages and use the last message in the response. - switch await roomProxy.paginateBackwards(count: UInt(ClientProxy.syncLimit)) { - case .success: - lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last) - case .failure(let error): - MXLog.error("Failed back paginating with error: \(error)") - } - } +struct RoomSummary { + let id: String + let name: String + let isDirect: Bool + let avatarURLString: String? + let lastMessage: AttributedString? + let lastMessageTimestamp: Date? + let unreadNotificationCount: UInt } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProtocol.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProtocol.swift deleted file mode 100644 index ba103465a..000000000 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProtocol.swift +++ /dev/null @@ -1,43 +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 UIKit - -enum RoomSummaryCallback { - case updatedAvatar - case updatedDisplayName - case updatedLastMessage -} - -@MainActor -protocol RoomSummaryProtocol { - var id: String { get } - var name: String? { get } - var topic: String? { get } - var isDirect: Bool { get } - var isEncrypted: Bool { get } - var isSpace: Bool { get } - var isTombstoned: Bool { get } - - var displayName: String? { get } - var lastMessage: EventBrief? { get } - var avatar: UIImage? { get } - - var callbacks: PassthroughSubject { get } - - func loadDetails() async -} diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift new file mode 100644 index 000000000..ac8b4fbb0 --- /dev/null +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift @@ -0,0 +1,264 @@ +// +// 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 + +private class WeakRoomSummaryProviderWrapper: SlidingSyncViewRoomListObserver, SlidingSyncViewStateObserver, SlidingSyncViewRoomsCountObserver { + private weak var roomSummaryProvider: RoomSummaryProvider? + + /// Publishes room list diffs as they come in through sliding sync + let roomListDiffPublisher = PassthroughSubject() + + /// Publishes the current state of sliding sync, such as whether its catching up or live. + let stateUpdatePublisher = CurrentValueSubject(.cold) + + /// Publishes the number of available rooms + let countUpdatePublisher = CurrentValueSubject(0) + + init(roomSummaryProvider: RoomSummaryProvider) { + self.roomSummaryProvider = roomSummaryProvider + } + + // MARK: - SlidingSyncViewRoomListObserver + + func didReceiveUpdate(diff: SlidingSyncViewRoomsListDiff) { + roomListDiffPublisher.send(diff) + } + + // MARK: - SlidingSyncViewStateObserver + + func didReceiveUpdate(newState: SlidingSyncState) { + stateUpdatePublisher.send(newState) + } + + // MARK: - SlidingSyncViewRoomsCountObserver + + func didReceiveUpdate(count: UInt32) { + countUpdatePublisher.send(UInt(count)) + } +} + +class RoomSummaryProvider: RoomSummaryProviderProtocol { + private let slidingSyncController: SlidingSyncProtocol + private let slidingSyncView: SlidingSyncViewProtocol + private let roomMessageFactory: RoomMessageFactoryProtocol + + private var listUpdateObserverToken: StoppableSpawn? + private var stateUpdateObserverToken: StoppableSpawn? + + private var cancellables = Set() + + let callbacks = PassthroughSubject() + let stateUpdatePublisher = CurrentValueSubject(.cold) + let countUpdatePublisher = CurrentValueSubject(0) + + private(set) var roomSummaries: [RoomSummary] = [] { + didSet { + callbacks.send(.updatedRoomSummaries) + } + } + + deinit { + listUpdateObserverToken?.cancel() + stateUpdateObserverToken?.cancel() + } + + init(slidingSyncController: SlidingSyncProtocol, slidingSyncView: SlidingSyncViewProtocol, roomMessageFactory: RoomMessageFactoryProtocol) { + self.slidingSyncView = slidingSyncView + self.slidingSyncController = slidingSyncController + self.roomMessageFactory = roomMessageFactory + + let weakProvider = WeakRoomSummaryProviderWrapper(roomSummaryProvider: self) + + weakProvider.stateUpdatePublisher + .map(RoomSummaryProviderState.init) + .subscribe(stateUpdatePublisher) + .store(in: &cancellables) + + weakProvider.countUpdatePublisher + .subscribe(countUpdatePublisher) + .store(in: &cancellables) + + weakProvider.roomListDiffPublisher + .collect(.byTime(DispatchQueue.global(qos: .background), 0.5)) + .sink { self.updateRoomsWithDiffs($0) } + .store(in: &cancellables) + + listUpdateObserverToken = slidingSyncView.observeRoomList(observer: weakProvider) + stateUpdateObserverToken = slidingSyncView.observeState(observer: weakProvider) + } + + func updateRoomsWithIdentifiers(_ identifiers: [String]) { + Task.detached { + guard self.stateUpdatePublisher.value == .live else { + return + } + + var changes = [CollectionDifference.Change]() + for identifier in identifiers { + guard let oldSummary = self.roomSummaries.first(where: { $0.id == identifier }), + let index = self.roomSummaries.firstIndex(where: { $0.id == identifier }) else { + continue + } + + let newSummary = self.buildRoomSummaryForIdentifier(identifier) + + changes.append(.remove(offset: index, element: oldSummary, associatedWith: nil)) + changes.append(.insert(offset: index, element: newSummary, associatedWith: nil)) + } + + guard let diff = CollectionDifference(changes) else { + MXLog.error("Failed creating diff from changes: \(changes)") + return + } + + guard let newSummaries = self.roomSummaries.applying(diff) else { + MXLog.error("Failed applying diff: \(diff)") + return + } + + self.roomSummaries = newSummaries + } + } + + // MARK: - Private + + fileprivate func updateRoomsWithDiffs(_ diffs: [SlidingSyncViewRoomsListDiff]) { + roomSummaries = diffs + .compactMap(buildDiff) + .reduce(roomSummaries) { $0.applying($1) ?? $0 } + } + + private func buildEmptyRoomSummary(forIdentifier identifier: String = UUID().uuidString) -> RoomSummary { + RoomSummary(id: identifier, + name: "", + isDirect: false, + avatarURLString: nil, + lastMessage: nil, + lastMessageTimestamp: nil, + unreadNotificationCount: 0) + } + + private func buildRoomSummaryForIdentifier(_ identifier: String) -> RoomSummary { + guard let room = try? slidingSyncController.getRoom(roomId: identifier) else { + MXLog.error("Failed finding room with id: \(identifier)") + return buildEmptyRoomSummary(forIdentifier: identifier) + } + + var attributedLastMessage: AttributedString? + var lastMessageTimestamp: Date? + if let latestRoomMessage = room.latestRoomMessage() { + let lastMessage = roomMessageFactory.buildRoomMessageFrom(EventTimelineItem(item: latestRoomMessage)) + if let lastMessageSender = try? AttributedString(markdown: "**\(lastMessage.sender)**") { + // Don't include the message body in the markdown otherwise it makes tappable links. + attributedLastMessage = lastMessageSender + ": " + AttributedString(lastMessage.body) + } + lastMessageTimestamp = lastMessage.originServerTs + } + + return RoomSummary(id: room.roomId(), + name: room.name() ?? room.roomId(), + isDirect: room.isDm() ?? false, + avatarURLString: room.fullRoom()?.avatarUrl(), + lastMessage: attributedLastMessage, + lastMessageTimestamp: lastMessageTimestamp, + unreadNotificationCount: UInt(room.unreadNotifications().notificationCount())) + } + + private func buildSummaryForRoomListEntry(_ entry: RoomListEntry) -> RoomSummary { + switch entry { + case .empty: + return buildEmptyRoomSummary() + case .filled(let roomId): + return buildRoomSummaryForIdentifier(roomId) + case .invalidated(let roomId): + return buildRoomSummaryForIdentifier(roomId) + } + } + + private func buildDiff(from diff: SlidingSyncViewRoomsListDiff) -> CollectionDifference? { + // Invalidations are a no-op for the moment + if diff.isInvalidation { + return nil + } + + var changes = [CollectionDifference.Change]() + + switch diff { + case .push(value: let value): + let summary = buildSummaryForRoomListEntry(value) + changes.append(.insert(offset: Int(roomSummaries.count), element: summary, associatedWith: nil)) + case .updateAt(let index, let value): + let summary = buildSummaryForRoomListEntry(value) + changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil)) + changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil)) + case .insertAt(let index, let value): + let summary = buildSummaryForRoomListEntry(value) + changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil)) + case .move(let oldIndex, let newIndex): + let summary = roomSummaries[Int(oldIndex)] + changes.append(.remove(offset: Int(oldIndex), element: summary, associatedWith: nil)) + changes.append(.insert(offset: Int(newIndex), element: summary, associatedWith: nil)) + case .removeAt(let index): + let summary = roomSummaries[Int(index)] + changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil)) + case .replace(let values): + for (index, summary) in roomSummaries.enumerated() { + changes.append(.remove(offset: index, element: summary, associatedWith: nil)) + } + + values + .reversed() + .map { buildSummaryForRoomListEntry($0) } + .forEach { summary in + changes.append(.insert(offset: 0, element: summary, associatedWith: nil)) + } + } + + return CollectionDifference(changes) + } +} + +extension SlidingSyncViewRoomsListDiff { + var isInvalidation: Bool { + switch self { + case .push(let value), .updateAt(_, let value), .insertAt(_, let value): + switch value { + case .invalidated: + return true + default: + return false + } + default: + return false + } + } +} + +extension RoomSummaryProviderState { + init(slidingSyncState: SlidingSyncState) { + switch slidingSyncState { + case .catchingUp: + self = .live + case .live: + self = .live + default: + self = .cold + } + } +} diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift new file mode 100644 index 000000000..7f531ae99 --- /dev/null +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProviderProtocol.swift @@ -0,0 +1,45 @@ +// +// 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 + +enum RoomSummaryProviderCallback { + case updatedRoomSummaries +} + +enum RoomSummaryProviderState { + case cold + case live +} + +protocol RoomSummaryProviderProtocol { + var callbacks: PassthroughSubject { get } + + /// Publishes the current state the summary provider is finding itself in + var stateUpdatePublisher: CurrentValueSubject { get } + + /// Publishes the total number of rooms + var countUpdatePublisher: CurrentValueSubject { get } + + /// The current list of rooms currently provided by the sliding sync view + var roomSummaries: [RoomSummary] { get } + + /// Invoked by the sliding sync controller whenever certain rooms have updated + /// without necessarily changing their position in the list + /// - Parameter identifiers: the identifiers for the rooms that have changed + func updateRoomsWithIdentifiers(_ identifiers: [String]) +} diff --git a/ElementX/Sources/Services/Session/MockUserSession.swift b/ElementX/Sources/Services/Session/MockUserSession.swift index 1b8b2b657..231a4a965 100644 --- a/ElementX/Sources/Services/Session/MockUserSession.swift +++ b/ElementX/Sources/Services/Session/MockUserSession.swift @@ -19,7 +19,6 @@ import Combine struct MockUserSession: UserSessionProtocol { let callbacks = PassthroughSubject() let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil - var userID: String { clientProxy.userIdentifier } var isSoftLogout: Bool { clientProxy.isSoftLogout } var deviceId: String? { clientProxy.deviceId } diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index d131297c4..cd104489f 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -76,7 +76,7 @@ class UserSession: UserSessionProtocol { }.store(in: &cancellables) case .failure(let error): - MXLog.error("Failed getting session verification controller with error: \(error). Will retry on the next sync update.") + MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.") } } } diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index da32bc626..94f84bbf1 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -24,6 +24,7 @@ enum UserSessionCallback { case updateRestoreTokenNeeded } +@MainActor protocol UserSessionProtocol { var userID: String { get } var isSoftLogout: Bool { get } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index cfac29089..2ded3a052 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -22,39 +22,74 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() - var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString, - text: "Yesterday"), - TextRoomTimelineItem(id: UUID().uuidString, - text: "You rock!", - timestamp: "10:10 AM", - shouldShowSenderDetails: true, - isOutgoing: false, - senderId: "", - senderDisplayName: "Some user with a really long long long long long display name", - properties: RoomTimelineItemProperties(isEdited: true)), - TextRoomTimelineItem(id: UUID().uuidString, - text: "You also rule!", - timestamp: "10:11 AM", - shouldShowSenderDetails: false, - isOutgoing: false, - senderId: "", - senderDisplayName: "Alice", - properties: RoomTimelineItemProperties(reactions: [ - AggregatedReaction(key: "🙌", count: 1, isHighlighted: true) - ])), - SeparatorRoomTimelineItem(id: UUID().uuidString, - text: "Today"), - TextRoomTimelineItem(id: UUID().uuidString, - text: "You too!", - timestamp: "5 PM", - shouldShowSenderDetails: false, - isOutgoing: true, - senderId: "", - senderDisplayName: "Bob", - properties: RoomTimelineItemProperties(reactions: [ - AggregatedReaction(key: "🙏", count: 1, isHighlighted: false), - AggregatedReaction(key: "😁", count: 3, isHighlighted: false) - ]))] + var timelineItems: [RoomTimelineItemProtocol] = [ + SeparatorRoomTimelineItem(id: UUID().uuidString, + text: "Yesterday"), + TextRoomTimelineItem(id: UUID().uuidString, + text: "That looks so good!", + timestamp: "10:10 AM", + shouldShowSenderDetails: true, + inGroupState: .single, + isOutgoing: false, + senderId: "", + senderDisplayName: "Jacob", + properties: RoomTimelineItemProperties(isEdited: true)), + TextRoomTimelineItem(id: UUID().uuidString, + text: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗", + timestamp: "10:11 AM", + shouldShowSenderDetails: true, + inGroupState: .beginning, + isOutgoing: false, + senderId: "", + senderDisplayName: "Helena", + properties: RoomTimelineItemProperties(reactions: [ + AggregatedReaction(key: "🙌", count: 1, isHighlighted: true) + ])), + TextRoomTimelineItem(id: UUID().uuidString, + text: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/", + timestamp: "10:11 AM", + shouldShowSenderDetails: false, + inGroupState: .middle, + isOutgoing: false, + senderId: "", + senderDisplayName: "Helena", + properties: RoomTimelineItemProperties(reactions: [ + AggregatedReaction(key: "🙏", count: 1, isHighlighted: false), + AggregatedReaction(key: "🙌", count: 2, isHighlighted: true) + ])), + SeparatorRoomTimelineItem(id: UUID().uuidString, + text: "Today"), + TextRoomTimelineItem(id: UUID().uuidString, + text: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!", + timestamp: "5 PM", + shouldShowSenderDetails: false, + inGroupState: .end, + isOutgoing: false, + senderId: "", + senderDisplayName: "Helena", + properties: RoomTimelineItemProperties()), + TextRoomTimelineItem(id: UUID().uuidString, + text: "And John's speech was amazing!", + timestamp: "5 PM", + shouldShowSenderDetails: false, + inGroupState: .single, + isOutgoing: true, + senderId: "", + senderDisplayName: "Bob", + properties: RoomTimelineItemProperties()), + TextRoomTimelineItem(id: UUID().uuidString, + text: "New home office set up!", + timestamp: "5 PM", + shouldShowSenderDetails: false, + inGroupState: .single, + isOutgoing: true, + senderId: "", + senderDisplayName: "Bob", + properties: RoomTimelineItemProperties(reactions: [ + AggregatedReaction(key: "🙏", count: 1, isHighlighted: false), + AggregatedReaction(key: "😁", count: 3, isHighlighted: false) + ])) + ] func paginateBackwards(_ count: UInt) async -> Result { .failure(.generic) diff --git a/ElementX/Sources/Services/Room/Messages/NoticeRoomMessage.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift similarity index 50% rename from ElementX/Sources/Services/Room/Messages/NoticeRoomMessage.swift rename to ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift index 10dfadae4..88f68b2f4 100644 --- a/ElementX/Sources/Services/Room/Messages/NoticeRoomMessage.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift @@ -14,34 +14,21 @@ // limitations under the License. // -import Foundation -import MatrixRustSDK -import UIKit +import Combine -struct NoticeRoomMessage: RoomMessageProtocol { - private let message: MatrixRustSDK.NoticeMessage +struct MockRoomTimelineProvider: RoomTimelineProviderProtocol { + var callbacks = PassthroughSubject() + var items = [RoomTimelineProviderItem]() - init(message: MatrixRustSDK.NoticeMessage) { - self.message = message + func paginateBackwards(_ count: UInt) async -> Result { + .failure(.failedPaginatingBackwards) } - var id: String { - message.baseMessage().id() + func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result { + .failure(.failedSendingMessage) } - var body: String { - message.baseMessage().body() - } - - var htmlBody: String? { - message.htmlBody() - } - - var sender: String { - message.baseMessage().sender() - } - - var originServerTs: Date { - Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs())) + func redact(_ eventID: String) async -> Result { + .failure(.failedRedactingItem) } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index 9ebafb9a0..73ffec825 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -23,7 +23,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { private let timelineProvider: RoomTimelineProviderProtocol private let timelineItemFactory: RoomTimelineItemFactoryProtocol private let mediaProvider: MediaProviderProtocol - private let memberDetailProvider: MemberDetailProviderProtocol + private let roomProxy: RoomProxyProtocol private var cancellables = Set() private var timelineItemsUpdateTask: Task? { @@ -42,13 +42,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol { timelineProvider: RoomTimelineProviderProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol, - memberDetailProvider: MemberDetailProviderProtocol) { + roomProxy: RoomProxyProtocol) { self.userId = userId self.roomId = roomId self.timelineProvider = timelineProvider self.timelineItemFactory = timelineItemFactory self.mediaProvider = mediaProvider - self.memberDetailProvider = memberDetailProvider + self.roomProxy = roomProxy self.timelineProvider .callbacks @@ -133,29 +133,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol { private func asyncUpdateTimelineItems() async { var newTimelineItems = [RoomTimelineItemProtocol]() - - var previousMessage: RoomMessageProtocol? - for message in timelineProvider.messages { + + for (index, item) in timelineProvider.items.enumerated() { if Task.isCancelled { return } + + let previousItem = timelineProvider.items[safe: index - 1] + let nextItem = timelineProvider.items[safe: index + 1] + + let inGroupState = inGroupState(for: item, previousItem: previousItem, nextItem: nextItem) - let areMessagesFromTheSameDay = haveSameDay(lhs: previousMessage, rhs: message) - let shouldAddSectionHeader = !areMessagesFromTheSameDay - - if shouldAddSectionHeader { - newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(), - text: message.originServerTs.formatted(date: .complete, time: .omitted))) + switch item { + case .event(let eventItem): + guard eventItem.isMessage else { break } // To be handled in the future + + newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(eventItem: eventItem, + showSenderDetails: inGroupState.shouldShowSenderDetails, + inGroupState: inGroupState)) + case .virtual: +// case .virtual(let virtualItem): +// newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(), +// text: message.originServerTs.formatted(date: .complete, time: .omitted))) + #warning("Fix the UUID or \"bad things will happen\"") + newTimelineItems.append(SeparatorRoomTimelineItem(id: UUID().uuidString, + text: "The day before")) + case .other: + break } - - let areMessagesFromTheSameSender = (previousMessage?.sender == message.sender) - let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay - - newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(message: message, - isOutgoing: message.sender == userId, - showSenderDetails: shouldShowSenderDetails)) - - previousMessage = message } if Task.isCancelled { @@ -166,13 +171,50 @@ class RoomTimelineController: RoomTimelineControllerProtocol { callbacks.send(.updatedTimelineItems) } - - private func haveSameDay(lhs: RoomMessageProtocol?, rhs: RoomMessageProtocol?) -> Bool { - guard let lhs = lhs, let rhs = rhs else { - return false + + private func inGroupState(for item: RoomTimelineProviderItem, + previousItem: RoomTimelineProviderItem?, + nextItem: RoomTimelineProviderItem?) -> TimelineItemInGroupState { + guard let previousItem = previousItem else { + // no previous item, check next item + guard let nextItem = nextItem else { + // no next item neither, this is single + return .single + } + guard nextItem.canBeGrouped(with: item) else { + // there is a next item but can't be grouped, this is single + return .single + } + // next will be grouped with this one, this is the start + return .beginning } - - return Calendar.current.isDate(lhs.originServerTs, inSameDayAs: rhs.originServerTs) + + guard let nextItem = nextItem else { + // no next item + guard item.canBeGrouped(with: previousItem) else { + // there is a previous item but can't be grouped, this is single + return .single + } + // will be grouped with previous, this is the end + return .end + } + + guard item.canBeGrouped(with: previousItem) else { + guard nextItem.canBeGrouped(with: item) else { + // there is a next item but can't be grouped, this is single + return .single + } + // next will be grouped with this one, this is the start + return .beginning + } + + guard nextItem.canBeGrouped(with: item) else { + // there is a next item but can't be grouped, this is the end + return .end + } + + // next will be grouped with this one, this is the start + return .middle } private func loadImageForTimelineItem(_ timelineItem: ImageRoomTimelineItem) async { @@ -204,13 +246,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return } - switch await memberDetailProvider.loadAvatarURLStringForUserId(timelineItem.senderId) { + switch await roomProxy.loadAvatarURLForUserId(timelineItem.senderId) { case .success(let avatarURLString): guard let avatarURLString = avatarURLString else { return } - switch await mediaProvider.loadImageFromURLString(avatarURLString) { + switch await mediaProvider.loadImageFromURLString(avatarURLString, size: MediaProviderDefaultAvatarSize) { case .success(let avatar): guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), var item = timelineItems[index] as? EventBasedTimelineItemProtocol else { @@ -234,7 +276,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return } - switch await memberDetailProvider.loadDisplayNameForUserId(timelineItem.senderId) { + switch await roomProxy.loadDisplayNameForUserId(timelineItem.senderId) { case .success(let displayName): guard let displayName = displayName, let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index 7105cb601..25e061ac2 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -15,7 +15,20 @@ // import Combine -import Foundation +import MatrixRustSDK + +#warning("Rename to RoomTimelineListener???") +class WeakRoomTimelineProviderWrapper: TimelineListener { + private weak var timelineProvider: RoomTimelineProvider? + + init(timelineProvider: RoomTimelineProvider) { + self.timelineProvider = timelineProvider + } + + func onUpdate(update: TimelineDiff) { + timelineProvider?.onUpdate(update: update) + } +} class RoomTimelineProvider: RoomTimelineProviderProtocol { private let roomProxy: RoomProxyProtocol @@ -23,31 +36,20 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { let callbacks = PassthroughSubject() + private(set) var items: [RoomTimelineProviderItem] + init(roomProxy: RoomProxyProtocol) { self.roomProxy = roomProxy - - self.roomProxy.callbacks - .receive(on: DispatchQueue.main) - .sink { [weak self] callback in - guard let self = self else { return } - - switch callback { - case .updatedMessages: - self.callbacks.send(.updatedMessages) - } - }.store(in: &cancellables) - } - - var messages: [RoomMessageProtocol] { - roomProxy.messages + items = [] } func paginateBackwards(_ count: UInt) async -> Result { switch await roomProxy.paginateBackwards(count: count) { case .success: return .success(()) - case .failure: - return .failure(.generic) + case .failure(let error): + if error == .noMoreMessagesToBackPaginate { return .failure(.noMoreMessagesToBackPaginate) } + return .failure(.failedPaginatingBackwards) } } @@ -69,3 +71,73 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { } } } + +// MARK: - TimelineListener + +private extension RoomTimelineProvider { + func onUpdate(update: TimelineDiff) { + let change = update.change() + MXLog.verbose("Change: \(change)") + + switch change { + case .replace: + replaceItems(update.replace()) + case .insertAt: + insertItem(update.insertAt()) + case .updateAt: + updateItem(update.updateAt()) + case .removeAt: + removeItem(at: update.removeAt()) + case .move: + moveItem(update.move()) + case .push: + pushItem(update.push()) + case .pop: + popItem() + case .clear: + clearAllItems() + } + + callbacks.send(.updatedMessages) + } + + private func replaceItems(_ items: [MatrixRustSDK.TimelineItem]?) { + guard let items = items else { return } + self.items = items.map(RoomTimelineProviderItem.init) + } + + private func insertItem(_ data: InsertAtData?) { + guard let data = data else { return } + let item = RoomTimelineProviderItem(item: data.item) + items.insert(item, at: Int(data.index)) + } + + private func updateItem(_ data: UpdateAtData?) { + guard let data = data else { return } + let item = RoomTimelineProviderItem(item: data.item) + items[Int(data.index)] = item + } + + private func removeItem(at index: UInt32?) { + guard let index = index else { return } + items.remove(at: Int(index)) + } + + private func moveItem(_ data: MoveData?) { + guard let data = data else { return } + items.move(fromOffsets: IndexSet(integer: Int(data.oldIndex)), toOffset: Int(data.newIndex)) + } + + private func pushItem(_ item: MatrixRustSDK.TimelineItem?) { + guard let item = item else { return } + items.append(RoomTimelineProviderItem(item: item)) + } + + private func popItem() { + items.removeLast() + } + + private func clearAllItems() { + items.removeAll() + } +} diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProviderItem.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProviderItem.swift new file mode 100644 index 000000000..6d7bcc1ac --- /dev/null +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProviderItem.swift @@ -0,0 +1,89 @@ +// +// 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 MatrixRustSDK + +/// A light wrapper around timeline items returned from Rust for use in `RoomTimelineProvider`. +enum RoomTimelineProviderItem { + case event(EventTimelineItem) + case virtual(MatrixRustSDK.VirtualTimelineItem) + case other(MatrixRustSDK.TimelineItem) + + init(item: MatrixRustSDK.TimelineItem) { + if let eventItem = item.asEvent() { + self = .event(EventTimelineItem(item: eventItem)) + } else if let virtualItem = item.asVirtual() { + self = .virtual(virtualItem) + } else { + self = .other(item) + } + } + + func canBeGrouped(with prevItem: RoomTimelineProviderItem) -> Bool { + guard case let .event(selfEventItem) = self, case let .event(prevEventItem) = prevItem else { + return false + } + // can be improved by adding a date threshold + return prevEventItem.reactions.isEmpty && selfEventItem.sender == prevEventItem.sender + } +} + +/// A light wrapper around event timeline items returned from Rust, used in `RoomTimelineProviderItem`. +struct EventTimelineItem { + let item: MatrixRustSDK.EventTimelineItem + + init(item: MatrixRustSDK.EventTimelineItem) { + self.item = item + } + + var id: String { + #warning("Handle txid in a better way") + switch item.key() { + case .transactionId(let txnID): + return txnID + case .eventId(let eventID): + return eventID + } + } + + var body: String? { + content.asMessage()?.body() + } + + var isMessage: Bool { + content.asMessage() != nil + } + + var content: TimelineItemContent { + item.content() + } + + var sender: String { + item.sender() + } + + var reactions: [Reaction] { + item.reactions() + } + + var originServerTs: Date { + if let timestamp = item.originServerTs() { + return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + } else { + return .now + } + } +} diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift index 38fa38277..547650c62 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift @@ -22,16 +22,17 @@ enum RoomTimelineProviderCallback { } enum RoomTimelineProviderError: Error { + case noMoreMessagesToBackPaginate + case failedPaginatingBackwards case failedSendingMessage case failedRedactingItem case generic } -@MainActor protocol RoomTimelineProviderProtocol { var callbacks: PassthroughSubject { get } - var messages: [RoomMessageProtocol] { get } + var items: [RoomTimelineProviderItem] { get } func paginateBackwards(_ count: UInt) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift new file mode 100644 index 000000000..14495061d --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift @@ -0,0 +1,111 @@ +// +// 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 CoreGraphics +import Foundation +import MatrixRustSDK + +/// A protocol that contains the base `m.room.message` event content properties. +protocol MessageContentProtocol: RoomMessageEventContentProtocol { + var body: String { get } +} + +/// A timeline item that represents an `m.room.message` event. +struct MessageTimelineItem { + let item: MatrixRustSDK.EventTimelineItem + let content: Content + + var id: String { + #warning("Handle txid properly") + switch item.key() { + case .transactionId(let txnID): + return txnID + case .eventId(let eventID): + return eventID + } + } + + var body: String { + content.body + } + + var isEdited: Bool { + item.content().asMessage()?.isEdited() == true + } + + var inReplyTo: String? { + item.content().asMessage()?.inReplyTo() + } + + var reactions: [Reaction] { + item.reactions() + } + + var sender: String { + item.sender() + } + + var originServerTs: Date { + if let timestamp = item.originServerTs() { + return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + } else { + return .now + } + } +} + +// MARK: - Formatted Text + +/// A protocol that contains the expected event content properties for a formatted message. +protocol FormattedMessageContentProtocol: MessageContentProtocol { + var formatted: FormattedBody? { get } +} + +extension MatrixRustSDK.TextMessageContent: FormattedMessageContentProtocol { } +extension MatrixRustSDK.EmoteMessageContent: FormattedMessageContentProtocol { } +extension MatrixRustSDK.NoticeMessageContent: FormattedMessageContentProtocol { } + +/// A timeline item that represents an `m.room.message` event where +/// the `msgtype` would likely contain a formatted body. +extension MessageTimelineItem where Content: FormattedMessageContentProtocol { + var htmlBody: String? { + #warning("Should this check the formatted type?") + return content.formatted?.body + } +} + +// MARK: - Media + +extension MatrixRustSDK.ImageMessageContent: MessageContentProtocol { } + +/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.image`. +extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent { + var source: MediaSource { + MediaSource(source: content.source) + } + + var width: CGFloat? { + content.info?.width.map(CGFloat.init) + } + + var height: CGFloat? { + content.info?.height.map(CGFloat.init) + } + + var blurhash: String? { + content.info?.blurhash + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index c224856f5..b772050db 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -17,10 +17,40 @@ import Foundation import UIKit +enum TimelineItemInGroupState { + case single + case beginning + case middle + case end + + var roundedCorners: UIRectCorner { + switch self { + case .single: + return .allCorners + case .beginning: + return [.topLeft, .topRight] + case .middle: + return [] + case .end: + return [.bottomLeft, .bottomRight] + } + } + + var shouldShowSenderDetails: Bool { + switch self { + case .single, .beginning: + return true + default: + return false + } + } +} + protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol { var text: String { get } var timestamp: String { get } var shouldShowSenderDetails: Bool { get } + var inGroupState: TimelineItemInGroupState { get } var isOutgoing: Bool { get } var senderId: String { get } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift index 9df1dc26e..436ad8ec4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift @@ -23,6 +23,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa var attributedComponents: [AttributedStringBuilderComponent]? let timestamp: String let shouldShowSenderDetails: Bool + let inGroupState: TimelineItemInGroupState let isOutgoing: Bool let senderId: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift index aacfe56c4..f96884e74 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift @@ -22,6 +22,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa let text: String let timestamp: String let shouldShowSenderDetails: Bool + let inGroupState: TimelineItemInGroupState let isOutgoing: Bool let senderId: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift index 4dc98e5b1..1b81e48a9 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift @@ -23,6 +23,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equ var attributedComponents: [AttributedStringBuilderComponent]? let timestamp: String let shouldShowSenderDetails: Bool + let inGroupState: TimelineItemInGroupState let isOutgoing: Bool let senderId: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift index 2d739df0c..f395ba43f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift @@ -23,6 +23,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equat var attributedComponents: [AttributedStringBuilderComponent]? let timestamp: String let shouldShowSenderDetails: Bool + let inGroupState: TimelineItemInGroupState let isOutgoing: Bool let senderId: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 04305bcec..e8a567538 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -14,46 +14,85 @@ // limitations under the License. // -import Foundation +import MatrixRustSDK import UIKit struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private let mediaProvider: MediaProviderProtocol - private let memberDetailProvider: MemberDetailProviderProtocol + private let roomProxy: RoomProxyProtocol private let attributedStringBuilder: AttributedStringBuilderProtocol - init(mediaProvider: MediaProviderProtocol, - memberDetailProvider: MemberDetailProviderProtocol, + /// The Matrix ID of the current user. + private let userID: String + + init(userID: String, + mediaProvider: MediaProviderProtocol, + roomProxy: RoomProxyProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) { + self.userID = userID self.mediaProvider = mediaProvider - self.memberDetailProvider = memberDetailProvider + self.roomProxy = roomProxy self.attributedStringBuilder = attributedStringBuilder } - func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) async -> RoomTimelineItemProtocol { - let displayName = memberDetailProvider.displayNameForUserId(message.sender) - let avatarURL = memberDetailProvider.avatarURLStringForUserId(message.sender) - let avatarImage = mediaProvider.imageFromURLString(avatarURL) + func buildTimelineItemFor(eventItem: EventTimelineItem, + showSenderDetails: Bool, + inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol { + guard let messageContent = eventItem.content.asMessage() else { fatalError("Must be a message for now.") } + let displayName = roomProxy.displayNameForUserId(eventItem.sender) + let avatarURL = roomProxy.avatarURLStringForUserId(eventItem.sender) + let avatarImage = mediaProvider.imageFromURLString(avatarURL, size: MediaProviderDefaultAvatarSize) + let isOutgoing = eventItem.sender == userID - switch message { - case let message as TextRoomMessage: - return await buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage) - case let message as ImageRoomMessage: - return await buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage) - case let message as NoticeRoomMessage: - return await buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage) - case let message as EmoteRoomMessage: - return await buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage) - default: - fatalError("Unknown room message.") + switch messageContent.msgtype() { + case .text(content: let content): + let message = MessageTimelineItem(item: eventItem.item, content: content) + return await buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage) + case .image(content: let content): + let message = MessageTimelineItem(item: eventItem.item, content: content) + return await buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage) + case .notice(content: let content): + let message = MessageTimelineItem(item: eventItem.item, content: content) + return await buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage) + case .emote(content: let content): + let message = MessageTimelineItem(item: eventItem.item, content: content) + return await buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage) + case .none: + return await buildFallbackTimelineItem(eventItem, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage) } } // MARK: - Private - private func buildTextTimelineItemFromMessage(_ message: TextRoomMessage, + // swiftformat:disable function_parameter_count + // swiftlint:disable function_parameter_count + private func buildFallbackTimelineItem(_ item: EventTimelineItem, + _ isOutgoing: Bool, + _ showSenderDetails: Bool, + _ inGroupState: TimelineItemInGroupState, + _ displayName: String?, + _ avatarImage: UIImage?) async -> RoomTimelineItemProtocol { + let attributedText = await attributedStringBuilder.fromPlain(item.body) + let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText) + + return TextRoomTimelineItem(id: item.id, + text: item.body ?? "", + attributedComponents: attributedComponents, + timestamp: item.originServerTs.formatted(date: .omitted, time: .shortened), + shouldShowSenderDetails: showSenderDetails, + inGroupState: inGroupState, + isOutgoing: isOutgoing, + senderId: item.sender, + senderDisplayName: displayName, + senderAvatar: avatarImage, + properties: RoomTimelineItemProperties(isEdited: item.content.asMessage()?.isEdited() ?? false, + reactions: aggregateReactions(item.reactions))) + } + + private func buildTextTimelineItemFromMessage(_ message: MessageTimelineItem, _ isOutgoing: Bool, _ showSenderDetails: Bool, + _ inGroupState: TimelineItemInGroupState, _ displayName: String?, _ avatarImage: UIImage?) async -> RoomTimelineItemProtocol { let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) @@ -64,15 +103,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { attributedComponents: attributedComponents, timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), shouldShowSenderDetails: showSenderDetails, + inGroupState: inGroupState, isOutgoing: isOutgoing, senderId: message.sender, senderDisplayName: displayName, - senderAvatar: avatarImage) + senderAvatar: avatarImage, + properties: RoomTimelineItemProperties(isEdited: message.isEdited, + reactions: aggregateReactions(message.reactions))) } - private func buildImageTimelineItemFromMessage(_ message: ImageRoomMessage, + private func buildImageTimelineItemFromMessage(_ message: MessageTimelineItem, _ isOutgoing: Bool, _ showSenderDetails: Bool, + _ inGroupState: TimelineItemInGroupState, _ displayName: String?, _ avatarImage: UIImage?) async -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? @@ -85,6 +128,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { text: message.body, timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), shouldShowSenderDetails: showSenderDetails, + inGroupState: inGroupState, isOutgoing: isOutgoing, senderId: message.sender, senderDisplayName: displayName, @@ -94,12 +138,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { width: message.width, height: message.height, aspectRatio: aspectRatio, - blurhash: message.blurhash) + blurhash: message.blurhash, + properties: RoomTimelineItemProperties(isEdited: message.isEdited, + reactions: aggregateReactions(message.reactions))) } - private func buildNoticeTimelineItemFromMessage(_ message: NoticeRoomMessage, + private func buildNoticeTimelineItemFromMessage(_ message: MessageTimelineItem, _ isOutgoing: Bool, _ showSenderDetails: Bool, + _ inGroupState: TimelineItemInGroupState, _ displayName: String?, _ avatarImage: UIImage?) async -> RoomTimelineItemProtocol { let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) @@ -110,15 +157,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { attributedComponents: attributedComponents, timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), shouldShowSenderDetails: showSenderDetails, + inGroupState: inGroupState, isOutgoing: isOutgoing, senderId: message.sender, senderDisplayName: displayName, - senderAvatar: avatarImage) + senderAvatar: avatarImage, + properties: RoomTimelineItemProperties(isEdited: message.isEdited, + reactions: aggregateReactions(message.reactions))) } - private func buildEmoteTimelineItemFromMessage(_ message: EmoteRoomMessage, + private func buildEmoteTimelineItemFromMessage(_ message: MessageTimelineItem, _ isOutgoing: Bool, _ showSenderDetails: Bool, + _ inGroupState: TimelineItemInGroupState, _ displayName: String?, _ avatarImage: UIImage?) async -> RoomTimelineItemProtocol { let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) @@ -129,9 +180,22 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { attributedComponents: attributedComponents, timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), shouldShowSenderDetails: showSenderDetails, + inGroupState: inGroupState, isOutgoing: isOutgoing, senderId: message.sender, senderDisplayName: displayName, - senderAvatar: avatarImage) + senderAvatar: avatarImage, + properties: RoomTimelineItemProperties(isEdited: message.isEdited, + reactions: aggregateReactions(message.reactions))) + } + + // swiftlint:enable function_parameter_count + // swiftformat:enable function_parameter_count + + private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] { + reactions.map { reaction in + let isHighlighted = false // reaction.details.contains(where: { $0.sender == userID }) + return AggregatedReaction(key: reaction.key, count: Int(reaction.count), isHighlighted: isHighlighted) + } } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift index b73335185..ff40761d0 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift @@ -18,5 +18,7 @@ import Foundation @MainActor protocol RoomTimelineItemFactoryProtocol { - func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) async -> RoomTimelineItemProtocol + func buildTimelineItemFor(eventItem: EventTimelineItem, + showSenderDetails: Bool, + inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol } diff --git a/ElementX/Sources/UITestsAppCoordinator.swift b/ElementX/Sources/UITestsAppCoordinator.swift index 45a4d18eb..fc9ad7b85 100644 --- a/ElementX/Sources/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITestsAppCoordinator.swift @@ -110,14 +110,12 @@ class MockScreen: Identifiable { case .roomPlainNoAvatar: let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(), roomName: "Some room name", - roomAvatar: nil, - roomEncryptionBadge: nil) + roomAvatar: nil) return RoomScreenCoordinator(parameters: parameters) case .roomEncryptedWithAvatar: let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(), roomName: "Some room name", - roomAvatar: Asset.Images.appLogo.image, - roomEncryptionBadge: Asset.Images.encryptionTrusted.image) + roomAvatar: Asset.Images.appLogo.image) return RoomScreenCoordinator(parameters: parameters) case .sessionVerification: let parameters = SessionVerificationCoordinatorParameters(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy()) diff --git a/UITests/Sources/SessionVerificationUITests.swift b/UITests/Sources/SessionVerificationUITests.swift index dc24c0048..f34f237a9 100644 --- a/UITests/Sources/SessionVerificationUITests.swift +++ b/UITests/Sources/SessionVerificationUITests.swift @@ -22,10 +22,8 @@ class SessionVerificationUITests: XCTestCase { let app = Application.launch() app.goToScreenWithIdentifier(.sessionVerification) - XCTAssert(app.navigationBars[ElementL10n.verificationVerifyDevice].exists) - XCTAssert(app.buttons["startButton"].exists) - XCTAssert(app.buttons["dismissButton"].exists) + XCTAssert(app.buttons["closeButton"].exists) XCTAssert(app.staticTexts["titleLabel"].exists) app.assertScreenshot(.sessionVerification) @@ -33,72 +31,63 @@ class SessionVerificationUITests: XCTestCase { app.buttons["startButton"].tap() XCTAssert(app.activityIndicators["requestingVerificationProgressView"].exists) - XCTAssert(app.buttons["cancelButton"].exists) XCTAssert(app.buttons["challengeAcceptButton"].waitForExistence(timeout: 5.0)) XCTAssert(app.buttons["challengeDeclineButton"].waitForExistence(timeout: 5.0)) - XCTAssert(app.buttons["cancelButton"].waitForExistence(timeout: 5.0)) app.buttons["challengeAcceptButton"].tap() XCTAssert(app.activityIndicators["acceptingChallengeProgressView"].exists) - XCTAssert(app.buttons["cancelButton"].exists) XCTAssert(app.images["sessionVerificationSucceededIcon"].waitForExistence(timeout: 5.0)) - XCTAssert(app.buttons["dismissButton"].exists) - app.buttons["dismissButton"].tap() + XCTAssert(app.buttons["finishButton"].exists) + XCTAssert(app.buttons["closeButton"].exists) + app.buttons["closeButton"].tap() } func testChallengeDoesNotMatch() { let app = Application.launch() app.goToScreenWithIdentifier(.sessionVerification) - XCTAssert(app.navigationBars[ElementL10n.verificationVerifyDevice].exists) - XCTAssert(app.buttons["startButton"].exists) - XCTAssert(app.buttons["dismissButton"].exists) + XCTAssert(app.buttons["closeButton"].exists) XCTAssert(app.staticTexts["titleLabel"].exists) app.buttons["startButton"].tap() XCTAssert(app.activityIndicators["requestingVerificationProgressView"].exists) - XCTAssert(app.buttons["cancelButton"].exists) XCTAssert(app.buttons["challengeAcceptButton"].waitForExistence(timeout: 5.0)) XCTAssert(app.buttons["challengeDeclineButton"].waitForExistence(timeout: 5.0)) - XCTAssert(app.buttons["cancelButton"].waitForExistence(timeout: 5.0)) app.buttons["challengeDeclineButton"].tap() XCTAssert(app.images["sessionVerificationFailedIcon"].exists) XCTAssert(app.buttons["restartButton"].exists) - XCTAssert(app.buttons["dismissButton"].exists) - app.buttons["dismissButton"].tap() + XCTAssert(app.buttons["closeButton"].exists) + app.buttons["closeButton"].tap() } func testSessionVerificationCancelation() { let app = Application.launch() app.goToScreenWithIdentifier(.sessionVerification) - XCTAssert(app.navigationBars[ElementL10n.verificationVerifyDevice].exists) - XCTAssert(app.buttons["startButton"].exists) - XCTAssert(app.buttons["dismissButton"].exists) + XCTAssert(app.buttons["closeButton"].exists) XCTAssert(app.staticTexts["titleLabel"].exists) app.buttons["startButton"].tap() XCTAssert(app.activityIndicators["requestingVerificationProgressView"].waitForExistence(timeout: 1)) - XCTAssert(app.buttons["cancelButton"].exists) - app.buttons["cancelButton"].tap() + app.buttons["closeButton"].tap() XCTAssert(app.images["sessionVerificationFailedIcon"].waitForExistence(timeout: 1)) XCTAssert(app.buttons["restartButton"].exists) - XCTAssert(app.buttons["dismissButton"].exists) - app.buttons["dismissButton"].tap() + XCTAssert(app.buttons["closeButton"].exists) + app.buttons["closeButton"].tap() } } diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomEncryptedWithAvatar.png index a609b485e..97a8722ba 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65411f747c74e10f406884245395873861aa62723f9e80887444003bf0687b76 -size 106569 +oid sha256:ab72bd1f5eeba3e7310148e088d47e8166c2aec1940914befe01d953c17aa230 +size 179710 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomPlainNoAvatar.png index dd8cb1161..02675e1a5 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:649263627fab93c7935261e22ae6f2d186eac98e198856938a4299920e622db2 -size 106725 +oid sha256:95e40aea82cf2dc6fc7a6da2c46328288b62e4677ef5fcc78ad602e9368e953d +size 180213 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.sessionVerification.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.sessionVerification.png index 10d84723e..93e1a23e5 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.sessionVerification.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.sessionVerification.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:223ec364fe7ac2c034467b4fbf576ed2bdfe28bd498879f56975758da7d653fa -size 96373 +oid sha256:29ac322852f64822340fafe556dec80c2d6e1a2785e7ac0a45d55632a63b8fd2 +size 91786 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.settings.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.settings.png index 8883ba457..fa20056f3 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.settings.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a15c6fd4900684cbd027efb4524e199fb8e83f884728d5e0f85298737cd47bac -size 91151 +oid sha256:391c1ab2b0acfb78643925683ea83e0e5bec14b8c6ca37a937371bf44e85145e +size 104279 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png index e02799f6c..ed34755ce 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac66380757fb5492b031005df9cc61ab81e1eff082fdc95d5349e77e81fa5fd0 -size 135105 +oid sha256:797dc8ca92fe40120ddb66f3368fa7cf9f1ed2af4e2a6ce21f0f598a04b96f67 +size 275383 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomPlainNoAvatar.png index 85ac57d79..bdb78e6cd 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e578a0c50e39ee37348da998a2cf20c985dafd36fe83b28fab08f28fb65e15c -size 135170 +oid sha256:10e8dd701fee045c5a8a65f49f88c0000ab5d2087162cd81cd8ea17b2e67dc39 +size 276199 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.sessionVerification.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.sessionVerification.png index fca0aea0a..e5819cd3f 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.sessionVerification.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.sessionVerification.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bfe3ac39aa9ba811c71f7c560754a9f73271fffc1ffe09e3ec15438a2e75bd00 -size 124558 +oid sha256:4023584a45db5ac9dd3b46dee7a744c3de9459185373842349704c1af9129b6d +size 115260 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.settings.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.settings.png index cc3b83f57..559f2d230 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.settings.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42844501aeb4bf2c3532ef588f1d7ce9895ebbe9f190ea47917e7cba3246dd23 -size 111782 +oid sha256:715457882656559dca2ec8305bc1745fcf3a555a4e856f1f4567d2fc306e1bd3 +size 135210 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png index 771fb6ffc..3d8dd9678 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ef80dc65e958a3c883d720563afe4264c88f718483cb9ea65898b160e90585c -size 106134 +oid sha256:f6ff042bf5131386b978e5445794726ebd39f64233a0dd13b177fa360b90573d +size 179281 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomPlainNoAvatar.png index 8de054ca2..0c220b74c 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b480be07ff7d03638a85397445baf1dfdb8fbb8036d4cee68800bd60de1be8f -size 106287 +oid sha256:332006c46bd36c85aa5447657e182484e566b1a86ea723e5054243c6d3a17a76 +size 179777 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.sessionVerification.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.sessionVerification.png index ee22d07a9..e3dcdb364 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.sessionVerification.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.sessionVerification.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72c7dd8e7c84c4d2ed2468a8b3b2a75166e86e420ea11999dfb92e65f17b742b -size 88654 +oid sha256:d4694112e7b0edcccac8d82041490e0e720a8c284a887a9862f999d2e41125f5 +size 85500 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.settings.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.settings.png index 46ce170ff..1a5c939f5 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.settings.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa65b3d416530492f6f5b427edce94d8cc3c7fca415b8919f92f8b19a5fb0021 -size 87559 +oid sha256:cc305f05d923778155d785b595f7a9a776e690652e84f7288559826cef18e849 +size 104989 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png index 0a5c2fe19..43a9549da 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3dc8c1d9f96740dd0589a816973a742653baa447b5a0ca37a3b63cb4ae6b8c72 -size 134309 +oid sha256:1a09f2deb866181b689891a37ac64d9b4e4c033956688149a85f637a73cc58f2 +size 274466 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomPlainNoAvatar.png index 3bf681464..d78e9caa2 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6dbdeb72f968f2cb31ae1b9e200dc5ffd0153dda4a2bd63120b9b00de1eb3602 -size 134370 +oid sha256:e3720cf84874e05deafdeab9abd8c707bd5afde6911e630df3475093a41540df +size 275253 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.sessionVerification.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.sessionVerification.png index 3e15fae14..c2b9aafb5 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.sessionVerification.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.sessionVerification.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91056e0613160fc0089bf2d34e485c25b2a579b06b741ef1b7d56a85c6df08b3 -size 111426 +oid sha256:8411ada29a2799c18ab4870561defd28069ed64c08fd2d58f1b607b8e54532b1 +size 106254 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.settings.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.settings.png index 2dd741ea0..cb0961e28 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.settings.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9aca831b2e11d2649792c0404cd5411a5f346cd6ebeba98a6899a4be1c1a1a47 -size 106872 +oid sha256:41ba1c0a3b7ae66f166daa9b3009056a2bdc43de115dd4469b543d29554606f0 +size 134952 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomEncryptedWithAvatar.png index 2fa8e1ca4..e7f1aa245 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffc574282697847ee0fea857387979cbf5b5555b15ef4de9a2d842da3a4aaf5c -size 106337 +oid sha256:e32c0e11d7a862b0c6822aae6c37a353a70b773351c00bf26d2cac54d43e346f +size 179464 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomPlainNoAvatar.png index b2ef73a95..34f7f31fe 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:214757cc447a5505480489b7bd3306621a59850012eae5d7061e68623a0e3641 -size 106490 +oid sha256:e356c25fce49d9f0ce4777f977587aa2c5767f1efa17a7db7053fa0b965bb2dc +size 179956 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.sessionVerification.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.sessionVerification.png index 1bd2122fd..329ff384d 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.sessionVerification.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.sessionVerification.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd4504a73c07f016a58885be6421d6e44b54c24dda61182874eef5fa3b072ecf -size 93546 +oid sha256:eaf1e474481bb1cf922028d01dc4a6908ebf108725f27986dd68705f2e944763 +size 90584 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.settings.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.settings.png index 55e04d3aa..78b7e6d54 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.settings.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:176b470ba2e65515fadf4bc3a6c41258fa0503d5e9b6beeb972c64f2fa2599df -size 91099 +oid sha256:2060c8d80d4934790fb2cd1f24a5efe37d0e17d1ea35776517db4ef987e93691 +size 105403 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png index c3653d898..985886383 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbd03af660d10c41794bf2a9f541a4c799cc302a1279802a9eadfa6947f8bfdd -size 134604 +oid sha256:857e89d001c41d8b715fbf6c2530f27bdeebe4fd5e73ff951758e91d05a9cbdf +size 274849 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomPlainNoAvatar.png index 86f3d4dd8..e2c427410 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79112f8b685c1e8f6a32a324b3e1e354d352d359316e41fc8eeb4f58eb082fec -size 134669 +oid sha256:8dff5bdc24963c9f64039176bcc17a98d2db9902e36c78144b1235de534cf764 +size 275646 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.sessionVerification.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.sessionVerification.png index fec801fd5..671f4e28b 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.sessionVerification.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.sessionVerification.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebd7dfa3bace1fe7c80b5c258a6a32135158f151ee7cbf455b23082c6e4604fe -size 118603 +oid sha256:46dfaec839bfeecbdfaec5d99370beb320cc54c4053b48b1c54b2b08ab2fe225 +size 112952 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.settings.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.settings.png index 1f9062fd7..0866c53ac 100644 --- a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.settings.png +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:793711e3e040940e9d778760352f3e11df9b123539f95ab3b09dcfeff8098133 -size 112048 +oid sha256:1e2679e5d9d7f780e1561d15ee0cc94f4c551cfa9556c6d474c65f4789dda915 +size 136740 diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 6fcef0797..b6220566b 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -21,12 +21,14 @@ import XCTest class HomeScreenViewModelTests: XCTestCase { var viewModel: HomeScreenViewModelProtocol! var context: HomeScreenViewModelType.Context! - + @MainActor override func setUpWithError() throws { - viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder()) + viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"), + mediaProvider: MockMediaProvider()), + attributedStringBuilder: AttributedStringBuilder()) context = viewModel.context } - + @MainActor func testSelectRoom() async throws { let mockRoomId = "mock_room_id" var correctResult = false diff --git a/UnitTests/Sources/SessionVerificationViewModelTests.swift b/UnitTests/Sources/SessionVerificationViewModelTests.swift index c40a4adac..672d994e6 100644 --- a/UnitTests/Sources/SessionVerificationViewModelTests.swift +++ b/UnitTests/Sources/SessionVerificationViewModelTests.swift @@ -47,7 +47,7 @@ class SessionVerificationViewModelTests: XCTestCase { context.send(viewAction: .start) - context.send(viewAction: .cancel) + context.send(viewAction: .close) await Task.yield() diff --git a/changelog.d/177.feature b/changelog.d/177.feature new file mode 100644 index 000000000..fc09c339a --- /dev/null +++ b/changelog.d/177.feature @@ -0,0 +1 @@ +Room: New bubbles design implementation. diff --git a/changelog.d/180.feature b/changelog.d/180.feature new file mode 100644 index 000000000..b55d26896 --- /dev/null +++ b/changelog.d/180.feature @@ -0,0 +1 @@ +Settings screen: Implement new design. diff --git a/changelog.d/181.change b/changelog.d/181.change new file mode 100644 index 000000000..011f1a87d --- /dev/null +++ b/changelog.d/181.change @@ -0,0 +1 @@ +Style the session verification banner to match Figma. diff --git a/project.yml b/project.yml index 7f07c1a20..0d9029fe4 100644 --- a/project.yml +++ b/project.yml @@ -33,7 +33,7 @@ include: packages: MatrixRustSDK: url: https://github.com/matrix-org/matrix-rust-components-swift - exactVersion: 1.0.13-alpha + exactVersion: 0.0.4-demo # path: ../matrix-rust-components-swift DesignKit: path: ./