Further design updates to the timeline. (#524)

- Decryption banner on the timeline
- Bubble colours
- Background insets
- Multiline composer corner radius
- Read marker
- GIF indicator
- Reactions shape
- Use RoomTimelineViewLabelStyle everywhere possible
- Only set a line limit on reply quotes, allow all the lines for regular quotes.
- Message label icons
- Yes/No button casing
- Make sender profiles immutable.
- Enable copy link and invite buttons.
- Fix layout loop detecting multiline composer text.
- Add animation to the encryption banner.
This commit is contained in:
Doug
2023-02-03 14:09:48 +00:00
committed by GitHub
parent 183cc88114
commit 2aaeb576d2
52 changed files with 379 additions and 166 deletions
+6 -9
View File
@@ -67,22 +67,19 @@ public struct ElementColors {
// MARK: - Temp // MARK: - Temp
private var tempActionBlack: UIColor { UIColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255, alpha: 1.0) } public var bubblesYou: Color {
public var tempActionBackground: Color {
Color(UIColor { collection in Color(UIColor { collection in
collection.userInterfaceStyle == .light ? tempActionBlack : .white // Note: Light colour doesn't currently match Figma.
collection.userInterfaceStyle == .light ? .element.systemGray5 : UIColor(red: 0.16, green: 0.18, blue: 0.21, alpha: 1)
}) })
} }
public var tempActionForeground: Color { public var bubblesNotYou: Color {
Color(UIColor { collection in Color(UIColor { collection in
collection.userInterfaceStyle == .light ? .white : tempActionBlack // Note: Light colour doesn't currently match Figma.
collection.userInterfaceStyle == .light ? .element.systemGray6 : .element.system
}) })
} }
public var tempActionBackgroundTint: Color { tempActionBackground.opacity(0.2) }
public var tempActionForegroundTint: Color { tempActionForeground.opacity(0.2) }
} }
// MARK: UIKit // MARK: UIKit
@@ -1,6 +1,8 @@
/* Used for testing */ /* Used for testing */
"untranslated" = "Untranslated"; "untranslated" = "Untranslated";
"ios_yes" = "Yes";
"ios_no" = "No";
"action_confirm" = "Confirm"; "action_confirm" = "Confirm";
"action_match" = "Match"; "action_match" = "Match";
@@ -24,9 +26,9 @@
"room_timeline_syncing" = "Syncing"; "room_timeline_syncing" = "Syncing";
"room_timeline_unable_to_decrypt" = "Unable to decrypt"; "room_timeline_unable_to_decrypt" = "Unable to decrypt";
"room_timeline_context_menu_retry_decryption" = "Retry decryption";
"room_timeline_item_unsupported" = "Unsupported event"; "room_timeline_item_unsupported" = "Unsupported event";
"room_timeline_image_gif" = "GIF";
"room_timeline_read_marker_title" = "New";
"noticeRoomInviteAccepted" = "%1$@ accepted the invite"; "noticeRoomInviteAccepted" = "%1$@ accepted the invite";
"noticeRoomInviteAcceptedByYou" = "You accepted the invite"; "noticeRoomInviteAcceptedByYou" = "You accepted the invite";
@@ -36,6 +36,10 @@ extension ElementL10n {
public static let ftueAuthCarouselWelcomeTitle = ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_title") public static let ftueAuthCarouselWelcomeTitle = ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_title")
/// Enter your details /// Enter your details
public static let ftueAuthSignInEnterDetails = ElementL10n.tr("Untranslated", "ftue_auth_sign_in_enter_details") public static let ftueAuthSignInEnterDetails = ElementL10n.tr("Untranslated", "ftue_auth_sign_in_enter_details")
/// No
public static let iosNo = ElementL10n.tr("Untranslated", "ios_no")
/// Yes
public static let iosYes = ElementL10n.tr("Untranslated", "ios_yes")
/// Mobile /// Mobile
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device") public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
/// Tablet /// Tablet
@@ -94,14 +98,16 @@ extension ElementL10n {
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title") public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
/// Failed loading messages /// Failed loading messages
public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure") public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure")
/// Retry decryption
public static let roomTimelineContextMenuRetryDecryption = ElementL10n.tr("Untranslated", "room_timeline_context_menu_retry_decryption")
/// Editing /// Editing
public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing") public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing")
/// GIF
public static let roomTimelineImageGif = ElementL10n.tr("Untranslated", "room_timeline_image_gif")
/// Unsupported event /// Unsupported event
public static let roomTimelineItemUnsupported = ElementL10n.tr("Untranslated", "room_timeline_item_unsupported") public static let roomTimelineItemUnsupported = ElementL10n.tr("Untranslated", "room_timeline_item_unsupported")
/// Failed creating the permalink /// Failed creating the permalink
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure") public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure")
/// New
public static let roomTimelineReadMarkerTitle = ElementL10n.tr("Untranslated", "room_timeline_read_marker_title")
/// Replying to %@ /// Replying to %@
public static func roomTimelineReplyingTo(_ p1: Any) -> String { public static func roomTimelineReplyingTo(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1)) return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1))
@@ -94,7 +94,11 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
attributedString.removeSubrange(range) attributedString.removeSubrange(range)
} }
return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: value != nil) let isBlockquote = value != nil
/// This is a temporary workaround until replies are retrieved from the SDK.
let isReply = isBlockquote && attributedString.characters.starts(with: "In reply to @")
return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: isBlockquote, isReply: isReply)
} }
} }
@@ -19,6 +19,7 @@ import Foundation
struct AttributedStringBuilderComponent: Hashable { struct AttributedStringBuilderComponent: Hashable {
let attributedString: AttributedString let attributedString: AttributedString
let isBlockquote: Bool let isBlockquote: Bool
let isReply: Bool
} }
protocol AttributedStringBuilderProtocol { protocol AttributedStringBuilderProtocol {
@@ -16,32 +16,26 @@
import SwiftUI import SwiftUI
/// Small squared action button for settings screens /// Small squared action button style for settings screens
struct SettingsActionButton: View { struct SettingsActionButtonStyle: ButtonStyle {
// MARK: Public
let title: String let title: String
let image: Image
let action: () -> Void
// MARK: Private
@ScaledMetric private var menuIconSize = 54.0 @ScaledMetric private var menuIconSize = 54.0
// MARK: Views func makeBody(configuration: Configuration) -> some View {
var body: some View {
Button(action: action) {
VStack { VStack {
image configuration.label
.renderingMode(.template) .buttonStyle(.plain)
.foregroundColor(.element.primaryContent) .foregroundColor(.element.primaryContent)
.frame(width: menuIconSize, height: menuIconSize) .frame(width: menuIconSize, height: menuIconSize)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.element.background)) .background {
RoundedRectangle(cornerRadius: 16)
.fill(Color.element.background.opacity(configuration.isPressed ? 0.5 : 1))
}
Text(title) Text(title)
.foregroundColor(.element.secondaryContent) .foregroundColor(.element.secondaryContent)
.font(.element.subheadline) .font(.element.subheadline)
} }
} }
} }
}
@@ -31,7 +31,7 @@ struct ServerSelectionScreen: View {
.readableFrame() .readableFrame()
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
.background(Color.element.background, ignoresSafeAreaEdges: .all) .background(Color.element.background.ignoresSafeArea())
.toolbar { toolbar } .toolbar { toolbar }
.alert(item: $context.alertInfo) { $0.alert } .alert(item: $context.alertInfo) { $0.alert }
.interactiveDismissDisabled() .interactiveDismissDisabled()
@@ -162,8 +162,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
func presentCrashedLastRunAlert() { func presentCrashedLastRunAlert() {
state.bindings.alertInfo = AlertInfo(id: UUID(), state.bindings.alertInfo = AlertInfo(id: UUID(),
title: ElementL10n.sendBugReportAppCrashed, title: ElementL10n.sendBugReportAppCrashed,
primaryButton: .init(title: ElementL10n.no, action: nil), primaryButton: .init(title: ElementL10n.iosNo, action: nil),
secondaryButton: .init(title: ElementL10n.yes) { [weak self] in secondaryButton: .init(title: ElementL10n.iosYes) { [weak self] in
self?.callback?(.presentFeedbackScreen) self?.callback?(.presentFeedbackScreen)
}) })
} }
@@ -84,7 +84,7 @@ struct HomeScreen: View {
userMenuButton userMenuButton
} }
} }
.background(Color.element.background) .background(Color.element.background.ignoresSafeArea())
} }
@ViewBuilder @ViewBuilder
@@ -36,6 +36,7 @@ struct RoomDetailsViewState: BindableState {
var title = "" var title = ""
var topic: String? var topic: String?
var avatarURL: URL? var avatarURL: URL?
let permalink: URL?
var members: [RoomDetailsMember] var members: [RoomDetailsMember]
var isLoadingMembers: Bool { var isLoadingMembers: Bool {
@@ -58,7 +59,6 @@ enum RoomDetailsErrorType: Hashable {
enum RoomDetailsViewAction { enum RoomDetailsViewAction {
case processTapPeople case processTapPeople
case copyRoomLink case copyRoomLink
case inviteToRoom
} }
struct RoomDetailsMember: Identifiable, Equatable { struct RoomDetailsMember: Identifiable, Equatable {
@@ -36,6 +36,7 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room", title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
topic: roomProxy.topic, topic: roomProxy.topic,
avatarURL: roomProxy.avatarURL, avatarURL: roomProxy.avatarURL,
permalink: roomProxy.permalink,
members: [], members: [],
bindings: .init()), bindings: .init()),
imageProvider: mediaProvider) imageProvider: mediaProvider)
@@ -59,19 +60,17 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
callback?(.requestMemberDetailsPresentation(members)) callback?(.requestMemberDetailsPresentation(members))
case .copyRoomLink: case .copyRoomLink:
copyRoomLink() copyRoomLink()
case .inviteToRoom:
inviteToRoom()
} }
} }
// MARK: - Private // MARK: - Private
private func copyRoomLink() { private func copyRoomLink() {
// TODO: to be implemented if let roomLink = state.permalink {
// ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard)) UIPasteboard.general.url = roomLink
} ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard))
} else {
private func inviteToRoom() { ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.unknownError))
// TODO: to be implemented }
} }
} }
@@ -62,16 +62,20 @@ struct RoomDetailsScreen: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
// TODO: uncomment this code to display copy link and invite buttons if let permalink = context.viewState.permalink {
// HStack(spacing: 32) { HStack(spacing: 32) {
// SettingsActionButton(title: ElementL10n.roomDetailsCopyLink, image: Image(systemName: "link")) { Button { context.send(viewAction: .copyRoomLink) } label: {
// context.send(viewAction: .copyRoomLink) Image(systemName: "link")
// } }
// SettingsActionButton(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized, image: Image(systemName: "square.and.arrow.up")) { .buttonStyle(SettingsActionButtonStyle(title: ElementL10n.roomDetailsCopyLink))
// context.send(viewAction: .inviteToRoom)
// } ShareLink(item: permalink) {
// } Image(systemName: "square.and.arrow.up")
// .padding(.top, 32) }
.buttonStyle(SettingsActionButtonStyle(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized))
}
.padding(.top, 32)
}
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -53,6 +53,7 @@ enum RoomScreenViewAction {
case cancelEdit case cancelEdit
/// Mark the entire room as read - this is heavy handed as a starting point for now. /// Mark the entire room as read - this is heavy handed as a starting point for now.
case markRoomAsRead case markRoomAsRead
case retryDecryption
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction) case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
} }
@@ -63,6 +64,7 @@ struct RoomScreenViewState: BindableState {
var items: [RoomTimelineViewProvider] = [] var items: [RoomTimelineViewProvider] = []
var canBackPaginate = true var canBackPaginate = true
var isBackPaginating = false var isBackPaginating = false
var showEncryptionBanner = false
var showLoading = false var showLoading = false
var bindings: RoomScreenViewStateBindings var bindings: RoomScreenViewStateBindings
@@ -29,6 +29,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let timelineController: RoomTimelineControllerProtocol private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol private let timelineViewFactory: RoomTimelineViewFactoryProtocol
// swiftlint:disable:next cyclomatic_complexity
init(timelineController: RoomTimelineControllerProtocol, init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol, timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
@@ -66,6 +67,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
if self.state.isBackPaginating != isBackPaginating { if self.state.isBackPaginating != isBackPaginating {
self.state.isBackPaginating = isBackPaginating self.state.isBackPaginating = isBackPaginating
} }
case .hasEncryptedItems(let hasEncryptedItems):
if self.state.showEncryptionBanner != hasEncryptedItems {
self.state.showEncryptionBanner = hasEncryptedItems
}
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -113,6 +118,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.bindings.composerText = "" state.bindings.composerText = ""
case .markRoomAsRead: case .markRoomAsRead:
await markRoomAsRead() await markRoomAsRead()
case .retryDecryption:
retryDecryption()
case .contextMenuAction(let itemID, let action): case .contextMenuAction(let itemID, let action):
processContentMenuAction(action, itemID: itemID) processContentMenuAction(action, itemID: itemID)
} }
@@ -182,6 +189,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
/// Retry decrypting any encrypted items in the timeline.
private func retryDecryption() {
Task {
let firstEncryptedItem = state.items.first { $0.sessionID != nil }
if let sessionID = firstEncryptedItem?.sessionID {
// Request for the first encrypted item to be decrypted as the SDK
// will continue to decrypt any following items automatically.
await timelineController.retryDecryption(for: sessionID)
}
}
}
private func displayError(_ type: RoomScreenErrorType) { private func displayError(_ type: RoomScreenErrorType) {
switch type { switch type {
case .alert(let message): case .alert(let message):
@@ -224,12 +244,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actions.append(.redact) actions.append(.redact)
} }
var debugActions: [TimelineItemContextMenuAction] = [.viewSource] let debugActions: [TimelineItemContextMenuAction] = [.viewSource]
if let item = timelineItem as? EncryptedRoomTimelineItem,
case let .megolmV1AesSha2(sessionId) = item.encryptionType {
debugActions.append(.retryDecryption(sessionId: sessionId))
}
return .init(actions: actions, debugActions: debugActions) return .init(actions: actions, debugActions: debugActions)
} }
@@ -271,10 +286,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
let debugDescription = timelineController.debugDescription(for: item.id) let debugDescription = timelineController.debugDescription(for: item.id)
MXLog.info(debugDescription) MXLog.info(debugDescription)
state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription) state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription)
case .retryDecryption(let sessionId):
Task {
await timelineController.retryDecryption(for: sessionId)
}
} }
if action.switchToDefaultComposer { if action.switchToDefaultComposer {
@@ -291,3 +302,14 @@ extension RoomScreenViewModel {
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Preview room") roomName: "Preview room")
} }
private extension RoomTimelineViewProvider {
/// The item's session ID if it was unable to decrypt and uses megolm.
/// This will be nil for items that have already been decrypted.
var sessionID: String? {
guard case let .encrypted(item) = self,
case let .megolmV1AesSha2(sessionID) = item.encryptionType
else { return nil }
return sessionID
}
}
@@ -21,6 +21,7 @@ struct MessageComposer: View {
@Binding var focused: Bool @Binding var focused: Bool
let sendingDisabled: Bool let sendingDisabled: Bool
let type: RoomScreenComposerMode let type: RoomScreenComposerMode
@State private var isMultiline = false
let sendAction: () -> Void let sendAction: () -> Void
let replyCancellationAction: () -> Void let replyCancellationAction: () -> Void
@@ -34,10 +35,11 @@ struct MessageComposer: View {
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder, MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
text: $text, text: $text,
focused: $focused, focused: $focused,
isMultiline: $isMultiline,
maxHeight: 300, maxHeight: 300,
onEnterKeyHandler: sendAction) onEnterKeyHandler: sendAction)
.tint(.element.brand) .tint(.element.brand)
.padding(.vertical, 12) .padding(.vertical, 10)
Button { Button {
sendAction() sendAction()
@@ -55,7 +57,8 @@ struct MessageComposer: View {
.disabled(sendingDisabled) .disabled(sendingDisabled)
.animation(.elementDefault, value: sendingDisabled) .animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command]) .keyboardShortcut(.return, modifiers: [.command])
.padding(8) .padding(.trailing, 8)
.padding(.vertical, 6)
} }
} }
.padding(.leading, 12.0) .padding(.leading, 12.0)
@@ -67,8 +70,8 @@ struct MessageComposer: View {
.stroke(Color.element.quinaryContent, lineWidth: 1) .stroke(Color.element.quinaryContent, lineWidth: 1)
.opacity(focused ? 1 : 0) .opacity(focused ? 1 : 0)
} }
.animation(.easeOut(duration: 0.25), value: isMultiline)
} }
.clipShape(roundedRectangle)
.animation(.elementDefault, value: type) .animation(.elementDefault, value: type)
} }
@@ -98,9 +101,9 @@ struct MessageComposer: View {
private var borderRadius: CGFloat { private var borderRadius: CGFloat {
switch type { switch type {
case .default: case .default:
return 28.0 return isMultiline ? 8 : 28
case .reply, .edit: case .reply, .edit:
return 12.0 return 8
} }
} }
} }
@@ -19,29 +19,18 @@ import SwiftUI
typealias OnEnterKeyHandler = () -> Void typealias OnEnterKeyHandler = () -> Void
struct MessageComposerTextField: View { struct MessageComposerTextField: View {
@Binding private var text: String let placeholder: String
@Binding private var focused: Bool @Binding var text: String
@Binding var focused: Bool
@Binding var isMultiline: Bool
private let placeholder: String let maxHeight: CGFloat
private let maxHeight: CGFloat let onEnterKeyHandler: OnEnterKeyHandler
private let onEnterKeyHandler: OnEnterKeyHandler
private var showingPlaceholder: Bool { private var showingPlaceholder: Bool {
text.isEmpty text.isEmpty
} }
init(placeholder: String,
text: Binding<String>,
focused: Binding<Bool>,
maxHeight: CGFloat,
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
self.placeholder = placeholder
_text = text
_focused = focused
self.maxHeight = maxHeight
self.onEnterKeyHandler = onEnterKeyHandler
}
private var placeholderColor: Color { private var placeholderColor: Color {
.element.secondaryContent .element.secondaryContent
} }
@@ -49,6 +38,7 @@ struct MessageComposerTextField: View {
var body: some View { var body: some View {
UITextViewWrapper(text: $text, UITextViewWrapper(text: $text,
focused: $focused, focused: $focused,
isMultiline: $isMultiline,
maxHeight: maxHeight, maxHeight: maxHeight,
onEnterKeyHandler: onEnterKeyHandler) onEnterKeyHandler: onEnterKeyHandler)
.background(placeholderView, alignment: .topLeading) .background(placeholderView, alignment: .topLeading)
@@ -68,18 +58,22 @@ private struct UITextViewWrapper: UIViewRepresentable {
@Binding var text: String @Binding var text: String
@Binding var focused: Bool @Binding var focused: Bool
@Binding var isMultiline: Bool
let maxHeight: CGFloat let maxHeight: CGFloat
let onEnterKeyHandler: OnEnterKeyHandler let onEnterKeyHandler: OnEnterKeyHandler
private let font = UIFont.preferredFont(forTextStyle: .body)
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView { func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textView = TextViewWithKeyDetection() let textView = TextViewWithKeyDetection()
textView.isMultiline = $isMultiline
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.keyDelegate = context.coordinator textView.keyDelegate = context.coordinator
textView.textColor = .element.primaryContent textView.textColor = .element.primaryContent
textView.isEditable = true textView.isEditable = true
textView.font = .preferredFont(forTextStyle: .body) textView.font = font
textView.isSelectable = true textView.isSelectable = true
textView.isUserInteractionEnabled = true textView.isUserInteractionEnabled = true
textView.backgroundColor = UIColor.clear textView.backgroundColor = UIColor.clear
@@ -99,6 +93,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
height: CGFloat.greatestFiniteMagnitude)) height: CGFloat.greatestFiniteMagnitude))
let width = proposal.width ?? newSize.width let width = proposal.width ?? newSize.width
let height = min(maxHeight, newSize.height) let height = min(maxHeight, newSize.height)
return CGSize(width: width, height: height) return CGSize(width: width, height: height)
} }
@@ -140,7 +135,10 @@ private struct UITextViewWrapper: UIViewRepresentable {
private let onEnterKeyHandler: OnEnterKeyHandler private let onEnterKeyHandler: OnEnterKeyHandler
init(text: Binding<String>, focused: Binding<Bool>, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) { init(text: Binding<String>,
focused: Binding<Bool>,
maxHeight: CGFloat,
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
self.text = text self.text = text
self.focused = focused self.focused = focused
self.maxHeight = maxHeight self.maxHeight = maxHeight
@@ -177,6 +175,8 @@ private protocol TextViewWithKeyDetectionDelegate: AnyObject {
private class TextViewWithKeyDetection: UITextView { private class TextViewWithKeyDetection: UITextView {
weak var keyDelegate: TextViewWithKeyDetectionDelegate? weak var keyDelegate: TextViewWithKeyDetectionDelegate?
var isMultiline: Binding<Bool>?
override var keyCommands: [UIKeyCommand]? { override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)), [UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))] UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))]
@@ -189,6 +189,23 @@ private class TextViewWithKeyDetection: UITextView {
@objc func enterKeyPressed(sender: UIKeyCommand) { @objc func enterKeyPressed(sender: UIKeyCommand) {
keyDelegate?.enterKeyWasPressed(textView: self) keyDelegate?.enterKeyWasPressed(textView: self)
} }
override func layoutSubviews() {
super.layoutSubviews()
guard let isMultiline, let font else { return }
let numberOfLines = frame.height / font.lineHeight
if numberOfLines > 1.5 {
if !isMultiline.wrappedValue {
isMultiline.wrappedValue = true
}
} else {
if isMultiline.wrappedValue {
isMultiline.wrappedValue = false
}
}
}
} }
struct MessageComposerTextField_Previews: PreviewProvider { struct MessageComposerTextField_Previews: PreviewProvider {
@@ -203,14 +220,21 @@ struct MessageComposerTextField_Previews: PreviewProvider {
struct PreviewWrapper: View { struct PreviewWrapper: View {
@State var text: String @State var text: String
@State var focused: Bool @State var focused: Bool
@State var isMultiline: Bool
init(text: String) { init(text: String) {
_text = .init(initialValue: text) _text = .init(initialValue: text)
_focused = .init(initialValue: false) _focused = .init(initialValue: false)
_isMultiline = .init(initialValue: false)
} }
var body: some View { var body: some View {
MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300, onEnterKeyHandler: { }) MessageComposerTextField(placeholder: "Placeholder",
text: $text,
focused: $focused,
isMultiline: $isMultiline,
maxHeight: 300,
onEnterKeyHandler: { })
} }
} }
} }
@@ -23,8 +23,10 @@ struct RoomScreen: View {
var body: some View { var body: some View {
timeline timeline
.background(Color.element.background) // Kills the toolbar translucency. .background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency.
.safeAreaInset(edge: .bottom) { messageComposer } .overlay(alignment: .top) { encryptionBanner } // Overlay for now, safeAreaInset breaks timeline scroll offset.
.animation(.spring(), value: context.viewState.showEncryptionBanner)
.safeAreaInset(edge: .bottom, spacing: 0) { messageComposer }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar } .toolbar { toolbar }
.toolbarRole(.editor) // Hide the back button title. .toolbarRole(.editor) // Hide the back button title.
@@ -49,6 +51,44 @@ struct RoomScreen: View {
.overlay(alignment: .bottomTrailing) { scrollToBottomButton } .overlay(alignment: .bottomTrailing) { scrollToBottomButton }
} }
@ViewBuilder
var encryptionBanner: some View {
if context.viewState.showEncryptionBanner {
VStack(alignment: .leading, spacing: 4) {
Label {
Text("Unable to decrypt all messages")
.foregroundColor(.element.primaryContent)
} icon: {
Image(systemName: "lock.shield")
.foregroundColor(.element.background)
.padding(4)
.background(Color.element.tertiaryContent)
.cornerRadius(5)
}
.font(.element.bodyBold)
Text("Accessing your encrypted message history is not fully supported yet.")
.font(.element.subheadline)
.foregroundColor(.element.secondaryContent)
.padding(.bottom, 8)
Button(ElementL10n.globalRetry) {
context.send(viewAction: .retryDecryption)
}
.buttonStyle(.elementCapsuleProminent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(Color.element.system)
}
.padding([.horizontal, .top], 16)
.background(Color.element.background)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
var messageComposer: some View { var messageComposer: some View {
MessageComposer(text: $context.composerText, MessageComposer(text: $context.composerText,
focused: $context.composerFocused, focused: $context.composerFocused,
@@ -110,7 +150,8 @@ struct RoomScreen_Previews: PreviewProvider {
roomName: "Preview room") roomName: "Preview room")
NavigationView { NavigationView {
RoomScreen(context: viewModel.context) RoomScreen(context: viewModel.context).encryptionBanner
.padding()
} }
} }
} }
@@ -117,7 +117,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
} }
} }
.bubbleStyle(inset: true, .bubbleStyle(inset: true,
color: timelineItem.isOutgoing ? .element.systemGray5 : .element.systemGray6, color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
cornerRadius: cornerRadius, cornerRadius: cornerRadius,
corners: timelineItem.roundedCorners) corners: timelineItem.roundedCorners)
} }
@@ -51,12 +51,16 @@ struct TimelineReactionButton: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.background( .background(
Capsule() backgroundShape
.strokeBorder(reaction.isHighlighted ? Color.element.secondaryContent : .element.background, lineWidth: 2) .strokeBorder(reaction.isHighlighted ? Color.element.secondaryContent : .element.background, lineWidth: 2)
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: Capsule()) .background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: backgroundShape)
) )
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
var backgroundShape: some InsettableShape {
RoundedRectangle(cornerRadius: 12, style: .continuous)
}
} }
struct TimelineReactionView_Previews: PreviewProvider { struct TimelineReactionView_Previews: PreviewProvider {
@@ -21,23 +21,24 @@ struct EncryptedRoomTimelineView: View {
var body: some View { var body: some View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
Label { Label(timelineItem.text, systemImage: "lock.shield")
FormattedBodyText(text: timelineItem.text)
} icon: {
Image(systemName: "lock.shield")
.foregroundColor(.element.secondaryContent)
}
.labelStyle(RoomTimelineViewLabelStyle()) .labelStyle(RoomTimelineViewLabelStyle())
} }
} }
} }
struct RoomTimelineViewLabelStyle: LabelStyle { struct RoomTimelineViewLabelStyle: LabelStyle {
@Environment(\.timelineStyle) private var timelineStyle
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
configuration.icon configuration.icon
.foregroundColor(.element.secondaryContent)
configuration.title configuration.title
.font(.body)
.foregroundColor(.element.primaryContent)
} }
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
} }
} }
@@ -39,7 +39,7 @@ struct FormattedBodyText: View {
Text(component.attributedString.mergingAttributes(blockquoteAttributes)) Text(component.attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.tertiaryContent) .foregroundColor(.element.tertiaryContent)
.lineLimit(3) // FIXME: Quotes vs replies .lineLimit(component.isReply ? 3 : nil)
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
.clipped() .clipped()
.background(Color.element.background) .background(Color.element.background)
@@ -47,7 +47,7 @@ struct FormattedBodyText: View {
} }
} else { } else {
Text(component.attributedString) Text(component.attributedString)
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) // FIXME: Configurable .padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent) .foregroundColor(.element.primaryContent)
} }
@@ -65,7 +65,7 @@ struct FormattedBodyText: View {
extension FormattedBodyText { extension FormattedBodyText {
init(text: String) { init(text: String) {
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)] attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false, isReply: false)]
} }
} }
@@ -25,7 +25,9 @@ struct ImageRoomTimelineView: View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
LoadableImage(mediaSource: timelineItem.source, LoadableImage(mediaSource: timelineItem.source,
blurhash: timelineItem.blurhash, blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) { imageProvider: context.imageProvider) { image in
image.overlay { overlay }
} placeholder: {
placeholder placeholder
} }
.frame(maxHeight: 300) .frame(maxHeight: 300)
@@ -43,6 +45,20 @@ struct ImageRoomTimelineView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@ViewBuilder
var overlay: some View {
if timelineItem.type == .gif {
Text(ElementL10n.roomTimelineImageGif)
.font(.element.bodyBold)
.foregroundStyle(.primary)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(.thinMaterial)
.cornerRadius(8)
.environment(\.colorScheme, .dark)
}
}
} }
struct ImageRoomTimelineView_Previews: PreviewProvider { struct ImageRoomTimelineView_Previews: PreviewProvider {
@@ -82,7 +98,8 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
source: nil, source: nil,
aspectRatio: 0.7, aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")) blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
type: .gif))
} }
} }
} }
@@ -22,14 +22,21 @@ struct NoticeRoomTimelineView: View {
var body: some View { var body: some View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
HStack(alignment: .firstTextBaseline) { // Don't use RoomTimelineViewLabelStyle with FormattedBodyText as the formatted text
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0) // adds additional padding so the spacing between the icon and text is inconsistent.
// Spacing: 6 = label spacing - formatted text padding
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "info.bubble").padding(.top, 2.0)
.foregroundColor(.element.secondaryContent)
if let attributedComponents = timelineItem.attributedComponents { if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents) FormattedBodyText(attributedComponents: attributedComponents)
} else { } else {
FormattedBodyText(text: timelineItem.text) FormattedBodyText(text: timelineItem.text)
} }
} }
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText
} }
} }
} }
@@ -21,18 +21,47 @@ struct ReadMarkerRoomTimelineView: View {
let timelineItem: ReadMarkerRoomTimelineItem let timelineItem: ReadMarkerRoomTimelineItem
var body: some View { var body: some View {
VStack { VStack(alignment: .trailing, spacing: 2) {
Spacer(minLength: 4.0) Text(ElementL10n.roomTimelineReadMarkerTitle)
Divider() .textCase(.uppercase)
.frame(maxWidth: .infinity) .font(.element.caption2Bold)
.overlay(Color.element.accent) .foregroundColor(.element.quaternaryContent)
Rectangle()
.frame(height: 0.5)
.foregroundColor(.element.quaternaryContent)
} }
.padding(.horizontal, 20)
.padding(.vertical, 16)
} }
} }
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static let item = ReadMarkerRoomTimelineItem()
static var previews: some View { static var previews: some View {
let item = ReadMarkerRoomTimelineItem() VStack(alignment: .leading, spacing: 0) {
RoomTimelineViewProvider.separator(.init(text: "Today"))
RoomTimelineViewProvider.text(.init(id: "",
text: "This is another message",
timestamp: "",
groupState: .single,
isOutgoing: true,
isEditable: false,
sender: .init(id: "1", displayName: "Bob")))
ReadMarkerRoomTimelineView(timelineItem: item) ReadMarkerRoomTimelineView(timelineItem: item)
RoomTimelineViewProvider.separator(.init(text: "Today"))
RoomTimelineViewProvider.text(.init(id: "",
text: "This is a message",
timestamp: "",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Alice")))
}
.padding(.horizontal, 8)
.environmentObject(viewModel.context)
} }
} }
@@ -22,10 +22,9 @@ struct RedactedRoomTimelineView: View {
var body: some View { var body: some View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
HStack { Label(timelineItem.text, systemImage: "trash")
Image(systemName: "trash") .labelStyle(RoomTimelineViewLabelStyle())
FormattedBodyText(text: timelineItem.text) .imageScale(.small) // Smaller icon so that the bubble remains rounded on the outside.
}
} }
} }
} }
@@ -25,8 +25,8 @@ struct StateRoomTimelineView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(.element.secondaryContent) .foregroundColor(.element.secondaryContent)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 16) .padding(.horizontal, 36)
.padding(.vertical, 4) .padding(.vertical, 8)
} }
} }
@@ -25,17 +25,15 @@ struct UnsupportedRoomTimelineView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("\(timelineItem.text): \(timelineItem.eventType)") Text("\(timelineItem.text): \(timelineItem.eventType)")
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
Text(timelineItem.error) Text(timelineItem.error)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.font(.element.footnote) .font(.element.footnote)
.foregroundColor(.element.primaryContent)
} }
} icon: { } icon: {
Image(systemName: "exclamationmark.bubble") Image(systemName: "exclamationmark.triangle")
.foregroundColor(.element.secondaryContent)
} }
.labelStyle(RoomTimelineViewLabelStyle())
} }
} }
} }
@@ -39,7 +39,6 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable {
case redact case redact
case reply case reply
case viewSource case viewSource
case retryDecryption(sessionId: String)
var id: Self { self } var id: Self { self }
@@ -97,16 +96,12 @@ public struct TimelineItemContextMenu: View {
} }
case .redact: case .redact:
Button(role: .destructive) { send(action) } label: { Button(role: .destructive) { send(action) } label: {
Label(ElementL10n.messageActionItemRedact, systemImage: "trash") Label(ElementL10n.actionRemove, systemImage: "trash")
} }
case .viewSource: case .viewSource:
Button { send(action) } label: { Button { send(action) } label: {
Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg") Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg")
} }
case .retryDecryption:
Button { send(action) } label: {
Label(ElementL10n.roomTimelineContextMenuRetryDecryption, systemImage: "arrow.down.message")
}
} }
} }
} }
@@ -33,7 +33,7 @@ struct SessionVerificationScreen: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent } .toolbar { toolbarContent }
} }
.background(Color.element.background) .background(Color.element.background.ignoresSafeArea())
.safeAreaInset(edge: .bottom) { actionButtons.padding() } .safeAreaInset(edge: .bottom) { actionButtons.padding() }
} }
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
@@ -48,7 +48,7 @@ struct SettingsScreen: View {
} }
.navigationTitle(ElementL10n.settings) .navigationTitle(ElementL10n.settings)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.background(backgroundColor, ignoresSafeAreaEdges: .all) .background(backgroundColor.ignoresSafeArea())
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
doneButton doneButton
@@ -51,6 +51,8 @@ protocol RoomProxyProtocol {
var avatarURL: URL? { get } var avatarURL: URL? { get }
var permalink: URL? { get }
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError> func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError>
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
@@ -75,6 +77,17 @@ protocol RoomProxyProtocol {
} }
extension RoomProxyProtocol { extension RoomProxyProtocol {
var permalink: URL? {
if let canonicalAlias, let link = try? PermalinkBuilder.permalinkTo(roomAlias: canonicalAlias) {
return link
} else if let link = try? PermalinkBuilder.permalinkTo(roomIdentifier: id) {
return link
} else {
MXLog.error("Failed to build permalink for Room: \(id)")
return nil
}
}
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> { func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
await sendMessage(message, inReplyTo: nil) await sendMessage(message, inReplyTo: nil)
} }
@@ -78,9 +78,9 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
text: "", text: "",
attributedComponents: [ attributedComponents: [
AttributedStringBuilderComponent(attributedString: "Hol' up", isBlockquote: false), AttributedStringBuilderComponent(attributedString: "Hol' up", isBlockquote: false, isReply: false),
AttributedStringBuilderComponent(attributedString: "New home office set up!", isBlockquote: true), AttributedStringBuilderComponent(attributedString: "New home office set up!", isBlockquote: true, isReply: false),
AttributedStringBuilderComponent(attributedString: "That's amazing! Congrats 🥳", isBlockquote: false) AttributedStringBuilderComponent(attributedString: "That's amazing! Congrats 🥳", isBlockquote: false, isReply: false)
], ],
timestamp: "5 PM", timestamp: "5 PM",
groupState: .single, groupState: .single,
@@ -218,11 +218,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
updateTimelineItems() updateTimelineItems()
} }
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity function_body_length
private func updateTimelineItems() { private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]() var newTimelineItems = [RoomTimelineItemProtocol]()
var canBackPaginate = true var canBackPaginate = true
var isBackPaginating = false var isBackPaginating = false
var hasEncryptedItems = false
var createdIdentifiers = [String: Bool]() var createdIdentifiers = [String: Bool]()
@@ -243,6 +244,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
if createdIdentifiers[timelineItem.id] == nil { if createdIdentifiers[timelineItem.id] == nil {
newTimelineItems.append(timelineItem) newTimelineItems.append(timelineItem)
createdIdentifiers[timelineItem.id] = true createdIdentifiers[timelineItem.id] = true
if timelineItem is EncryptedRoomTimelineItem {
hasEncryptedItems = true
}
} else { } else {
MXLog.error("Found duplicated timeline item, ignoring") MXLog.error("Found duplicated timeline item, ignoring")
} }
@@ -280,6 +284,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
callbacks.send(.updatedTimelineItems) callbacks.send(.updatedTimelineItems)
callbacks.send(.canBackPaginate(canBackPaginate)) callbacks.send(.canBackPaginate(canBackPaginate))
callbacks.send(.isBackPaginating(isBackPaginating)) callbacks.send(.isBackPaginating(isBackPaginating))
callbacks.send(.hasEncryptedItems(hasEncryptedItems))
} }
private func computeGroupState(for itemProxy: TimelineItemProxy, private func computeGroupState(for itemProxy: TimelineItemProxy,
@@ -23,6 +23,7 @@ enum RoomTimelineControllerCallback {
case updatedTimelineItem(_ itemId: String) case updatedTimelineItem(_ itemId: String)
case canBackPaginate(Bool) case canBackPaginate(Bool)
case isBackPaginating(Bool) case isBackPaginating(Bool)
case hasEncryptedItems(Bool)
} }
enum RoomTimelineControllerAction { enum RoomTimelineControllerAction {
@@ -17,6 +17,7 @@
import CoreGraphics import CoreGraphics
import Foundation import Foundation
import MatrixRustSDK import MatrixRustSDK
import UniformTypeIdentifiers
/// A protocol that contains the base `m.room.message` event content properties. /// A protocol that contains the base `m.room.message` event content properties.
/// The `CustomStringConvertible` conformance is to redact specific properties from the logs. /// The `CustomStringConvertible` conformance is to redact specific properties from the logs.
@@ -95,6 +96,10 @@ extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent
var blurhash: String? { var blurhash: String? {
content.info?.blurhash content.info?.blurhash
} }
var type: UTType? {
content.info?.mimetype.flatMap { UTType(mimeType: $0) }
}
} }
extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { } extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { }
@@ -16,11 +16,14 @@
import UIKit import UIKit
#warning("This could be replaced by RoomMemberProxy if Rust includes a RoomMember.")
struct TimelineItemSender: Identifiable, Hashable { struct TimelineItemSender: Identifiable, Hashable {
let id: String let id: String
let displayName: String?
let avatarURL: URL?
// Lazy loaded properties, displayName and avatarURL will be come lets. init(id: String, displayName: String? = nil, avatarURL: URL? = nil) {
var displayName: String? self.id = id
var avatarURL: URL? self.displayName = displayName
self.avatarURL = avatarURL
}
} }
@@ -41,7 +41,7 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
var isOutgoing: Bool { get } var isOutgoing: Bool { get }
var isEditable: Bool { get } var isEditable: Bool { get }
var sender: TimelineItemSender { get set } var sender: TimelineItemSender { get }
var properties: RoomTimelineItemProperties { get } var properties: RoomTimelineItemProperties { get }
} }
@@ -25,7 +25,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -24,7 +24,7 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
let source: MediaSourceProxy? let source: MediaSourceProxy?
let thumbnailSource: MediaSourceProxy? let thumbnailSource: MediaSourceProxy?
@@ -15,6 +15,7 @@
// //
import UIKit import UIKit
import UniformTypeIdentifiers
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String let id: String
@@ -24,7 +25,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
let source: MediaSourceProxy? let source: MediaSourceProxy?
var cachedFileURL: URL? var cachedFileURL: URL?
@@ -33,6 +34,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
var height: CGFloat? var height: CGFloat?
var aspectRatio: CGFloat? var aspectRatio: CGFloat?
var blurhash: String? var blurhash: String?
var type: UTType?
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -25,7 +25,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -25,7 +25,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -24,7 +24,7 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
let duration: UInt64 let duration: UInt64
let source: MediaSourceProxy? let source: MediaSourceProxy?
@@ -31,7 +31,7 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable,
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -25,7 +25,7 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, H
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -24,7 +24,7 @@ struct StateRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -24,7 +24,7 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
let imageURL: URL let imageURL: URL
@@ -28,7 +28,7 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
var sender: TimelineItemSender let sender: TimelineItemSender
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -237,6 +237,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
height: message.height, height: message.height,
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
blurhash: message.blurhash, blurhash: message.blurhash,
type: message.type,
properties: RoomTimelineItemProperties(isEdited: message.isEdited, properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus)) deliveryStatus: eventItemProxy.deliveryStatus))
@@ -314,6 +314,29 @@ class AttributedStringBuilderTests: XCTestCase {
XCTAssertNotNil(foundBlockquoteAndLink, "Couldn't find blockquote or link") XCTAssertNotNil(foundBlockquoteAndLink, "Couldn't find blockquote or link")
} }
func testReplyBlockquote() {
let htmlString = "<blockquote><a href=\"https://matrix.to/#/someroom/someevent\">In reply to</a> <a href=\"https://matrix.to/#/@user:matrix.org\">@user:matrix.org</a><br>The future is <code>swift run tools</code> 😎</blockquote>"
guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else {
XCTFail("Could not build the attributed string")
return
}
guard let coalescedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) else {
XCTFail("Could not build the attributed string components")
return
}
XCTAssertEqual(coalescedComponents.count, 1)
guard let component = coalescedComponents.first else {
XCTFail("Could not get the first component")
return
}
XCTAssertTrue(component.isBlockquote, "The reply quote should be a blockquote.")
XCTAssertTrue(component.isReply, "The reply quote should be detected as a reply")
}
func testMultipleGroupedBlockquotes() { func testMultipleGroupedBlockquotes() {
let htmlString = """ let htmlString = """
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote> <blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
@@ -355,7 +378,15 @@ class AttributedStringBuilderTests: XCTestCase {
XCTAssertEqual(attributedString.runs.count, 12) XCTAssertEqual(attributedString.runs.count, 12)
XCTAssertEqual(attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString)?.count, 6) guard let coalescedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) else {
XCTFail("Could not build the attributed string components")
return
}
XCTAssertEqual(coalescedComponents.count, 6)
for component in coalescedComponents where component.isReply {
XCTFail("None of the blockquotes should be detected as replies.")
}
var numberOfBlockquotes = 0 var numberOfBlockquotes = 0
for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil { for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil {
+6 -3
View File
@@ -212,17 +212,20 @@ class LoggingTests: XCTestCase {
let textAttributedString = "TextAttributed" let textAttributedString = "TextAttributed"
let textMessage = TextRoomTimelineItem(id: "mytextmessage", text: "TextString", let textMessage = TextRoomTimelineItem(id: "mytextmessage", text: "TextString",
attributedComponents: [.init(attributedString: AttributedString(textAttributedString), attributedComponents: [.init(attributedString: AttributedString(textAttributedString),
isBlockquote: false)], isBlockquote: false,
isReply: false)],
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let noticeAttributedString = "NoticeAttributed" let noticeAttributedString = "NoticeAttributed"
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", text: "NoticeString", let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", text: "NoticeString",
attributedComponents: [.init(attributedString: AttributedString(noticeAttributedString), attributedComponents: [.init(attributedString: AttributedString(noticeAttributedString),
isBlockquote: false)], isBlockquote: false,
isReply: false)],
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let emoteAttributedString = "EmoteAttributed" let emoteAttributedString = "EmoteAttributed"
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", text: "EmoteString", let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", text: "EmoteString",
attributedComponents: [.init(attributedString: AttributedString(emoteAttributedString), attributedComponents: [.init(attributedString: AttributedString(emoteAttributedString),
isBlockquote: false)], isBlockquote: false,
isReply: false)],
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", text: "ImageString", let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", text: "ImageString",
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, timestamp: "", groupState: .single, isOutgoing: false, isEditable: false,
+1 -1
View File
@@ -1 +1 @@
Update the designs for the timeline. Finish the design review ready for a public TestFlight.