Reverting to TableView but with SwiftUI compatibility (#1407)

* revert but also allows us to keep the existing SwiftUI implementation if we need it

* code improvement

* pr review

* FF is volatile

* message that communicates that is volatile
This commit is contained in:
Mauro 2023-07-26 15:53:43 +02:00 committed by GitHub
parent ba6ad3236f
commit 99b5bf5150
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 497 additions and 37 deletions

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 51;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -480,6 +480,8 @@
A680F54935A6ADEA4ED6C38F /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4C9547BBFEEF30AA11329B /* TimelineItemStatusView.swift */; };
A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; };
A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; };
A70DACFC2A7146D2007F184C /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70DACFB2A7146D2007F184C /* TimelineTableViewController.swift */; };
A70DACFE2A7146DE007F184C /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70DACFD2A7146DE007F184C /* UITimelineView.swift */; };
A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; };
A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; };
A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; };
@ -866,7 +868,7 @@
127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.swift; sourceTree = "<group>"; };
12EDAFB64FA5F6812D54F39A /* MigrationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenViewModel.swift; sourceTree = "<group>"; };
12F1E7F9C2BE8BB751037826 /* WaitlistScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenCoordinator.swift; sourceTree = "<group>"; };
1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; path = IntegrationTests.xctestplan; sourceTree = "<group>"; };
1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = IntegrationTests.xctestplan; sourceTree = "<group>"; };
130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNTextInputNotificationResponse+Creator.swift"; sourceTree = "<group>"; };
13802897C7AFA360EA74C0B0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
1423AB065857FA546444DB15 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
@ -1007,7 +1009,7 @@
47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = "<group>"; };
471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineView.swift; sourceTree = "<group>"; };
47873756E45B46683D97DC32 /* LegalInformationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenModels.swift; sourceTree = "<group>"; };
478BE8591BD13E908EF70C0C /* DesignKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = DesignKit; path = DesignKit; sourceTree = SOURCE_ROOT; };
478BE8591BD13E908EF70C0C /* DesignKit */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DesignKit; sourceTree = SOURCE_ROOT; };
4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramePreferenceKey.swift; sourceTree = "<group>"; };
47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModel.swift; sourceTree = "<group>"; };
@ -1189,7 +1191,7 @@
8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilder.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>"; };
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = "<group>"; };
8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenModels.swift; sourceTree = "<group>"; };
8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = "<group>"; };
8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = "<group>"; };
@ -1255,6 +1257,8 @@
A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = "<group>"; };
A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = "<group>"; };
A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = "<group>"; };
A70DACFB2A7146D2007F184C /* TimelineTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
A70DACFD2A7146DE007F184C /* UITimelineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITimelineView.swift; sourceTree = "<group>"; };
A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; };
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; };
@ -1299,7 +1303,7 @@
B4CFE236419E830E8946639C /* Analytics+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Analytics+SwiftUI.swift"; sourceTree = "<group>"; };
B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = "<group>"; };
B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = "<group>"; };
B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; path = ConfettiScene.scn; sourceTree = "<group>"; };
B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; path = ConfettiScene.scn; sourceTree = "<group>"; };
B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = "<group>"; };
B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenViewModelProtocol.swift; sourceTree = "<group>"; };
B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
@ -1380,7 +1384,7 @@
CD6B0C4639E066915B5E6463 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = "<group>"; };
CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = "<group>"; };
CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = "<group>"; };
CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTests.xctestplan; sourceTree = "<group>"; };
CF48AF076424DBC1615C74AD /* AuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxy.swift; sourceTree = "<group>"; };
D0140615D2232612C813FD6C /* EncryptedHistoryRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedHistoryRoomTimelineItem.swift; sourceTree = "<group>"; };
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
@ -1454,7 +1458,7 @@
ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; 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>"; };
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = "<group>"; };
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = "<group>"; };
ED983D4DCA5AFA6E1ED96099 /* StateRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineView.swift; sourceTree = "<group>"; };
EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = "<group>"; };
EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = "<group>"; };
@ -1468,7 +1472,7 @@
F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenModels.swift; sourceTree = "<group>"; };
F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = "<group>"; };
F1B8500C152BC59445647DA8 /* UnsupportedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineItem.swift; sourceTree = "<group>"; };
F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */ = {isa = PBXFileReference; path = portrait_test_video.mp4; sourceTree = "<group>"; };
F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = portrait_test_video.mp4; sourceTree = "<group>"; };
F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixEntityRegexTests.swift; sourceTree = "<group>"; };
F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = "<group>"; };
F36C0A6D59717193F49EA986 /* UserSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionTests.swift; sourceTree = "<group>"; };
@ -3209,6 +3213,8 @@
F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */,
A2AC3C656E960E15B5905E05 /* UnsupportedRoomTimelineView.swift */,
1941C8817E6B6971BA4415F5 /* VideoRoomTimelineView.swift */,
A70DACFD2A7146DE007F184C /* UITimelineView.swift */,
A70DACFB2A7146D2007F184C /* TimelineTableViewController.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -4535,6 +4541,7 @@
899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */,
A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */,
EAF2B3E6C6AEC4AD3A8BD454 /* RoomMemberDetailsScreenViewModel.swift in Sources */,
A70DACFE2A7146DE007F184C /* UITimelineView.swift in Sources */,
5B6E5AD224509E6C0B520D6E /* RoomMemberDetailsScreenViewModelProtocol.swift in Sources */,
6448F8D1D3CA4CD27BB4CADD /* RoomMemberProxy.swift in Sources */,
92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */,
@ -4674,6 +4681,7 @@
C4FE0E11A907C8999F92D5A8 /* TimelineStartRoomTimelineItem.swift in Sources */,
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */,
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
A70DACFC2A7146D2007F184C /* TimelineTableViewController.swift in Sources */,
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,

View File

@ -34,6 +34,7 @@ final class AppSettings {
case readReceiptsEnabled
case hasShownWelcomeScreen
case notificationSettingsEnabled
case swiftUITimelineEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@ -213,4 +214,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.notificationSettingsEnabled, defaultValue: false, storageType: .userDefaults(store))
var notificationSettingsEnabled
@UserPreference(key: UserDefaultsKeys.swiftUITimelineEnabled, defaultValue: false, storageType: .volatile)
var swiftUITimelineEnabled
}

View File

@ -61,6 +61,7 @@ enum RoomScreenViewAction {
case cancelEdit
/// Mark the entire room as read - this is heavy handed as a starting point for now.
case markRoomAsRead
case paginateBackwards
case timelineItemMenu(itemID: TimelineItemIdentifier)
case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
@ -92,6 +93,7 @@ struct RoomScreenViewState: BindableState {
var isEncryptedOneToOneRoom = false
var timelineViewState = TimelineViewState() // check the doc before changing this
var composerMode: RoomScreenComposerMode = .default
var swiftUITimelineEnabled = false
var bindings: RoomScreenViewStateBindings
@ -177,6 +179,10 @@ struct RoomMemberState {
struct TimelineViewState {
var canBackPaginate = true
var isBackPaginating = false
// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
var timelineIDs: [String] {
@ -186,6 +192,4 @@ struct TimelineViewState {
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
var paginateAction: (() -> Void)?
}

View File

@ -61,11 +61,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])),
imageProvider: mediaProvider)
state.timelineViewState.paginateAction = { [weak self] in
self?.paginateBackwards()
}
setupSubscriptions()
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
@ -140,12 +136,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await handleRetrySend(itemID: itemID) }
case .cancelSend(let itemID):
Task { await handleCancelSend(itemID: itemID) }
case .paginateBackwards:
paginateBackwards()
}
}
// MARK: - Private
private func setupSubscriptions() {
appSettings.$swiftUITimelineEnabled
.weakAssign(to: \.state.swiftUITimelineEnabled, on: self)
.store(in: &cancellables)
timelineController.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
@ -274,19 +276,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
if itemGroup.count == 1 {
if let firstItem = itemGroup.first {
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: firstItem, groupStyle: .single),
timelineItemsDictionary.updateValue(updateViewstate(item: firstItem, groupStyle: .single),
forKey: firstItem.id.timelineID)
}
} else {
for (index, item) in itemGroup.enumerated() {
if index == 0 {
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .first),
timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .first),
forKey: item.id.timelineID)
} else if index == itemGroup.count - 1 {
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .last),
timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .last),
forKey: item.id.timelineID)
} else {
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .middle),
timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .middle),
forKey: item.id.timelineID)
}
}
@ -296,6 +298,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.timelineViewState.itemsDictionary = timelineItemsDictionary
}
private func updateViewstate(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] {
timelineItemViewState.groupStyle = groupStyle
timelineItemViewState.type = .init(item: item)
return timelineItemViewState
} else {
return RoomTimelineItemViewState(item: item, groupStyle: groupStyle)
}
}
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
return false

View File

@ -83,12 +83,48 @@ struct RoomScreen: View {
}
private var timeline: some View {
TimelineView(viewState: context.viewState.timelineViewState)
timelineSwitch
.id(context.viewState.roomId)
.environmentObject(context)
.environment(\.timelineStyle, context.viewState.timelineStyle)
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
}
@ViewBuilder
private var timelineSwitch: some View {
if context.viewState.swiftUITimelineEnabled {
TimelineView(viewState: context.viewState.timelineViewState,
scrollToBottomButtonVisible: $context.scrollToBottomButtonVisible) {
context.send(viewAction: .paginateBackwards)
}
} else {
UITimelineView()
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton
}
}
}
private var scrollToBottomButton: some View {
Button { context.viewState.timelineViewState.scrollToBottomPublisher.send(()) } label: {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
.fontWeight(.semibold)
.foregroundColor(.compound.iconSecondary)
.padding(13)
.offset(y: 1)
.background {
Circle()
.fill(Color.compound.iconOnSolidPrimary)
// Intentionally using system primary colour to get white/black.
.shadow(color: .primary.opacity(0.33), radius: 2.0)
}
.padding()
}
.opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0)
.accessibilityHidden(!context.scrollToBottomButtonVisible)
.animation(.elementDefault, value: context.scrollToBottomButtonVisible)
}
private var messageComposer: some View {
MessageComposer(text: $context.composerText,

View File

@ -0,0 +1,283 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
import OrderedCollections
/// A table view cell that displays a timeline item in a room. The cell is intended
/// to be configured to display a SwiftUI view and not use any UIKit.
class TimelineItemCell: UITableViewCell {
static let reuseIdentifier = "TimelineItemCell"
var item: RoomTimelineItemViewState?
override func prepareForReuse() {
item = nil
}
}
/// A table view controller that displays the timeline of a room.
///
/// This class subclasses `UIViewController` as `UITableViewController` adds some
/// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1).
/// Also this TableViewController uses a **flipped tableview**
class TimelineTableViewController: UIViewController {
private let coordinator: UITimelineView.Coordinator
private let tableView = UITableView(frame: .zero, style: .plain)
var timelineStyle: TimelineStyle
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>() {
didSet {
applySnapshot()
if timelineItemsDictionary.isEmpty {
paginateBackwardsPublisher.send()
}
}
}
/// The mode of the message composer. This is used to render selected
/// items in the timeline when replying, editing etc.
var composerMode: RoomScreenComposerMode = .default
/// Whether or not the timeline has more messages to back paginate.
var canBackPaginate = true
/// Whether or not the timeline is waiting for more messages to be added to the top.
var isBackPaginating = false {
didSet {
// Paginate again if the threshold hasn't been satisfied.
paginateBackwardsPublisher.send(())
}
}
var contextMenuActionProvider: (@MainActor (_ itemID: TimelineItemIdentifier) -> TimelineItemMenuActions?)?
@Binding private var scrollToBottomButtonVisible: Bool
private var timelineItemsIDs: [String] {
timelineItemsDictionary.keys.elements.reversed()
}
/// The table's diffable data source.
private var dataSource: UITableViewDiffableDataSource<TimelineSection, String>?
private var cancellables: Set<AnyCancellable> = []
/// A publisher used to throttle back pagination requests.
///
/// Our view actions get wrapped in a `Task` so it is possible that a second call in
/// quick succession can execute before ``isBackPaginating`` becomes `true`.
private let paginateBackwardsPublisher = PassthroughSubject<Void, Never>()
/// Whether or not the view has been shown on screen yet.
private var hasAppearedOnce = false
/// Whether the scroll and the animations should happen
private var shouldAnimate = false
init(coordinator: UITimelineView.Coordinator,
timelineStyle: TimelineStyle,
scrollToBottomButtonVisible: Binding<Bool>,
scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
self.coordinator = coordinator
self.timelineStyle = timelineStyle
_scrollToBottomButtonVisible = scrollToBottomButtonVisible
super.init(nibName: nil, bundle: nil)
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
tableView.separatorStyle = .none
tableView.allowsSelection = false
tableView.keyboardDismissMode = .onDrag
tableView.backgroundColor = UIColor(.compound.bgCanvasDefault)
tableView.transform = CGAffineTransform(scaleX: 1, y: -1)
view.addSubview(tableView)
// Prevents XCUITest from invoking the diffable dataSource's cellProvider
// for each possible cell, causing layout issues
tableView.accessibilityElementsHidden = Tests.shouldDisableTimelineAccessibility
scrollToBottomPublisher
.sink { [weak self] _ in
self?.scrollToBottom(animated: true)
}
.store(in: &cancellables)
paginateBackwardsPublisher
.collect(.byTime(DispatchQueue.main, 0.1))
.sink { [weak self] _ in
self?.paginateBackwardsIfNeeded()
}
.store(in: &cancellables)
configureDataSource()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not available.") }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard !hasAppearedOnce else { return }
tableView.contentOffset.y = -1
hasAppearedOnce = true
paginateBackwardsPublisher.send()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.shouldAnimate = true
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
guard tableView.frame.size != view.frame.size else {
return
}
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
}
/// Configures a diffable data source for the timeline's table view.
private func configureDataSource() {
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, id in
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
guard let self, let cell = cell as? TimelineItemCell else { return cell }
// A local reference to avoid capturing self in the cell configuration.
let coordinator = self.coordinator
let viewState = timelineItemsDictionary[id]
cell.item = viewState
guard let viewState else {
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
RoomTimelineItemView(viewState: viewState)
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.onAppear {
coordinator.send(viewAction: .itemAppeared(itemID: viewState.identifier))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(itemID: viewState.identifier))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
.background(Color.clear)
// Flipping the cell can create some issues with cell resizing, so flip the content View
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
return cell
}
dataSource?.defaultRowAnimation = .fade
tableView.delegate = self
}
/// Updates the table view with the latest items from the ``timelineItems`` array. After
/// updating the data, the table will be scrolled to the bottom if it was visible otherwise
/// the scroll position will be updated to maintain the position of the last visible item.
private func applySnapshot() {
guard let dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
snapshot.appendSections([.main])
snapshot.appendItems(timelineItemsIDs)
let currentSnapshot = dataSource.snapshot()
MXLog.verbose("DIFF: \(snapshot.itemIdentifiers.difference(from: currentSnapshot.itemIdentifiers))")
// We only animate when new items come at the end of the timeline
let animated = shouldAnimate &&
snapshot.itemIdentifiers.first != currentSnapshot.itemIdentifiers.first
dataSource.apply(snapshot, animatingDifferences: animated)
}
/// Scrolls to the bottom of the timeline.
private func scrollToBottom(animated: Bool) {
guard !timelineItemsIDs.isEmpty else {
return
}
tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: animated)
}
/// Scrolls to the top of the timeline.
private func scrollToTop(animated: Bool) {
guard !timelineItemsIDs.isEmpty else {
return
}
tableView.scrollToRow(at: IndexPath(item: timelineItemsIDs.count - 1, section: 0), at: .bottom, animated: animated)
}
/// Checks whether or a backwards pagination is needed and requests one if so.
///
/// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests.
private func paginateBackwardsIfNeeded() {
guard canBackPaginate,
!isBackPaginating,
tableView.contentOffset.y > tableView.contentSize.height - tableView.visibleSize.height * 2.0
else { return }
coordinator.send(viewAction: .paginateBackwards)
}
}
// MARK: - UITableViewDelegate
extension TimelineTableViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
paginateBackwardsPublisher.send(())
// Dispatch to fix runtime warning about making changes during a view update.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let scrollToBottomButtonVisible = scrollView.contentOffset.y > 0
// Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling.
if self.scrollToBottomButtonVisible != scrollToBottomButtonVisible {
self.scrollToBottomButtonVisible = scrollToBottomButtonVisible
}
}
// We never want the table view to be fully at the bottom to allow the status bar tap to work properly
if scrollView.contentOffset.y == 0 {
scrollView.contentOffset.y = -1
}
}
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
scrollToTop(animated: true)
return false
}
}
// MARK: - Layout Types
extension TimelineTableViewController {
/// The sections of the table view used in the diffable data source.
enum TimelineSection {
case main
}
}

View File

@ -0,0 +1,97 @@
//
// 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
/// A table view wrapper that displays the timeline of a room.
struct UITimelineView: UIViewControllerRepresentable {
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
@Environment(\.timelineStyle) private var timelineStyle
func makeUIViewController(context: Context) -> TimelineTableViewController {
let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
timelineStyle: timelineStyle,
scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible,
scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.scrollToBottomPublisher)
return tableViewController
}
func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) {
context.coordinator.update(tableViewController: uiViewController, timelineStyle: timelineStyle)
}
func makeCoordinator() -> Coordinator {
Coordinator(viewModelContext: viewModelContext)
}
// MARK: - Coordinator
@MainActor
class Coordinator {
let context: RoomScreenViewModel.Context
init(viewModelContext: RoomScreenViewModel.Context) {
context = viewModelContext
if viewModelContext.viewState.timelineViewState.itemViewStates.isEmpty {
viewModelContext.send(viewAction: .paginateBackwards)
}
}
/// Updates the specified table view's properties from the current view state.
func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) {
if tableViewController.timelineStyle != timelineStyle {
tableViewController.timelineStyle = timelineStyle
}
if tableViewController.timelineItemsDictionary != context.viewState.timelineViewState.itemsDictionary {
tableViewController.timelineItemsDictionary = context.viewState.timelineViewState.itemsDictionary
}
if tableViewController.canBackPaginate != context.viewState.timelineViewState.canBackPaginate {
tableViewController.canBackPaginate = context.viewState.timelineViewState.canBackPaginate
}
if tableViewController.isBackPaginating != context.viewState.timelineViewState.isBackPaginating {
tableViewController.isBackPaginating = context.viewState.timelineViewState.isBackPaginating
}
if tableViewController.composerMode != context.viewState.composerMode {
tableViewController.composerMode = context.viewState.composerMode
}
// Doesn't have an equatable conformance :(
tableViewController.contextMenuActionProvider = context.viewState.timelineItemMenuActionProvider
}
func send(viewAction: RoomScreenViewAction) {
context.send(viewAction: viewAction)
}
}
}
// MARK: - Previews
struct UITimelineView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
static var previews: some View {
NavigationStack {
RoomScreen(context: viewModel.context)
}
}
}

View File

@ -21,6 +21,9 @@ import SwiftUIIntrospect
struct TimelineView: View {
let viewState: TimelineViewState
@Binding var scrollToBottomButtonVisible: Bool
let paginationAction: () -> Void
@Environment(\.timelineStyle) private var timelineStyle
private let bottomID = "RoomTimelineBottomPinIdentifier"
@ -28,8 +31,6 @@ struct TimelineView: View {
@State private var scrollViewAdapter = ScrollViewAdapter()
@State private var paginateBackwardsPublisher = PassthroughSubject<Void, Never>()
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@State private var scrollToBottomButtonVisible = false
var body: some View {
ScrollViewReader { scrollView in
@ -49,9 +50,10 @@ struct TimelineView: View {
// Allows the scroll to top to work properly
uiScrollView.contentOffset.y -= 1
paginateBackwardsPublisher.send()
}
.scaleEffect(x: 1, y: -1)
.onReceive(scrollToBottomPublisher) { _ in
.onReceive(viewState.scrollToBottomPublisher) { _ in
withElementAnimation {
scrollView.scrollTo(bottomID)
}
@ -59,7 +61,7 @@ struct TimelineView: View {
.scrollDismissesKeyboard(.immediately)
}
.overlay(scrollToBottomButton, alignment: .bottomTrailing)
.animation(.elementDefault, value: viewState.itemViewStates)
.animation(.elementDefault, value: viewState.timelineIDs)
.onReceive(scrollViewAdapter.didScroll) { _ in
guard let scrollView = scrollViewAdapter.scrollView else {
return
@ -120,7 +122,7 @@ struct TimelineView: View {
private var scrollToBottomButton: some View {
Button {
scrollToBottomPublisher.send()
viewState.scrollToBottomPublisher.send()
} label: {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
@ -142,8 +144,7 @@ struct TimelineView: View {
}
private func paginateBackwardsIfNeeded() {
guard let paginateAction = viewState.paginateAction,
let scrollView = scrollViewAdapter.scrollView,
guard let scrollView = scrollViewAdapter.scrollView,
viewState.canBackPaginate,
!viewState.isBackPaginating else {
return
@ -158,20 +159,20 @@ struct TimelineView: View {
return
}
paginateAction()
paginationAction()
}
}
// MARK: - Previews
struct TimelineTableView_Previews: PreviewProvider {
struct TimelineView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
static var previews: some View {
NavigationStack {
RoomScreen(context: viewModel.context)

View File

@ -47,6 +47,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var userSuggestionsEnabled: Bool { get set }
var readReceiptsEnabled: Bool { get set }
var notificationSettingsEnabled: Bool { get set }
var swiftUITimelineEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }

View File

@ -31,6 +31,11 @@ struct DeveloperOptionsScreen: View {
Text("Show read receipts")
Text("Requires app reboot")
}
Toggle(isOn: $context.swiftUITimelineEnabled) {
Text("SwiftUI Timeline")
Text("Resets on reboot")
}
}
Section("Notifications") {

View File

@ -17,10 +17,12 @@ import SwiftUI
struct RoomTimelineItemView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let viewState: RoomTimelineItemViewState
@ObservedObject var viewState: RoomTimelineItemViewState
var body: some View {
timelineView
.animation(.elementDefault, value: viewState.groupStyle)
.animation(.elementDefault, value: viewState.type)
.environmentObject(context)
.environment(\.timelineGroupStyle, viewState.groupStyle)
.onAppear {

View File

@ -16,9 +16,13 @@
import Foundation
struct RoomTimelineItemViewState: Identifiable, Equatable {
let type: RoomTimelineItemType
let groupStyle: TimelineGroupStyle
final class RoomTimelineItemViewState: Identifiable, Equatable, ObservableObject {
static func == (lhs: RoomTimelineItemViewState, rhs: RoomTimelineItemViewState) -> Bool {
lhs.type == rhs.type && lhs.groupStyle == rhs.groupStyle
}
@Published var type: RoomTimelineItemType
@Published var groupStyle: TimelineGroupStyle
/// Contains all the identification info of the item, `timelineID`, `eventID` and `transactionID`
var identifier: TimelineItemIdentifier {
@ -33,10 +37,13 @@ struct RoomTimelineItemViewState: Identifiable, Equatable {
var isReactable: Bool {
type.isReactable
}
}
extension RoomTimelineItemViewState {
init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
init(type: RoomTimelineItemType, groupStyle: TimelineGroupStyle) {
self.type = type
self.groupStyle = groupStyle
}
convenience init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
self.init(type: .init(item: item), groupStyle: groupStyle)
}
}