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
124 changed files with 2502 additions and 1838 deletions
@@ -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
@@ -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
}
@@ -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
}
@@ -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) {
if case let .reply(_, displayName) = type {
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
}
let rect = RoundedRectangle(cornerRadius: borderRadius)
VStack(alignment: .leading, spacing: 4.0) {
if case let .reply(_, displayName) = type {
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
}
HStack(alignment: .center) {
MessageComposerTextField(placeholder: "Send a message",
text: $text,
focused: $focused,
maxHeight: 300)
Button {
sendAction()
} label: {
Image(systemName: "paperplane")
.font(.element.title3)
.foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground)
.padding(8.0)
.background(
Circle()
.foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground)
)
}
.disabled(sendingDisabled)
.animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.padding(4.0)
}
.padding(4.0)
.frame(minHeight: 44.0)
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
.animation(.elementDefault, value: type)
.animation(.elementDefault, value: borderWidth)
Button {
sendAction()
} label: {
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
.background(Circle()
.foregroundColor(.global.white)
)
}
.padding(.bottom, 6.0)
.disabled(sendingDisabled)
.opacity(sendingDisabled ? 0.5 : 1.0)
.animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command])
}
.padding(.leading, 12.0)
.background(.thinMaterial)
.clipShape(rect)
.animation(.elementDefault, value: type)
}
private var borderColor: Color {
.element.accent
}
private var borderWidth: CGFloat {
focused ? 2.0 : 1.0
private var borderRadius: CGFloat {
switch type {
case .default:
return 28.0
case .reply:
return 12.0
}
}
}
@@ -83,8 +85,9 @@ private struct MessageComposerReplyHeader: View {
Button {
action()
} label: {
Image(systemName: "xmark")
.font(.element.caption2)
Image(systemName: "x.circle")
.font(.element.callout)
.foregroundColor(.element.secondaryContent)
.padding(4.0)
}
}
@@ -71,6 +71,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
let textView = UITextView()
textView.delegate = context.coordinator
textView.textColor = .element.primaryContent
textView.isEditable = true
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.isSelectable = true
@@ -92,9 +93,8 @@ private struct UITextViewWrapper: UIViewRepresentable {
UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight)
if focused, textView.window != nil, !textView.isFirstResponder {
// Avoid cycle detected through attribute warnings
DispatchQueue.main.async {
DispatchQueue.main.async { // Avoid cycle detected through attribute warnings
if focused, textView.window != nil, !textView.isFirstResponder {
textView.becomeFirstResponder()
}
}
@@ -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")
}
}
@@ -33,7 +33,6 @@ struct RoomScreen: View {
context.send(viewAction: .cancelReply)
}
.padding()
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -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)
}
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContentWithReactions
}
.padding(.trailing, 16)
.padding(.leading, 51)
} else {
styledContentWithReactions
VStack(alignment: alignment) {
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContentWithReactions
}
.padding(.trailing, 16)
.padding(.leading, 16)
.padding(.trailing, 51)
} else {
styledContentWithReactions
.padding(.leading, 24)
.padding(.trailing, 24)
}
}
}
}
@@ -56,10 +61,10 @@ struct TimelineItemBubbledStylerView<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 timelineItem.isOutgoing {
styledContentOutgoing
} else {
styledContentIncoming
}
}
@ViewBuilder
var styledContentOutgoing: some View {
if timelineItem.inGroupState == .single || timelineItem.inGroupState == .beginning {
Spacer()
.frame(height: 8)
}
if shouldAvoidBubbling {
ZStack(alignment: .bottomTrailing) {
content()
.clipped()
.cornerRadius(8)
Text(timelineItem.timestamp)
.foregroundColor(.global.white)
.font(.element.caption2)
.padding(4)
.background(Color(white: 0, opacity: 0.7))
.clipped()
.cornerRadius(8)
.offset(x: -8, y: -8)
}
content()
.frame(width: bubbleWidth)
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
} else {
VStack(alignment: .trailing, spacing: 4) {
content()
.frame(minWidth: minBubbleWidth, alignment: .leading)
.frame(width: bubbleWidth - 24, alignment: .leading)
if timelineItem.properties.isEdited {
Text(ElementL10n.editedSuffix)
@@ -108,25 +117,50 @@ struct TimelineItemBubbledStylerView<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)
// )
}
}
private var shouldAvoidBubbling: Bool {
timelineItem is ImageRoomTimelineItem
}
private var bubbleColor: Color {
let opacity = colorScheme == .light ? 0.06 : 0.15
return timelineItem.isOutgoing ? .element.accent.opacity(opacity) : .element.system
}
private var alignment: HorizontalAlignment {
timelineItem.isOutgoing ? .trailing : .leading
}
private var bubbleWidth: CGFloat {
timelineWidth * (timelineItem.isOutgoing ? bubbleWidthPercentOutgoing : bubbleWidthPercentIncoming)
}
}
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
@@ -144,6 +178,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
}
}
.timelineStyle(.bubbles)
.timelineWidth(390)
.padding(.horizontal, 8)
.previewLayout(.sizeThatFits)
}
@@ -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)
}
}
@@ -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)
}
@@ -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.")
}
}
}
@@ -21,39 +21,32 @@ struct ImageRoomTimelineView: View {
let timelineItem: ImageRoomTimelineItem
var body: some View {
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
Image(uiImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
} else {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
.frame(maxHeight: 1000.0)
} else {
TimelineStyler(timelineItem: timelineItem) {
HStack {
Spacer()
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView("Loading")
Spacer()
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.id(timelineItem.id)
}
.id(timelineItem.id)
.animation(.elementDefault, value: timelineItem.image)
.frame(maxHeight: 1000.0)
}
}
@@ -70,6 +63,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some image",
timestamp: "Now",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: false,
senderId: "Bob",
source: nil,
@@ -79,6 +73,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some other image",
timestamp: "Now",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: false,
senderId: "Bob",
source: nil,
@@ -88,6 +83,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Blurhashed image",
timestamp: "Now",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: false,
senderId: "Bob",
source: nil,
@@ -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)
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
@@ -28,26 +28,26 @@ 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()
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
Spacer()
}
.listRowBackground(Color.clear)
ProgressView()
.frame(maxWidth: .infinity)
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
timelineItem
.contextMenu(menuItems: {
.contextMenu {
context.viewState.contextMenuBuilder?(timelineItem.id)
})
}
.opacity(opacityForItem(timelineItem))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -65,6 +65,8 @@ struct TimelineItemList: View {
}
}
.listStyle(.plain)
.background(ViewFrameReader(frame: $viewFrame))
.environment(\.timelineWidth, viewFrame.width)
.timelineStyle(settings.timelineStyle)
.environment(\.defaultMinListRowHeight, 0.0)
.introspectTableView { tableView in
@@ -81,24 +83,24 @@ struct TimelineItemList: View {
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
}
.onAppear(perform: {
.onAppear {
if timelineItems != context.viewState.items {
timelineItems = context.viewState.items
}
})
.onReceive(scrollToBottomPublisher, perform: {
}
.onReceive(scrollToBottomPublisher) {
tableViewObserver.scrollToBottom(animated: true)
})
.onReceive(tableViewObserver.scrollViewTopVisiblePublisher, perform: { isTopVisible in
}
.onReceive(tableViewObserver.scrollViewTopVisiblePublisher) { isTopVisible in
if !isTopVisible || context.viewState.isBackPaginating {
return
}
attemptBackPagination()
})
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher, perform: { isBottomVisible in
}
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher) { isBottomVisible in
bottomVisiblePublisher.send(isBottomVisible)
})
}
.onChange(of: context.viewState.items) { _ in
// Don't update the list while moving
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
@@ -109,7 +111,7 @@ struct TimelineItemList: View {
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
}
.onReceive(tableViewObserver.scrollViewDidRestPublisher, perform: {
.onReceive(tableViewObserver.scrollViewDidRestPublisher) {
if hasPendingChanges == false {
return
}
@@ -117,13 +119,13 @@ struct TimelineItemList: View {
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
hasPendingChanges = false
})
.onChange(of: timelineItems, perform: { _ in
}
.onChange(of: timelineItems) { _ in
tableViewObserver.restoreSavedOffset()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
})
}
}
}
@@ -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)