Sliding Sync + New Timeline API (#189)

* Begin adopting new Timeline API.

* Add edited indicator and reactions.

* vector-im/element-x-ios/issues/65 - Sliding sync support
* Fix missing room display name, wrong placeholder avatar text color and various other warnings that would fail the build on the CI

* Various tweaks:
* using release version of the demo branch of the sdk
* enabled home screen last room messages
* switched debug mode rust logging to warn
* enabled redactions
* enabled new logout flows and soft logout
* enabled replies

* Fix room member display name and avatar crashes / race condition, fix unit tests
* Make the ClientProxy and the UserSession MainActors
* Remove unused MatrixRustSDK imports, we should strive to keep these only in top level services and proxies
* Don't start either of the syncs while in soft logout

* #181: Style the session verification banner to match Figma.
* #181: Update verification modal.
* #181: Update snapshot tests.

* Make session verification state machine less pedantic
* Remove unnecessary weak selfs
* Various tweaks following code review:
* add start and stop sync client proxy methods
* move ss proxy url the build settings
* made media provider load results discardable
* added publishers for the roomSummaryProvider's total number of rooms and state

* Fix when sender details are shown
* Disable sync v2, causes duplicates in the timeline (as expected)
* Move ClientProxy media loading off the main queue and into a detached task
* Another attempt at moving image loading off the main queue
* Moved home screen diffing and latest room fetching to the background
* Prevent the timeline composer from becoming the first responder when not needed

* Bump to a newer version of the RustSDK

* Fixes vector-im/element-x-ios/issues/107 - New home screen design
* Implement thumbnail loading instead of full image avatars.
* Revert "Disable sync v2, causes duplicates in the timeline (as expected)"
* Add support for local echoes, dispatching detached tasks to a concurrenc GCD queue
* Move the session verification banner to a List Section to avoid UI glitches
* Optimise room mapping after sliding sync updates and thumbnail fetching
* Replace home screen List with a LazyVStack in an attempt to fix performance. Moved move summary provider room updating to a background thread

* Fixes vector-im/element-x-ios/issues/177 - New Bubbles Design
* Define in group state for timeline items
* Add replies into the bubble
* Add timeline width environment value
* Add `RoundedCorner` shape with specific corners rounding
* Add in group state for previews
* Implement bubble grouping logic
* Timeline avatar layout changes
* Fix placeholder avatars for dark mode
* New bubbles design
* Update mock timeline items
* Update timeline separator design
* Update room screen reference screenshots
* Add changelog
* Formatting fixes
* Add some space before single or beginning outgoing items

* Redesign the message composer

* Handle the msgtype enum.

* Update room name label line limit and incoming bubble background. Disabled syncv2, ss withCommonExtensions and session verification controller checking

* Increase default back pagination limit.
* Stop parsing links and tidy up composer button.
* Also fix the frame of an image whilst loading.
* Bump SDK package version.

* Remove app states about settings
* Add strings
* Use colors on placeholder avatars
* Tiny changes for placeholder avatars
* Update settings screen design
* Provide a user display name from the mock client
* Settings screen presentation logic
* Add changelog
* Update reference screenshots

Co-authored-by: Doug <douglase@element.io>
Co-authored-by: ismailgulek <ismailgulek@users.noreply.github.com>
Co-authored-by: ismailgulek <ismailg@matrix.org>
This commit is contained in:
Stefan Ceriu 2022-09-21 11:21:58 +03:00 committed by GitHub
parent a88491be1a
commit 5ebe923991
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
124 changed files with 2502 additions and 1838 deletions

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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 = "<group>"; };
095AED4CF56DFF3EB7BB84C8 /* RoomTimelineProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderProtocol.swift; sourceTree = "<group>"; };
0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToggleStyle.swift; sourceTree = "<group>"; };
09747989908EC5E4AA29F844 /* MemberDetailsProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailsProviderProtocol.swift; sourceTree = "<group>"; };
0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
0AB7A0C06CB527A1095DEB33 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = da; path = da.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
0B869438A1B52836F912A702 /* MockSoftLogoutScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftLogoutScreenState.swift; sourceTree = "<group>"; };
@ -412,6 +409,7 @@
105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = "<group>"; };
105D16E7DB0CCE9526612BDD /* bn-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-IN"; path = "bn-IN.lproj/Localizable.strings"; sourceTree = "<group>"; };
109C0201D8CB3F947340DC80 /* WeakDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionary.swift; sourceTree = "<group>"; };
10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderProtocol.swift; sourceTree = "<group>"; };
111B698739E3410E2CDB7144 /* MXLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MXLog.swift; sourceTree = "<group>"; };
113356152C099951A6D17D85 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
1215A4FC53D2319E81AE8970 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@ -470,7 +468,6 @@
33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = "<group>"; };
351E89CE2ED9B73C5CC47955 /* TimelineReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReactionsView.swift; sourceTree = "<group>"; };
35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
36322DD0D4E29D31B0945ADC /* EventBriefFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBriefFactory.swift; sourceTree = "<group>"; };
3747C96188856006F784BF49 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ko; path = ko.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3782C506F4FF1AADF61B6212 /* tlh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tlh; path = tlh.lproj/Localizable.strings; sourceTree = "<group>"; };
398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadableFrameModifier.swift; sourceTree = "<group>"; };
@ -492,11 +489,9 @@
3FAA6438B00FDB130F404E31 /* UserIndicatorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorStore.swift; sourceTree = "<group>"; };
3FEE631F3A4AFDC6652DD9DA /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = "<group>"; };
40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
4110685D9CA159F3FD2D6BA1 /* TextRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomMessage.swift; sourceTree = "<group>"; };
4112D04077F6709C5CA0A13E /* FullscreenLoadingViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenLoadingViewPresenter.swift; sourceTree = "<group>"; };
422724361B6555364C43281E /* RoomHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHeaderView.swift; sourceTree = "<group>"; };
434522ED2BDED08759048077 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = "<group>"; };
4411C0DA0087A1CB143E96FA /* EventBrief.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBrief.swift; sourceTree = "<group>"; };
4470B8CB654B097D807AA713 /* ToastViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewPresenter.swift; sourceTree = "<group>"; };
4488F5F92A64A137665C96CD /* pa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pa; path = pa.lproj/Localizable.strings; sourceTree = "<group>"; };
44AEEE13AC1BF303AE48CBF8 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -572,7 +567,6 @@
68232D336E2B546AD95B78B5 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = "<group>"; };
68706A66BBA04268F7747A2F /* ActivityIndicatorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorPresenter.swift; sourceTree = "<group>"; };
6920A4869821BF72FFC58842 /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = "<group>"; };
6A152791A2F56BD193BFE986 /* MemberDetailsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailsProvider.swift; sourceTree = "<group>"; };
6A1AAC8EB2992918D01874AC /* rue */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = rue; path = rue.lproj/Localizable.strings; sourceTree = "<group>"; };
6A6C4BE591FE5C38CE9C7EF3 /* UserProperties+Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProperties+Element.swift"; sourceTree = "<group>"; };
6A901D95158B02CA96C79C7F /* InfoPlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPlist.swift; sourceTree = "<group>"; };
@ -607,6 +601,7 @@
7E154FEA1E6FE964D3DF7859 /* fy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fy; path = fy.lproj/Localizable.strings; sourceTree = "<group>"; };
7E532D95330139D118A9BF88 /* BugReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModel.swift; sourceTree = "<group>"; };
7FFCC48E7F701B6C24484593 /* WeakDictionaryKeyReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionaryKeyReference.swift; sourceTree = "<group>"; };
8023C7413A426FBF0A52B684 /* RoundedCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorner.swift; sourceTree = "<group>"; };
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = "<group>"; };
8140010A796DB2C7977B6643 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
8166F121C79C7B62BF01D508 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = pt; path = pt.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@ -626,16 +621,15 @@
892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = "<group>"; };
8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = "<group>"; };
8BF686BA36D0C2FA3C63DFDF /* ImageRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomMessage.swift; sourceTree = "<group>"; };
8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailProviderManager.swift; sourceTree = "<group>"; };
8C37FB986891D90BEAA93EAE /* UserSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStore.swift; sourceTree = "<group>"; };
8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = "<group>"; };
8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = "<group>"; };
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = "<group>"; };
9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = "<group>"; };
90733775209F4D4D366A268F /* RootRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootRouterType.swift; sourceTree = "<group>"; };
92B61C243325DC76D3086494 /* EventBriefFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBriefFactoryProtocol.swift; sourceTree = "<group>"; };
92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = "<group>"; };
9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = "<group>"; };
938BD1FCD9E6FF3FCFA7AB4C /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-CN"; path = "zh-CN.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
@ -655,6 +649,7 @@
997783054A2E95F9E624217E /* kaa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kaa; path = kaa.lproj/Localizable.strings; sourceTree = "<group>"; };
99DE232F24EAD72A3DF7EF1A /* kab */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = kab; path = kab.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportServiceProtocol.swift; sourceTree = "<group>"; };
9ABAECB0CA5FF8F8E6F10DD7 /* RoomTimelineProviderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderItem.swift; sourceTree = "<group>"; };
9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMeasurementParser.swift; sourceTree = "<group>"; };
9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
A2B6433F516F1E6DFA0E2D89 /* vls */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vls; path = vls.lproj/Localizable.strings; sourceTree = "<group>"; };
A30A1758E2B73EF38E7C42F8 /* ServerSelectionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionModels.swift; sourceTree = "<group>"; };
A40C19719687984FD9478FBE /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
A436057DBEA1A23CA8CB1FD7 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = "<group>"; };
A443FAE2EE820A5790C35C8D /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = "<group>"; };
@ -680,6 +676,7 @@
A8D1CC633517D695FEC54208 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = "<group>"; };
A8F48EB9B52E70285A4BCB07 /* ur */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ur; path = ur.lproj/Localizable.strings; sourceTree = "<group>"; };
A9873374E72AA53260AE90A2 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = "<group>"; };
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = "<group>"; };
AA8BA82CF99D843FEF680E91 /* AnalyticsPromptModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptModels.swift; sourceTree = "<group>"; };
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 = "<group>"; };
B64F3A3D0DF86ED5A241AB05 /* ActivityIndicatorView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActivityIndicatorView.xib; sourceTree = "<group>"; };
B695D0D12086158BAD1D9859 /* UserIndicatorPresenterSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorPresenterSpy.swift; sourceTree = "<group>"; };
B6BDAC8895AB2B77B47703AE /* MockRoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummary.swift; sourceTree = "<group>"; };
B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
B7E035C6AC137C9392D98814 /* lv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lv; path = lv.lproj/Localizable.strings; sourceTree = "<group>"; };
B80D1901BA0B095E27793EDE /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -728,7 +724,6 @@
C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = "<group>"; };
C06FCD42EEFEFC220F14EAC5 /* SessionVerificationStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationStateMachine.swift; sourceTree = "<group>"; };
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = "<group>"; };
C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProtocol.swift; sourceTree = "<group>"; };
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = "<group>"; };
C483956FA3D665E3842E319A /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = "<group>"; };
@ -744,12 +739,12 @@
CACA846B3E3E9A521D98B178 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
CBA95E52C4C6EE8769A63E57 /* eo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eo; path = eo.lproj/Localizable.strings; sourceTree = "<group>"; };
CBBCC6E74774E79B599625D0 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
CBDA364DFFC3AC71C4771251 /* NoticeRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomMessage.swift; sourceTree = "<group>"; };
CBF9AEA706926DD0DA2B954C /* JoinedRoomSize+MemberCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JoinedRoomSize+MemberCount.swift"; sourceTree = "<group>"; };
CC680E0E79D818706CB28CF8 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
CC7CCC6DE5FA623E31BA8546 /* RoomTimelineControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerProtocol.swift; sourceTree = "<group>"; };
CCA431E6EDD71F7067B5F9E7 /* UITestsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsRootView.swift; sourceTree = "<group>"; };
CCF86010A0A719A9A50EEC59 /* SessionVerificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationCoordinator.swift; sourceTree = "<group>"; };
CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = "<group>"; };
CDE3F3911FF7CC639BDE5844 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
CED34C87277BA3CCC6B6EC7A /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
CF3EDF23226895776553F04A /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
@ -796,10 +791,12 @@
E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = "<group>"; };
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = "<group>"; };
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = "<group>"; };
EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = "<group>"; };
EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTimelineItem.swift; sourceTree = "<group>"; };
EEE384418EB1FEDFA62C9CD0 /* RoomTimelineViewFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactoryProtocol.swift; sourceTree = "<group>"; };
EF1593DD87F974F8509BB619 /* ElementAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementAnimations.swift; sourceTree = "<group>"; };
EF188681D6B6068CFAEAFC3F /* MXLogger.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MXLogger.m; sourceTree = "<group>"; };
@ -822,7 +819,6 @@
FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = "<group>"; };
FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = "<group>"; };
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = "<group>"; };
FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomMessage.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -1009,6 +1005,7 @@
isa = PBXGroup;
children = (
0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */,
8023C7413A426FBF0A52B684 /* RoundedCorner.swift */,
);
path = Views;
sourceTree = "<group>";
@ -1024,16 +1021,6 @@
path = Generated;
sourceTree = "<group>";
};
33996F58948B54839D653EC1 /* Members */ = {
isa = PBXGroup;
children = (
8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */,
6A152791A2F56BD193BFE986 /* MemberDetailsProvider.swift */,
09747989908EC5E4AA29F844 /* MemberDetailsProviderProtocol.swift */,
);
path = Members;
sourceTree = "<group>";
};
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 = "<group>";
@ -1186,6 +1170,7 @@
isa = PBXGroup;
children = (
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */,
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */,
);
path = View;
sourceTree = "<group>";
@ -1228,6 +1213,7 @@
isa = PBXGroup;
children = (
F15BE37BE2FB86E00C8D150A /* AggregratedReaction.swift */,
EEAF1C75771D9DC75877F4B4 /* MessageTimelineItem.swift */,
);
path = TimeLineItemContent;
sourceTree = "<group>";
@ -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 = "<group>";
@ -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" */ = {

View File

@ -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"
}
},
{

View File

@ -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 youre 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!";

View File

@ -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 }
@ -172,11 +171,6 @@ class AppCoordinator: Coordinator {
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):
@ -192,8 +186,6 @@ class AppCoordinator: Coordinator {
}
}
// swiftlint:enable cyclomatic_complexity function_body_length
private func restoreUserSession() {
Task {
switch await userSessionStore.restoreUserSession() {
@ -265,10 +257,12 @@ class AppCoordinator: Coordinator {
}
private func tearDownUserSession(isSoftLogout: Bool = false) {
Task {
userSession.clientProxy.stopSync()
deobserveUserSessionChanges()
if !isSoftLogout {
Task {
// first log out from the server
_ = await userSession.clientProxy.logout()
@ -276,11 +270,11 @@ class AppCoordinator: Coordinator {
userSessionStore.logout(userSession: userSession)
userSession = nil
}
}
// complete logging out
stateMachine.processEvent(.completedSigningOut)
}
}
private func presentSplashScreen(isSoftLogout: Bool = false) {
if let presentedCoordinator = childCoordinators.first {
@ -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,29 +348,27 @@ 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)
@ -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)
}

View File

@ -36,9 +36,6 @@ 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<State, Event>
@ -98,9 +90,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])

View File

@ -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

View File

@ -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 youre 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

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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<Success, Failure> {
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<Success, Failure> {
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)
}
}
}
}
}
}

View File

@ -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))
}
}

View File

@ -14,7 +14,6 @@
// limitations under the License.
//
import MatrixRustSDK
import UIKit
@MainActor

View File

@ -15,7 +15,6 @@
//
import AppAuth
import MatrixRustSDK
import SwiftUI
struct LoginCoordinatorParameters {

View File

@ -15,7 +15,6 @@
//
import AppAuth
import MatrixRustSDK
import SwiftUI
struct SoftLogoutCoordinatorParameters {

View File

@ -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<AnyCancellable>()
// 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,38 +70,6 @@ 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
@ -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:

View File

@ -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 }
if bindings.searchQuery.isEmpty {
return rooms
}
private var searchFilteredRooms: LazyFilterSequence<LazySequence<[HomeScreenRoom]>.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 }
}
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 {

View File

@ -20,29 +20,54 @@ import SwiftUI
typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, HomeScreenViewAction>
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
private let userSession: UserSessionProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
private var roomUpdateListeners = Set<AnyCancellable>()
private var roomsUpdateTask: Task<Void, Never>? {
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)
}
}
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() {
case .skipSessionVerification:
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() }
if room.avatar != nil {
return
}
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 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
}
room.avatar = roomSummary.avatar
room.displayName = roomSummary.displayName
room.topic = roomSummary.topic
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
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)
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
}
}

View File

@ -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()
}

View File

@ -29,36 +29,20 @@ struct HomeScreen: View {
ProgressView()
}
} else {
ScrollView {
if context.viewState.showSessionVerificationBanner {
HStack {
Text(ElementL10n.verificationVerifyDevice)
Spacer()
Button(ElementL10n.startVerification) {
context.send(viewAction: .verifySession)
}
}
.padding()
.background(Color.element.quaternaryContent)
.padding(.top, 1)
sessionVerificationBanner
}
List {
Section(ElementL10n.rooms) {
LazyVStack {
ForEach(context.viewState.visibleRooms) { room in
RoomCell(room: room, context: context)
.listRowBackground(Color.clear)
HomeScreenRoomCell(room: room, context: context)
}
}
Section(ElementL10n.bottomActionPeople) {
ForEach(context.viewState.visibleDMs) { room in
RoomCell(room: room, context: context)
.listRowBackground(Color.clear)
}
}
}
.listStyle(.plain)
.searchable(text: $context.searchQuery)
.animation(.default, value: context.viewState.visibleRooms)
.padding(.horizontal)
}
}
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
@ -120,6 +104,37 @@ struct HomeScreen: View {
}
}
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 {
@ -204,26 +163,11 @@ struct HomeScreen_Previews: PreviewProvider {
}
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)

View File

@ -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)
}
}
}
}

View File

@ -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

View File

@ -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
}

View File

@ -20,7 +20,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let backPaginationPageSize: UInt = 30
static let backPaginationPageSize: UInt = 50
}
private let timelineController: RoomTimelineControllerProtocol
@ -36,7 +36,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomId,
roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: roomAvatar,
roomEncryptionBadge: roomEncryptionBadge,
bindings: .init(composerText: "", composerFocused: false)))
@ -106,19 +107,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
fatalError("This message should never be empty")
}
state.messageComposerDisabled = true
switch state.composerMode {
case .reply(let itemId, _):
await timelineController.sendReply(state.bindings.composerText, to: itemId)
default:
await timelineController.sendMessage(state.bindings.composerText)
}
let message = state.bindings.composerText
state.bindings.composerText = ""
state.composerMode = .default
state.messageComposerDisabled = false
switch state.composerMode {
case .reply(let itemId, _):
await timelineController.sendReply(message, to: itemId)
default:
await timelineController.sendMessage(message)
}
}
private func displayError(_ type: RoomScreenErrorType) {
@ -144,14 +143,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return []
}
let actions: [TimelineItemContextMenuAction] = [
var actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink, .reply
]
#warning("Outgoing actions to be handled with the new Timeline API.")
// if timelineItem.isOutgoing {
// actions.append(.redact)
// }
if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isOutgoing {
actions.append(.redact)
}
return actions
}

View File

@ -26,46 +26,48 @@ struct MessageComposer: View {
let replyCancellationAction: () -> Void
var body: some View {
HStack(alignment: .bottom) {
let rect = RoundedRectangle(cornerRadius: 8.0)
VStack(alignment: .leading, spacing: 2.0) {
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)
}
.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)
Image(systemName: "paperplane")
.font(.element.title3)
.foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground)
.padding(8.0)
.background(
Circle()
.foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground)
)
}
.padding(.bottom, 6.0)
.disabled(sendingDisabled)
.opacity(sendingDisabled ? 0.5 : 1.0)
.animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.padding(4.0)
}
}
.padding(.leading, 12.0)
.background(.thinMaterial)
.clipShape(rect)
.animation(.elementDefault, value: type)
}
private var borderColor: Color {
.element.accent
private var borderRadius: CGFloat {
switch type {
case .default:
return 28.0
case .reply:
return 12.0
}
private var borderWidth: CGFloat {
focused ? 2.0 : 1.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)
}
}

View File

@ -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)
DispatchQueue.main.async { // Avoid cycle detected through attribute warnings
if focused, textView.window != nil, !textView.isFirstResponder {
// Avoid cycle detected through attribute warnings
DispatchQueue.main.async {
textView.becomeFirstResponder()
}
}

View File

@ -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")
}
}

View File

@ -33,7 +33,6 @@ struct RoomScreen: View {
context.send(viewAction: .cancelReply)
}
.padding()
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

@ -24,25 +24,30 @@ struct TimelineItemBubbledStylerView<Content: View>: 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)
}
VStack(alignment: alignment) {
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContentWithReactions
}
.padding(.trailing, 16)
.padding(.leading, 51)
.padding(.leading, 16)
} else {
styledContentWithReactions
.padding(.leading, 16)
.padding(.trailing, 51)
.padding(.leading, 24)
.padding(.trailing, 24)
}
}
}
}
@ -56,10 +61,10 @@ struct TimelineItemBubbledStylerView<Content: View>: 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<Content: View>: 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<Content: View>: View {
@ViewBuilder
var styledContent: some View {
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)
if timelineItem.isOutgoing {
styledContentOutgoing
} else {
styledContentIncoming
}
}
@ViewBuilder
var styledContentOutgoing: some View {
if timelineItem.inGroupState == .single || timelineItem.inGroupState == .beginning {
Spacer()
.frame(height: 8)
}
if shouldAvoidBubbling {
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,10 +117,36 @@ struct TimelineItemBubbledStylerView<Content: View>: 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)
// )
}
}
@ -119,14 +154,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
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)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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.")
}
}
}

View File

@ -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()
}
.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")
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
.frame(maxHeight: 1000.0)
} else {
TimelineStyler(timelineItem: timelineItem) {
HStack {
Spacer()
ProgressView("Loading")
Spacer()
}
}
.id(timelineItem.id)
}
}
}
@ -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,

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -29,25 +29,25 @@ struct TimelineItemList: View {
let bottomVisiblePublisher: PassthroughSubject<Bool, Never>
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
@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()
.frame(maxWidth: .infinity)
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
Spacer()
}
.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()
})
}
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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:
@ -124,8 +103,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
func processEvent(_ event: Event) {

View File

@ -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)")
}
}

View File

@ -14,7 +14,6 @@
// limitations under the License.
//
import MatrixRustSDK
import SwiftUI
struct SessionVerificationScreen: View {
@ -24,32 +23,48 @@ struct SessionVerificationScreen: View {
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 32.0) {
Text(heading)
if let title = context.viewState.title {
Text(title)
.font(.element.headlineBold)
.foregroundColor(.element.systemPrimaryLabel)
.multilineTextAlignment(.center)
}
Text(context.viewState.message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
.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(.stack)
}
// MARK: - Private
@ViewBuilder
private var mainContent: some View {
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")
@ -64,87 +79,76 @@ struct SessionVerificationScreen: View {
.accessibilityIdentifier("decliningChallengeProgressView")
case .showingChallenge(let emojis):
HStack(spacing: 8.0) {
HStack(spacing: 16) {
ForEach(emojis.prefix(4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
HStack(spacing: 8.0) {
HStack(spacing: 16) {
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
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")
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
// MARK: - Private
private var heading: String {
@ViewBuilder
private var actionButtons: 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
case .cancelled:
return ElementL10n.verificationCancelled
}
Button(ElementL10n.startVerification) {
context.send(viewAction: .start)
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("startButton")
private var actionButtons: some View {
HStack(spacing: 16.0) {
Button(ElementL10n.verificationSasDoNotMatch) {
case .cancelled:
Button(ElementL10n.globalRetry) {
context.send(viewAction: .restart)
}
.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)
}
.buttonStyle(.elementAction(.regular, color: .red))
.font(.element.bodyBold)
.accessibilityLabel("challengeDeclineButton")
}
Button(ElementL10n.verificationSasMatch) {
context.send(viewAction: .accept)
case .verified:
Button(ElementL10n.finish) {
context.send(viewAction: .close)
}
.buttonStyle(.elementAction(.regular))
.accessibilityLabel("challengeAcceptButton")
.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)
@ -192,6 +198,7 @@ struct SessionVerification_Previews: PreviewProvider {
sessionVerificationScreen(state: .showingChallenge(emojis: MockSessionVerificationControllerProxy.emojis))
sessionVerificationScreen(state: .verified)
}
.tint(Color.element.accent)
}
static func sessionVerificationScreen(state: SessionVerificationStateMachine.State) -> some View {

View File

@ -23,6 +23,7 @@ struct SettingsCoordinatorParameters {
}
enum SettingsCoordinatorAction {
case dismiss
case logout
}
@ -39,6 +40,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
// Must be used only internally
@ -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,13 +111,26 @@ 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:
/// - label: The label to show on the indicator.

View File

@ -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

View File

@ -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:

View File

@ -19,10 +19,13 @@ 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
@ObservedObject var context: SettingsViewModel.Context
@ -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 analyticsSection: some View {
Section(ElementL10n.settingsAnalytics) {
Button { context.send(viewAction: .reportBug) } label: {
Text(ElementL10n.sendBugReport)
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 {
Button { context.send(viewAction: .reportBug) } label: {
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: {
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)
}
.frame(maxWidth: .infinity)
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("logoutButton")
.confirmationDialog(ElementL10n.actionSignOutConfirmationSimple,
isPresented: $showingLogoutConfirmation,
titleVisibility: .visible) {
Button(ElementL10n.actionSignOut,
role: .destructive) { context.send(viewAction: .logout)
}
}
} 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()
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@userid:example.com"),
mediaProvider: MockMediaProvider())
let viewModel = SettingsViewModel(withUserSession: userSession)
return NavigationView {
SettingsScreen(context: viewModel.context)
.previewInterfaceOrientation(.portrait)
}
}
}

View File

@ -108,13 +108,10 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
let loginTask: Task<Client, Error> = 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 {

View File

@ -16,7 +16,6 @@
import Foundation
import GZIP
import MatrixRustSDK
import Sentry
import UIKit

View File

@ -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<ClientProxyCallback, Never>()
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<String, ClientProxyError> {
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)
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<SessionVerificationControllerProxyProtocol, ClientProxyError> {
@ -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)
}
}

View File

@ -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<ClientProxyCallback, Never> { 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<String, ClientProxyError>
@ -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<SessionVerificationControllerProxyProtocol, ClientProxyError>

View File

@ -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<String, ClientProxyError> {
.failure(.failedRetrievingDisplayName)
.success("User display name")
}
func loadUserAvatarURLString() async -> Result<String, ClientProxyError> {
@ -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
}

View File

@ -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<UIImage, MediaProviderError> {
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)))
func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size)
}
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
if let image = imageFromSource(source) {
func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
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<UIImage, MediaProviderError> 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 {

View File

@ -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<UIImage, MediaProviderError>
@discardableResult func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError>
func imageFromURLString(_ urlString: String?) -> UIImage?
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage?
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError>
@discardableResult func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError>
}
extension MediaProviderProtocol {
func imageFromSource(_ source: MediaSource?) -> UIImage? {
imageFromSource(source, size: nil)
}
@discardableResult func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(source, size: nil)
}
func imageFromURLString(_ urlString: String?) -> UIImage? {
imageFromURLString(urlString, size: nil)
}
@discardableResult func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
await loadImageFromURLString(urlString, size: nil)
}
}

View File

@ -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<UIImage, MediaProviderError> {
func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
.failure(.failedRetrievingImage)
}
func imageFromURLString(_ urlString: String?) -> UIImage? {
nil
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? {
if urlString != nil {
return UIImage(systemName: "photo")
}
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
return nil
}
func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
.failure(.failedRetrievingImage)
}
}

View File

@ -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
}
}

View File

@ -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<String?, MemberDetailProviderError> {
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<String?, MemberDetailProviderError> {
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)
}
}
}

View File

@ -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<String?, MemberDetailProviderError>
func displayNameForUserId(_ userId: String) -> String?
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError>
}

View File

@ -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()))
}
}

View File

@ -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()
}
}

View File

@ -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()))
}
}

View File

@ -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<RoomProxyCallback, Never>()
let timelineProvider: RoomTimelineProviderProtocol = MockRoomTimelineProvider()
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
.failure(.failedRetrievingMemberDisplayName)
}
func avatarURLStringForUserId(_ userId: String) -> String? {
nil
}
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
.failure(.failedRetrievingMemberAvatarURL)
}
func displayNameForUserId(_ userId: String) -> String? {
nil
}
func loadDisplayName() async -> Result<String, RoomProxyError> {
.failure(.failedRetrievingDisplayName)
}
func startLiveEventListener() { }
func addTimelineListener(listener: TimelineListener) { }
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
.failure(.backwardStreamNotAvailable)
.failure(.failedPaginatingBackwards)
}
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {

View File

@ -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)
}
}

View File

@ -18,5 +18,5 @@ import Foundation
import MatrixRustSDK
protocol RoomMessageFactoryProtocol {
func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol
func buildRoomMessageFrom(_ message: EventTimelineItem) -> RoomMessageProtocol
}

View File

@ -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<RoomProxyCallback, Never>()
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<String?, RoomProxyError> {
await Task.detached { () -> Result<String?, RoomProxyError> 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<String?, RoomProxyError> {
await Task.detached { () -> Result<String?, RoomProxyError> 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<String, RoomProxyError> {
await Task.detached { () -> Result<String, RoomProxyError> 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
}
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
await Task.detached { () -> Result<Void, RoomProxyError> in
guard let backwardStream = self.backwardStream else {
return .failure(RoomProxyError.backwardStreamNotAvailable)
private func addTimelineListener(listener: TimelineListener) {
room.addTimelineListener(listener: listener)
}
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)")
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
guard backPaginationOutcome?.moreMessages != false else {
return .failure(.noMoreMessagesToBackPaginate)
}
let messages = sdkMessages.map { message in
self.roomMessageFactory.buildRoomMessageFrom(message)
}.reversed()
self.messages.insert(contentsOf: messages, at: 0)
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)
}
}
.value
}
@ -183,45 +184,49 @@ class RoomProxy: RoomProxyProtocol {
let transactionId = genTransactionId()
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> 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 {
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<Void, RoomProxyError> {
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<Void, RoomProxyError> {
#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
}
}

View File

@ -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<String?, RoomProxyError>
func displayNameForUserId(_ userId: String) -> String?
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
func loadDisplayName() async -> Result<String, RoomProxyError>
@ -58,8 +61,6 @@ protocol RoomProxyProtocol {
func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}
extension RoomProxyProtocol {

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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<RoomSummaryCallback, Never>()
}

View File

@ -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<RoomSummaryProviderCallback, Never>()
var stateUpdatePublisher = CurrentValueSubject<RoomSummaryProviderState, Never>(.cold)
var countUpdatePublisher = CurrentValueSubject<UInt, Never>(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]) { }
}

View File

@ -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<AnyCancellable>()
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<RoomSummaryCallback, Never>()
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
}

View File

@ -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<RoomSummaryCallback, Never> { get }
func loadDetails() async
}

View File

@ -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<SlidingSyncViewRoomsListDiff, Never>()
/// Publishes the current state of sliding sync, such as whether its catching up or live.
let stateUpdatePublisher = CurrentValueSubject<SlidingSyncState, Never>(.cold)
/// Publishes the number of available rooms
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(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<AnyCancellable>()
let callbacks = PassthroughSubject<RoomSummaryProviderCallback, Never>()
let stateUpdatePublisher = CurrentValueSubject<RoomSummaryProviderState, Never>(.cold)
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(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<RoomSummary>.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<RoomSummary>? {
// Invalidations are a no-op for the moment
if diff.isInvalidation {
return nil
}
var changes = [CollectionDifference<RoomSummary>.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
}
}
}

View File

@ -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<RoomSummaryProviderCallback, Never> { get }
/// Publishes the current state the summary provider is finding itself in
var stateUpdatePublisher: CurrentValueSubject<RoomSummaryProviderState, Never> { get }
/// Publishes the total number of rooms
var countUpdatePublisher: CurrentValueSubject<UInt, Never> { 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])
}

View File

@ -19,7 +19,6 @@ import Combine
struct MockUserSession: UserSessionProtocol {
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil
var userID: String { clientProxy.userIdentifier }
var isSoftLogout: Bool { clientProxy.isSoftLogout }
var deviceId: String? { clientProxy.deviceId }

View File

@ -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.")
}
}
}

View File

@ -24,6 +24,7 @@ enum UserSessionCallback {
case updateRestoreTokenNeeded
}
@MainActor
protocol UserSessionProtocol {
var userID: String { get }
var isSoftLogout: Bool { get }

View File

@ -22,39 +22,74 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString,
var timelineItems: [RoomTimelineItemProtocol] = [
SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You rock!",
text: "That looks so good!",
timestamp: "10:10 AM",
shouldShowSenderDetails: true,
inGroupState: .single,
isOutgoing: false,
senderId: "",
senderDisplayName: "Some user with a really long long long long long display name",
senderDisplayName: "Jacob",
properties: RoomTimelineItemProperties(isEdited: true)),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You also rule!",
text: "Lets get lunch soon! New salad place opened up 🥗. When are yall free? 🤗",
timestamp: "10:11 AM",
shouldShowSenderDetails: false,
shouldShowSenderDetails: true,
inGroupState: .beginning,
isOutgoing: false,
senderId: "",
senderDisplayName: "Alice",
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: "You too!",
text: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Heres the menu, let me know what you want its 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<Void, RoomTimelineControllerError> {
.failure(.generic)

View File

@ -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<RoomTimelineProviderCallback, Never>()
var items = [RoomTimelineProviderItem]()
init(message: MatrixRustSDK.NoticeMessage) {
self.message = message
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedPaginatingBackwards)
}
var id: String {
message.baseMessage().id()
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
.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<Void, RoomTimelineProviderError> {
.failure(.failedRedactingItem)
}
}

View File

@ -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<AnyCancellable>()
private var timelineItemsUpdateTask: Task<Void, Never>? {
@ -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
@ -134,28 +134,33 @@ 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 areMessagesFromTheSameDay = haveSameDay(lhs: previousMessage, rhs: message)
let shouldAddSectionHeader = !areMessagesFromTheSameDay
let previousItem = timelineProvider.items[safe: index - 1]
let nextItem = timelineProvider.items[safe: index + 1]
if shouldAddSectionHeader {
newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
text: message.originServerTs.formatted(date: .complete, time: .omitted)))
let inGroupState = inGroupState(for: item, previousItem: previousItem, nextItem: nextItem)
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 {
@ -167,12 +172,49 @@ 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 }),

View File

@ -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<RoomTimelineProviderCallback, Never>()
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<Void, RoomTimelineProviderError> {
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()
}
}

View File

@ -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
}
}
}

View File

@ -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<RoomTimelineProviderCallback, Never> { get }
var messages: [RoomMessageProtocol] { get }
var items: [RoomTimelineProviderItem] { get }
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>

View File

@ -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<Content: MessageContentProtocol> {
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
}
}

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<TextMessageContent>,
_ 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<ImageMessageContent>,
_ 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<NoticeMessageContent>,
_ 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<EmoteMessageContent>,
_ 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)
}
}
}

View File

@ -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
}

View File

@ -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())

View File

@ -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()
}
}

Some files were not shown because too many files have changed in this diff Show More