New timeline items (#424)

* Cleanup session verification state machine configuration

* Add support for new timeline items: loading indicators, stickers and invalid/unsupported events

* Add sticker accessibility label, fix session verification ones

* Add support for the new TimelineStart timeline item and new pagination API. Remove identifiers from individual timeline items and only set them in the TimelineTableViewController

* Add changelog
This commit is contained in:
Stefan Ceriu
2023-01-09 17:17:18 +02:00
committed by Stefan Ceriu
parent 956e92a9ac
commit d70a240869
59 changed files with 802 additions and 296 deletions
@@ -21,6 +21,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let backPaginationEventLimit: UInt = 20
static let backPaginationPageSize: UInt = 50
static let toastErrorID = "RoomScreenToastError"
}
@@ -122,7 +123,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Private
private func paginateBackwards() async {
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) {
case .failure:
displayError(.toast(ElementL10n.roomTimelineBackpaginationFailure))
default:
@@ -98,7 +98,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var styledContentOutgoing: some View {
let topPadding: CGFloat? = timelineItem.inGroupState == .single || timelineItem.inGroupState == .beginning ? 8 : 0
let topPadding: CGFloat? = timelineItem.groupState == .single || timelineItem.groupState == .beginning ? 8 : 0
if shouldAvoidBubbling {
content()
@@ -16,7 +16,7 @@
import SwiftUI
struct TimelineDeliveryStatusView: View {
let deliveryStatus: MessageTimelineItemDeliveryStatus
let deliveryStatus: TimelineItemDeliveryStatus
@State var showDeliveryStatus: Bool
@@ -29,7 +29,7 @@ struct TimelineDeliveryStatusView: View {
}
}
init(deliveryStatus: MessageTimelineItemDeliveryStatus) {
init(deliveryStatus: TimelineItemDeliveryStatus) {
self.deliveryStatus = deliveryStatus
switch deliveryStatus {
@@ -31,7 +31,6 @@ struct EmoteRoomTimelineView: View {
}
}
}
.id(timelineItem.id)
}
}
@@ -57,7 +56,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
EmoteRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: senderId)
@@ -26,20 +26,19 @@ struct EncryptedRoomTimelineView: View {
Button {
showEncryptionInfo = !showEncryptionInfo
} label: {
HStack(alignment: .top) {
Image(systemName: "lock.shield")
.foregroundColor(.red)
.padding(.top, 1.0)
Label {
if showEncryptionInfo {
FormattedBodyText(text: encryptionDetails)
} else {
FormattedBodyText(text: timelineItem.text)
}
} icon: {
Image(systemName: "lock.shield")
.foregroundColor(.red)
}
.animation(nil, value: showEncryptionInfo)
}
}
.id(timelineItem.id)
}
private var encryptionDetails: String {
@@ -79,7 +78,7 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider {
text: text,
encryptionType: .unknown,
timestamp: timestamp,
inGroupState: .single,
groupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
senderId: senderId)
@@ -30,7 +30,6 @@ struct FileRoomTimelineView: View {
.padding(.vertical, 12)
.padding(.horizontal, 6)
}
.id(timelineItem.id)
}
}
@@ -45,7 +44,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
text: "document.pdf",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -55,7 +54,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
text: "document.docx",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -65,7 +64,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
text: "document.txt",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -44,7 +44,6 @@ struct ImageRoomTimelineView: View {
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
}
}
@@ -60,7 +59,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Some image",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -70,7 +69,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -80,7 +79,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed image",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -31,7 +31,6 @@ struct NoticeRoomTimelineView: View {
}
}
}
.id(timelineItem.id)
}
}
@@ -57,7 +56,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
NoticeRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: senderId)
@@ -0,0 +1,33 @@
//
// 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 PaginationIndicatorRoomTimelineView: View {
let timelineItem: PaginationIndicatorRoomTimelineItem
var body: some View {
ProgressView()
.frame(maxWidth: .infinity)
}
}
struct PaginationIndicatorRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
let item = PaginationIndicatorRoomTimelineItem()
PaginationIndicatorRoomTimelineView(timelineItem: item)
}
}
@@ -24,7 +24,6 @@ struct ReadMarkerRoomTimelineView: View {
VStack {
Spacer(minLength: 4.0)
Divider()
.id(timelineItem.id)
.frame(maxWidth: .infinity)
.overlay(Color.element.accent)
}
@@ -33,7 +32,7 @@ struct ReadMarkerRoomTimelineView: View {
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
let item = ReadMarkerRoomTimelineItem(id: UUID().uuidString)
let item = ReadMarkerRoomTimelineItem()
ReadMarkerRoomTimelineView(timelineItem: item)
}
}
@@ -27,7 +27,6 @@ struct RedactedRoomTimelineView: View {
FormattedBodyText(text: timelineItem.text)
}
}
.id(timelineItem.id)
}
}
@@ -44,7 +43,7 @@ struct RedactedRoomTimelineView_Previews: PreviewProvider {
RedactedRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: senderId)
@@ -14,7 +14,6 @@
// limitations under the License.
//
import Foundation
import SwiftUI
struct SeparatorRoomTimelineView: View {
@@ -24,7 +23,6 @@ struct SeparatorRoomTimelineView: View {
Text(timelineItem.text)
.font(.element.footnote)
.foregroundColor(.element.secondaryContent)
.id(timelineItem.id)
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
}
@@ -32,7 +30,7 @@ struct SeparatorRoomTimelineView: View {
struct SeparatorRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
let item = SeparatorRoomTimelineItem(id: UUID().uuidString, text: "This is a separator")
let item = SeparatorRoomTimelineItem(text: "This is a separator")
SeparatorRoomTimelineView(timelineItem: item)
}
}
@@ -0,0 +1,93 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
struct StickerRoomTimelineView: View {
let timelineItem: StickerRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.animation(.elementDefault, value: timelineItem.image)
.accessibilityLabel(timelineItem.text)
}
}
struct StickerRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
body
body.timelineStyle(.plain)
}
static var body: some View {
VStack(spacing: 20.0) {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Some image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
urlString: nil,
image: UIImage(systemName: "photo")))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
urlString: nil,
image: nil))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
urlString: nil,
image: nil,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
}
}
@@ -28,7 +28,6 @@ struct TextRoomTimelineView: View {
FormattedBodyText(text: timelineItem.text)
}
}
.id(timelineItem.id)
}
}
@@ -66,7 +65,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
TextRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
inGroupState: .single,
groupState: .single,
isOutgoing: isOutgoing,
isEditable: isOutgoing,
senderId: senderId)
@@ -0,0 +1,44 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct TimelineStartRoomTimelineView: View {
let timelineItem: TimelineStartRoomTimelineItem
var body: some View {
Text(title)
.font(.element.footnote)
.foregroundColor(.element.secondaryContent)
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
}
var title: String {
var text = ElementL10n.thisIsTheBeginningOfRoomNoName
if let name = timelineItem.name {
text = ElementL10n.thisIsTheBeginningOfRoom(name)
}
return text
}
}
struct TimelineStartRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
let item = TimelineStartRoomTimelineItem(name: "Alice and Bob")
TimelineStartRoomTimelineView(timelineItem: item)
}
}
@@ -0,0 +1,74 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct UnsupportedRoomTimelineView: View {
let timelineItem: UnsupportedRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Label {
VStack(alignment: .leading) {
Text("\(timelineItem.text): \(timelineItem.eventType)")
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
Text(timelineItem.error)
.fixedSize(horizontal: false, vertical: true)
.font(.element.footnote)
.foregroundColor(.element.primaryContent)
}
} icon: {
Image(systemName: "exclamationmark.bubble")
.foregroundColor(.red)
}
}
}
}
struct UnsupportedRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
body
body.timelineStyle(.plain)
}
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
UnsupportedRoomTimelineView(timelineItem: itemWith(text: "Text",
timestamp: "Now",
isOutgoing: false,
senderId: "Bob"))
UnsupportedRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
}
}
private static func itemWith(text: String, timestamp: String, isOutgoing: Bool, senderId: String) -> UnsupportedRoomTimelineItem {
UnsupportedRoomTimelineItem(id: UUID().uuidString,
text: text,
eventType: "Some Event Type",
error: "Something went wrong",
timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
senderId: senderId)
}
}
@@ -40,7 +40,6 @@ struct VideoRoomTimelineView: View {
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
}
@@ -70,7 +69,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Some video",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -82,7 +81,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Some other video",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -94,7 +93,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed video",
timestamp: "Now",
inGroupState: .single,
groupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
@@ -191,32 +191,29 @@ class TimelineTableViewController: UIViewController {
cell.item = timelineItem
cell.contentConfiguration = UIHostingConfiguration {
if case .backPaginationIndicator = timelineItem {
timelineItem
} else {
timelineItem
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(opacity)
.contextMenu {
contextMenuBuilder?(timelineItem.id)
}
.onAppear {
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture(count: 2) {
coordinator.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
}
.onTapGesture {
coordinator.send(viewAction: .itemTapped(id: timelineItem.id))
}
}
timelineItem
.id(timelineItem.id)
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(opacity)
.contextMenu {
contextMenuBuilder?(timelineItem.id)
}
.onAppear {
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture(count: 2) {
coordinator.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
}
.onTapGesture {
coordinator.send(viewAction: .itemTapped(id: timelineItem.id))
}
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
@@ -236,8 +233,6 @@ class TimelineTableViewController: UIViewController {
let previousLayout = layout()
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>()
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.backPaginationIndicator(isBackPaginating)])
snapshot.appendSections([.main])
snapshot.appendItems(timelineItems)
dataSource.apply(snapshot, animatingDifferences: false)
@@ -399,7 +394,6 @@ extension TimelineTableViewController: UITableViewDelegate {
extension TimelineTableViewController {
/// The sections of the table view used in the diffable data source.
enum TimelineSection {
case loadingIndicator
case main
}