mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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 {
|
||||
|
||||
+73
-38
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user