diff --git a/DesignKit/Sources/Colors/ElementColors.swift b/DesignKit/Sources/Colors/ElementColors.swift index b2a7ed873..ae12781fa 100644 --- a/DesignKit/Sources/Colors/ElementColors.swift +++ b/DesignKit/Sources/Colors/ElementColors.swift @@ -67,22 +67,19 @@ public struct ElementColors { // MARK: - Temp - private var tempActionBlack: UIColor { UIColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255, alpha: 1.0) } - - public var tempActionBackground: Color { + public var bubblesYou: Color { 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 - 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 diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index b14215485..deda96cd6 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -1,6 +1,8 @@ /* Used for testing */ "untranslated" = "Untranslated"; +"ios_yes" = "Yes"; +"ios_no" = "No"; "action_confirm" = "Confirm"; "action_match" = "Match"; @@ -24,9 +26,9 @@ "room_timeline_syncing" = "Syncing"; "room_timeline_unable_to_decrypt" = "Unable to decrypt"; -"room_timeline_context_menu_retry_decryption" = "Retry decryption"; - "room_timeline_item_unsupported" = "Unsupported event"; +"room_timeline_image_gif" = "GIF"; +"room_timeline_read_marker_title" = "New"; "noticeRoomInviteAccepted" = "%1$@ accepted the invite"; "noticeRoomInviteAcceptedByYou" = "You accepted the invite"; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index eaf6aa1da..e04e80916 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -36,6 +36,10 @@ extension ElementL10n { public static let ftueAuthCarouselWelcomeTitle = ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_title") /// Enter your 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 public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device") /// Tablet @@ -94,14 +98,16 @@ extension ElementL10n { public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title") /// Failed loading messages 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 public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing") + /// GIF + public static let roomTimelineImageGif = ElementL10n.tr("Untranslated", "room_timeline_image_gif") /// Unsupported event public static let roomTimelineItemUnsupported = ElementL10n.tr("Untranslated", "room_timeline_item_unsupported") /// Failed creating the permalink 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 %@ public static func roomTimelineReplyingTo(_ p1: Any) -> String { return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1)) diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index 24bbf07a2..54cfc3614 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -94,7 +94,11 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { 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) } } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift index ea7667561..943db54ca 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -19,6 +19,7 @@ import Foundation struct AttributedStringBuilderComponent: Hashable { let attributedString: AttributedString let isBlockquote: Bool + let isReply: Bool } protocol AttributedStringBuilderProtocol { diff --git a/ElementX/Sources/Other/SwiftUI/Views/SettingsActionButton.swift b/ElementX/Sources/Other/SwiftUI/Views/SettingsActionButton.swift index e386e3a79..a17588e46 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/SettingsActionButton.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/SettingsActionButton.swift @@ -16,32 +16,26 @@ import SwiftUI -/// Small squared action button for settings screens -struct SettingsActionButton: View { - // MARK: Public - +/// Small squared action button style for settings screens +struct SettingsActionButtonStyle: ButtonStyle { let title: String - let image: Image - let action: () -> Void - - // MARK: Private @ScaledMetric private var menuIconSize = 54.0 - - // MARK: Views - var body: some View { - Button(action: action) { - VStack { - image - .renderingMode(.template) - .foregroundColor(.element.primaryContent) - .frame(width: menuIconSize, height: menuIconSize) - .background(RoundedRectangle(cornerRadius: 16).fill(Color.element.background)) - Text(title) - .foregroundColor(.element.secondaryContent) - .font(.element.subheadline) - } + func makeBody(configuration: Configuration) -> some View { + VStack { + configuration.label + .buttonStyle(.plain) + .foregroundColor(.element.primaryContent) + .frame(width: menuIconSize, height: menuIconSize) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.element.background.opacity(configuration.isPressed ? 0.5 : 1)) + } + + Text(title) + .foregroundColor(.element.secondaryContent) + .font(.element.subheadline) } } } diff --git a/ElementX/Sources/Screens/Authentication/ServerSelection/View/ServerSelectionScreen.swift b/ElementX/Sources/Screens/Authentication/ServerSelection/View/ServerSelectionScreen.swift index f972fe57d..3532cf869 100644 --- a/ElementX/Sources/Screens/Authentication/ServerSelection/View/ServerSelectionScreen.swift +++ b/ElementX/Sources/Screens/Authentication/ServerSelection/View/ServerSelectionScreen.swift @@ -31,7 +31,7 @@ struct ServerSelectionScreen: View { .readableFrame() .padding(.horizontal, 16) } - .background(Color.element.background, ignoresSafeAreaEdges: .all) + .background(Color.element.background.ignoresSafeArea()) .toolbar { toolbar } .alert(item: $context.alertInfo) { $0.alert } .interactiveDismissDisabled() diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 7bf644564..6bbe35103 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -162,8 +162,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol func presentCrashedLastRunAlert() { state.bindings.alertInfo = AlertInfo(id: UUID(), title: ElementL10n.sendBugReportAppCrashed, - primaryButton: .init(title: ElementL10n.no, action: nil), - secondaryButton: .init(title: ElementL10n.yes) { [weak self] in + primaryButton: .init(title: ElementL10n.iosNo, action: nil), + secondaryButton: .init(title: ElementL10n.iosYes) { [weak self] in self?.callback?(.presentFeedbackScreen) }) } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index e694322de..9ad315562 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -84,7 +84,7 @@ struct HomeScreen: View { userMenuButton } } - .background(Color.element.background) + .background(Color.element.background.ignoresSafeArea()) } @ViewBuilder diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift index 65c4fa6f6..13565c4a0 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift @@ -36,6 +36,7 @@ struct RoomDetailsViewState: BindableState { var title = "" var topic: String? var avatarURL: URL? + let permalink: URL? var members: [RoomDetailsMember] var isLoadingMembers: Bool { @@ -58,7 +59,6 @@ enum RoomDetailsErrorType: Hashable { enum RoomDetailsViewAction { case processTapPeople case copyRoomLink - case inviteToRoom } struct RoomDetailsMember: Identifiable, Equatable { diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift index 7e0980542..949b4db91 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift @@ -36,6 +36,7 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room", topic: roomProxy.topic, avatarURL: roomProxy.avatarURL, + permalink: roomProxy.permalink, members: [], bindings: .init()), imageProvider: mediaProvider) @@ -59,19 +60,17 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc callback?(.requestMemberDetailsPresentation(members)) case .copyRoomLink: copyRoomLink() - case .inviteToRoom: - inviteToRoom() } } // MARK: - Private private func copyRoomLink() { - // TODO: to be implemented -// ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard)) - } - - private func inviteToRoom() { - // TODO: to be implemented + if let roomLink = state.permalink { + UIPasteboard.general.url = roomLink + ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard)) + } else { + ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.unknownError)) + } } } diff --git a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift index f870a974b..6c91cdcc8 100644 --- a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift @@ -61,17 +61,21 @@ struct RoomDetailsScreen: View { .font(.element.body) .multilineTextAlignment(.center) } - - // TODO: uncomment this code to display copy link and invite buttons -// HStack(spacing: 32) { -// SettingsActionButton(title: ElementL10n.roomDetailsCopyLink, image: Image(systemName: "link")) { -// context.send(viewAction: .copyRoomLink) -// } -// SettingsActionButton(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized, image: Image(systemName: "square.and.arrow.up")) { -// context.send(viewAction: .inviteToRoom) -// } -// } -// .padding(.top, 32) + + if let permalink = context.viewState.permalink { + HStack(spacing: 32) { + Button { context.send(viewAction: .copyRoomLink) } label: { + Image(systemName: "link") + } + .buttonStyle(SettingsActionButtonStyle(title: ElementL10n.roomDetailsCopyLink)) + + ShareLink(item: permalink) { + Image(systemName: "square.and.arrow.up") + } + .buttonStyle(SettingsActionButtonStyle(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized)) + } + .padding(.top, 32) + } } .frame(maxWidth: .infinity, alignment: .center) .listRowBackground(Color.clear) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 39f198490..319240b5d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -53,6 +53,7 @@ enum RoomScreenViewAction { case cancelEdit /// Mark the entire room as read - this is heavy handed as a starting point for now. case markRoomAsRead + case retryDecryption case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction) } @@ -63,6 +64,7 @@ struct RoomScreenViewState: BindableState { var items: [RoomTimelineViewProvider] = [] var canBackPaginate = true var isBackPaginating = false + var showEncryptionBanner = false var showLoading = false var bindings: RoomScreenViewStateBindings diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 76d56b86a..164b04bf4 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -29,6 +29,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let timelineController: RoomTimelineControllerProtocol private let timelineViewFactory: RoomTimelineViewFactoryProtocol + // swiftlint:disable:next cyclomatic_complexity init(timelineController: RoomTimelineControllerProtocol, timelineViewFactory: RoomTimelineViewFactoryProtocol, mediaProvider: MediaProviderProtocol, @@ -66,6 +67,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol if self.state.isBackPaginating != isBackPaginating { self.state.isBackPaginating = isBackPaginating } + case .hasEncryptedItems(let hasEncryptedItems): + if self.state.showEncryptionBanner != hasEncryptedItems { + self.state.showEncryptionBanner = hasEncryptedItems + } } } .store(in: &cancellables) @@ -113,6 +118,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.bindings.composerText = "" case .markRoomAsRead: await markRoomAsRead() + case .retryDecryption: + retryDecryption() case .contextMenuAction(let itemID, let action): 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) { switch type { case .alert(let message): @@ -224,12 +244,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol actions.append(.redact) } - var debugActions: [TimelineItemContextMenuAction] = [.viewSource] - - if let item = timelineItem as? EncryptedRoomTimelineItem, - case let .megolmV1AesSha2(sessionId) = item.encryptionType { - debugActions.append(.retryDecryption(sessionId: sessionId)) - } + let debugActions: [TimelineItemContextMenuAction] = [.viewSource] return .init(actions: actions, debugActions: debugActions) } @@ -271,10 +286,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol let debugDescription = timelineController.debugDescription(for: item.id) MXLog.info(debugDescription) state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription) - case .retryDecryption(let sessionId): - Task { - await timelineController.retryDecryption(for: sessionId) - } } if action.switchToDefaultComposer { @@ -291,3 +302,14 @@ extension RoomScreenViewModel { mediaProvider: MockMediaProvider(), 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 + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift index 737bcde85..4b790c98a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift @@ -21,6 +21,7 @@ struct MessageComposer: View { @Binding var focused: Bool let sendingDisabled: Bool let type: RoomScreenComposerMode + @State private var isMultiline = false let sendAction: () -> Void let replyCancellationAction: () -> Void @@ -34,10 +35,11 @@ struct MessageComposer: View { MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder, text: $text, focused: $focused, + isMultiline: $isMultiline, maxHeight: 300, onEnterKeyHandler: sendAction) .tint(.element.brand) - .padding(.vertical, 12) + .padding(.vertical, 10) Button { sendAction() @@ -55,7 +57,8 @@ struct MessageComposer: View { .disabled(sendingDisabled) .animation(.elementDefault, value: sendingDisabled) .keyboardShortcut(.return, modifiers: [.command]) - .padding(8) + .padding(.trailing, 8) + .padding(.vertical, 6) } } .padding(.leading, 12.0) @@ -67,8 +70,8 @@ struct MessageComposer: View { .stroke(Color.element.quinaryContent, lineWidth: 1) .opacity(focused ? 1 : 0) } + .animation(.easeOut(duration: 0.25), value: isMultiline) } - .clipShape(roundedRectangle) .animation(.elementDefault, value: type) } @@ -98,9 +101,9 @@ struct MessageComposer: View { private var borderRadius: CGFloat { switch type { case .default: - return 28.0 + return isMultiline ? 8 : 28 case .reply, .edit: - return 12.0 + return 8 } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift index 9409f76fc..5e13010ce 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift @@ -19,29 +19,18 @@ import SwiftUI typealias OnEnterKeyHandler = () -> Void struct MessageComposerTextField: View { - @Binding private var text: String - @Binding private var focused: Bool + let placeholder: String + @Binding var text: String + @Binding var focused: Bool + @Binding var isMultiline: Bool - private let placeholder: String - private let maxHeight: CGFloat - private let onEnterKeyHandler: OnEnterKeyHandler + let maxHeight: CGFloat + let onEnterKeyHandler: OnEnterKeyHandler private var showingPlaceholder: Bool { text.isEmpty } - init(placeholder: String, - text: Binding, - focused: Binding, - maxHeight: CGFloat, - onEnterKeyHandler: @escaping OnEnterKeyHandler) { - self.placeholder = placeholder - _text = text - _focused = focused - self.maxHeight = maxHeight - self.onEnterKeyHandler = onEnterKeyHandler - } - private var placeholderColor: Color { .element.secondaryContent } @@ -49,6 +38,7 @@ struct MessageComposerTextField: View { var body: some View { UITextViewWrapper(text: $text, focused: $focused, + isMultiline: $isMultiline, maxHeight: maxHeight, onEnterKeyHandler: onEnterKeyHandler) .background(placeholderView, alignment: .topLeading) @@ -68,18 +58,22 @@ private struct UITextViewWrapper: UIViewRepresentable { @Binding var text: String @Binding var focused: Bool + @Binding var isMultiline: Bool let maxHeight: CGFloat let onEnterKeyHandler: OnEnterKeyHandler + private let font = UIFont.preferredFont(forTextStyle: .body) + func makeUIView(context: UIViewRepresentableContext) -> UITextView { let textView = TextViewWithKeyDetection() + textView.isMultiline = $isMultiline textView.delegate = context.coordinator textView.keyDelegate = context.coordinator textView.textColor = .element.primaryContent textView.isEditable = true - textView.font = .preferredFont(forTextStyle: .body) + textView.font = font textView.isSelectable = true textView.isUserInteractionEnabled = true textView.backgroundColor = UIColor.clear @@ -99,6 +93,7 @@ private struct UITextViewWrapper: UIViewRepresentable { height: CGFloat.greatestFiniteMagnitude)) let width = proposal.width ?? newSize.width let height = min(maxHeight, newSize.height) + return CGSize(width: width, height: height) } @@ -140,7 +135,10 @@ private struct UITextViewWrapper: UIViewRepresentable { private let onEnterKeyHandler: OnEnterKeyHandler - init(text: Binding, focused: Binding, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) { + init(text: Binding, + focused: Binding, + maxHeight: CGFloat, + onEnterKeyHandler: @escaping OnEnterKeyHandler) { self.text = text self.focused = focused self.maxHeight = maxHeight @@ -177,6 +175,8 @@ private protocol TextViewWithKeyDetectionDelegate: AnyObject { private class TextViewWithKeyDetection: UITextView { weak var keyDelegate: TextViewWithKeyDetectionDelegate? + var isMultiline: Binding? + override var keyCommands: [UIKeyCommand]? { [UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)), UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))] @@ -189,6 +189,23 @@ private class TextViewWithKeyDetection: UITextView { @objc func enterKeyPressed(sender: UIKeyCommand) { 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 { @@ -203,14 +220,21 @@ struct MessageComposerTextField_Previews: PreviewProvider { struct PreviewWrapper: View { @State var text: String @State var focused: Bool + @State var isMultiline: Bool init(text: String) { _text = .init(initialValue: text) _focused = .init(initialValue: false) + _isMultiline = .init(initialValue: false) } 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: { }) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 108aaee9f..da6d63222 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -23,8 +23,10 @@ struct RoomScreen: View { var body: some View { timeline - .background(Color.element.background) // Kills the toolbar translucency. - .safeAreaInset(edge: .bottom) { messageComposer } + .background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency. + .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) .toolbar { toolbar } .toolbarRole(.editor) // Hide the back button title. @@ -49,6 +51,44 @@ struct RoomScreen: View { .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 { MessageComposer(text: $context.composerText, focused: $context.composerFocused, @@ -110,7 +150,8 @@ struct RoomScreen_Previews: PreviewProvider { roomName: "Preview room") NavigationView { - RoomScreen(context: viewModel.context) + RoomScreen(context: viewModel.context).encryptionBanner + .padding() } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index db3a2f81e..97063fb03 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -117,7 +117,7 @@ struct TimelineItemBubbledStylerView: View { } } .bubbleStyle(inset: true, - color: timelineItem.isOutgoing ? .element.systemGray5 : .element.systemGray6, + color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou, cornerRadius: cornerRadius, corners: timelineItem.roundedCorners) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift index f0cca2746..99735f75b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift @@ -51,12 +51,16 @@ struct TimelineReactionButton: View { .padding(.vertical, 6) .padding(.horizontal, 8) .background( - Capsule() + backgroundShape .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) } + + var backgroundShape: some InsettableShape { + RoundedRectangle(cornerRadius: 12, style: .continuous) + } } struct TimelineReactionView_Previews: PreviewProvider { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift index be2c6e724..a75011c96 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift @@ -21,23 +21,24 @@ struct EncryptedRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - Label { - FormattedBodyText(text: timelineItem.text) - } icon: { - Image(systemName: "lock.shield") - .foregroundColor(.element.secondaryContent) - } - .labelStyle(RoomTimelineViewLabelStyle()) + Label(timelineItem.text, systemImage: "lock.shield") + .labelStyle(RoomTimelineViewLabelStyle()) } } } struct RoomTimelineViewLabelStyle: LabelStyle { + @Environment(\.timelineStyle) private var timelineStyle + func makeBody(configuration: Configuration) -> some View { - HStack(spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 8) { configuration.icon + .foregroundColor(.element.secondaryContent) configuration.title + .font(.body) + .foregroundColor(.element.primaryContent) } + .padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift index 0fba9e48a..5d9328909 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift @@ -39,7 +39,7 @@ struct FormattedBodyText: View { Text(component.attributedString.mergingAttributes(blockquoteAttributes)) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.element.tertiaryContent) - .lineLimit(3) // FIXME: Quotes vs replies + .lineLimit(component.isReply ? 3 : nil) .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) .clipped() .background(Color.element.background) @@ -47,7 +47,7 @@ struct FormattedBodyText: View { } } else { Text(component.attributedString) - .padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) // FIXME: Configurable + .padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.element.primaryContent) } @@ -65,7 +65,7 @@ struct FormattedBodyText: View { extension FormattedBodyText { init(text: String) { - attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)] + attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false, isReply: false)] } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index a10335b7c..124ab160b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -25,7 +25,9 @@ struct ImageRoomTimelineView: View { TimelineStyler(timelineItem: timelineItem) { LoadableImage(mediaSource: timelineItem.source, blurhash: timelineItem.blurhash, - imageProvider: context.imageProvider) { + imageProvider: context.imageProvider) { image in + image.overlay { overlay } + } placeholder: { placeholder } .frame(maxHeight: 300) @@ -43,6 +45,20 @@ struct ImageRoomTimelineView: View { .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 { @@ -82,7 +98,8 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { sender: .init(id: "Bob"), source: nil, aspectRatio: 0.7, - blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")) + blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW", + type: .gif)) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index 167b6839e..0c3b32a1a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -22,14 +22,21 @@ struct NoticeRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - HStack(alignment: .firstTextBaseline) { - Image(systemName: "exclamationmark.bubble").padding(.top, 2.0) + // Don't use RoomTimelineViewLabelStyle with FormattedBodyText as the formatted text + // 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 { FormattedBodyText(attributedComponents: attributedComponents) } else { FormattedBodyText(text: timelineItem.text) } } + .padding(.leading, 4) // Trailing padding is provided by FormattedBodyText } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift index 6b5fe63dd..1c8d1b281 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift @@ -21,18 +21,47 @@ struct ReadMarkerRoomTimelineView: View { let timelineItem: ReadMarkerRoomTimelineItem var body: some View { - VStack { - Spacer(minLength: 4.0) - Divider() - .frame(maxWidth: .infinity) - .overlay(Color.element.accent) + VStack(alignment: .trailing, spacing: 2) { + Text(ElementL10n.roomTimelineReadMarkerTitle) + .textCase(.uppercase) + .font(.element.caption2Bold) + .foregroundColor(.element.quaternaryContent) + Rectangle() + .frame(height: 0.5) + .foregroundColor(.element.quaternaryContent) } + .padding(.horizontal, 20) + .padding(.vertical, 16) } } struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + + static let item = ReadMarkerRoomTimelineItem() static var previews: some View { - let item = ReadMarkerRoomTimelineItem() - ReadMarkerRoomTimelineView(timelineItem: item) + 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) + + 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) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift index 0d7db67ad..07ac8760d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift @@ -22,10 +22,9 @@ struct RedactedRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - HStack { - Image(systemName: "trash") - FormattedBodyText(text: timelineItem.text) - } + Label(timelineItem.text, systemImage: "trash") + .labelStyle(RoomTimelineViewLabelStyle()) + .imageScale(.small) // Smaller icon so that the bubble remains rounded on the outside. } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift index f3fcd1f62..6fd2dcd6b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift @@ -25,8 +25,8 @@ struct StateRoomTimelineView: View { .multilineTextAlignment(.center) .foregroundColor(.element.secondaryContent) .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 16) - .padding(.vertical, 4) + .padding(.horizontal, 36) + .padding(.vertical, 8) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift index 2e10be47a..793d0dd6f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift @@ -25,17 +25,15 @@ struct UnsupportedRoomTimelineView: View { VStack(alignment: .leading) { Text("\(timelineItem.text): \(timelineItem.eventType)") .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.element.primaryContent) Text(timelineItem.error) .fixedSize(horizontal: false, vertical: true) .font(.element.footnote) - .foregroundColor(.element.primaryContent) } } icon: { - Image(systemName: "exclamationmark.bubble") - .foregroundColor(.element.secondaryContent) + Image(systemName: "exclamationmark.triangle") } + .labelStyle(RoomTimelineViewLabelStyle()) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift index a8b525d84..9ad36fc0d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift @@ -39,7 +39,6 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable { case redact case reply case viewSource - case retryDecryption(sessionId: String) var id: Self { self } @@ -97,16 +96,12 @@ public struct TimelineItemContextMenu: View { } case .redact: Button(role: .destructive) { send(action) } label: { - Label(ElementL10n.messageActionItemRedact, systemImage: "trash") + Label(ElementL10n.actionRemove, systemImage: "trash") } case .viewSource: Button { send(action) } label: { Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg") } - case .retryDecryption: - Button { send(action) } label: { - Label(ElementL10n.roomTimelineContextMenuRetryDecryption, systemImage: "arrow.down.message") - } } } } diff --git a/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift b/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift index 915f42c1d..b64b35b0f 100644 --- a/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift +++ b/ElementX/Sources/Screens/SessionVerification/View/SessionVerificationScreen.swift @@ -33,7 +33,7 @@ struct SessionVerificationScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } } - .background(Color.element.background) + .background(Color.element.background.ignoresSafeArea()) .safeAreaInset(edge: .bottom) { actionButtons.padding() } } .navigationViewStyle(.stack) diff --git a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift index 769cae791..bbf94a2e6 100644 --- a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift @@ -48,7 +48,7 @@ struct SettingsScreen: View { } .navigationTitle(ElementL10n.settings) .navigationBarTitleDisplayMode(.inline) - .background(backgroundColor, ignoresSafeAreaEdges: .all) + .background(backgroundColor.ignoresSafeArea()) .toolbar { ToolbarItem(placement: .confirmationAction) { doneButton diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 0f8cc8142..fe032335a 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -51,6 +51,8 @@ protocol RoomProxyProtocol { var avatarURL: URL? { get } + var permalink: URL? { get } + func loadAvatarURLForUserId(_ userId: String) async -> Result func loadDisplayNameForUserId(_ userId: String) async -> Result @@ -75,6 +77,17 @@ protocol 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 { await sendMessage(message, inReplyTo: nil) } diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index 803f62f82..7651d5220 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -78,9 +78,9 @@ enum RoomTimelineItemFixtures { TextRoomTimelineItem(id: UUID().uuidString, text: "", attributedComponents: [ - AttributedStringBuilderComponent(attributedString: "Hol' up", isBlockquote: false), - AttributedStringBuilderComponent(attributedString: "New home office set up!", isBlockquote: true), - AttributedStringBuilderComponent(attributedString: "That's amazing! Congrats 🥳", isBlockquote: false) + AttributedStringBuilderComponent(attributedString: "Hol' up", isBlockquote: false, isReply: false), + AttributedStringBuilderComponent(attributedString: "New home office set up!", isBlockquote: true, isReply: false), + AttributedStringBuilderComponent(attributedString: "That's amazing! Congrats 🥳", isBlockquote: false, isReply: false) ], timestamp: "5 PM", groupState: .single, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index c26fe26cb..a6bf4e73e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -218,11 +218,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol { updateTimelineItems() } - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length private func updateTimelineItems() { var newTimelineItems = [RoomTimelineItemProtocol]() var canBackPaginate = true var isBackPaginating = false + var hasEncryptedItems = false var createdIdentifiers = [String: Bool]() @@ -243,6 +244,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol { if createdIdentifiers[timelineItem.id] == nil { newTimelineItems.append(timelineItem) createdIdentifiers[timelineItem.id] = true + if timelineItem is EncryptedRoomTimelineItem { + hasEncryptedItems = true + } } else { MXLog.error("Found duplicated timeline item, ignoring") } @@ -280,6 +284,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { callbacks.send(.updatedTimelineItems) callbacks.send(.canBackPaginate(canBackPaginate)) callbacks.send(.isBackPaginating(isBackPaginating)) + callbacks.send(.hasEncryptedItems(hasEncryptedItems)) } private func computeGroupState(for itemProxy: TimelineItemProxy, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 404becf36..2f01e2c99 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -23,6 +23,7 @@ enum RoomTimelineControllerCallback { case updatedTimelineItem(_ itemId: String) case canBackPaginate(Bool) case isBackPaginating(Bool) + case hasEncryptedItems(Bool) } enum RoomTimelineControllerAction { diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift index f8b24fe0b..40281992c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift @@ -17,6 +17,7 @@ import CoreGraphics import Foundation import MatrixRustSDK +import UniformTypeIdentifiers /// A protocol that contains the base `m.room.message` event content properties. /// The `CustomStringConvertible` conformance is to redact specific properties from the logs. @@ -95,6 +96,10 @@ extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent var blurhash: String? { content.info?.blurhash } + + var type: UTType? { + content.info?.mimetype.flatMap { UTType(mimeType: $0) } + } } extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemSender.swift b/ElementX/Sources/Services/Timeline/TimelineItemSender.swift index 5fb33ee9c..265cb0030 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemSender.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemSender.swift @@ -16,11 +16,14 @@ import UIKit -#warning("This could be replaced by RoomMemberProxy if Rust includes a RoomMember.") struct TimelineItemSender: Identifiable, Hashable { let id: String + let displayName: String? + let avatarURL: URL? - // Lazy loaded properties, displayName and avatarURL will be come lets. - var displayName: String? - var avatarURL: URL? + init(id: String, displayName: String? = nil, avatarURL: URL? = nil) { + self.id = id + self.displayName = displayName + self.avatarURL = avatarURL + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index fd048c5f8..fd15b3e3d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -41,7 +41,7 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol { var isOutgoing: Bool { get } var isEditable: Bool { get } - var sender: TimelineItemSender { get set } + var sender: TimelineItemSender { get } var properties: RoomTimelineItemProperties { get } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift index 7398eeb82..6c291e9a3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift @@ -25,7 +25,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift index b3fd1915d..b2f3be140 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift @@ -24,7 +24,7 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender let source: MediaSourceProxy? let thumbnailSource: MediaSourceProxy? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index a1f7c1ee5..55f21e0f4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -15,6 +15,7 @@ // import UIKit +import UniformTypeIdentifiers struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String @@ -24,7 +25,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender let source: MediaSourceProxy? var cachedFileURL: URL? @@ -33,6 +34,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash var height: CGFloat? var aspectRatio: CGFloat? var blurhash: String? + var type: UTType? var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift index 9b33bb70c..49033a2b1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift @@ -25,7 +25,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift index c377ffb06..21f34c4f3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift @@ -25,7 +25,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index ec78178e4..f3b38b5e7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -24,7 +24,7 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender let duration: UInt64 let source: MediaSourceProxy? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift index dab71ed4f..71f9bc5f9 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift @@ -31,7 +31,7 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift index 31e4d059c..1cc302d61 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift @@ -25,7 +25,7 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, H let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift index d53b2ab85..8b241fe94 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift @@ -24,7 +24,7 @@ struct StateRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift index a93a992b8..9d1ff3382 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift @@ -24,7 +24,7 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender let imageURL: URL diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift index 0cd71e19f..3d0a04687 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift @@ -28,7 +28,7 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable let isOutgoing: Bool let isEditable: Bool - var sender: TimelineItemSender + let sender: TimelineItemSender var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 0de41131d..233d3648b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -237,6 +237,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { height: message.height, aspectRatio: aspectRatio, blurhash: message.blurhash, + type: message.type, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index a60fc3b7a..02c76adfe 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -314,6 +314,29 @@ class AttributedStringBuilderTests: XCTestCase { XCTAssertNotNil(foundBlockquoteAndLink, "Couldn't find blockquote or link") } + func testReplyBlockquote() { + let htmlString = "
In reply to @user:matrix.org
The future is swift run tools 😎
" + + 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() { let htmlString = """
First blockquote with a link in it
@@ -355,7 +378,15 @@ class AttributedStringBuilderTests: XCTestCase { 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 for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil { diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index f76deb1b8..f91cd0922 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -212,17 +212,20 @@ class LoggingTests: XCTestCase { let textAttributedString = "TextAttributed" let textMessage = TextRoomTimelineItem(id: "mytextmessage", text: "TextString", attributedComponents: [.init(attributedString: AttributedString(textAttributedString), - isBlockquote: false)], + isBlockquote: false, + isReply: false)], timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let noticeAttributedString = "NoticeAttributed" let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", text: "NoticeString", attributedComponents: [.init(attributedString: AttributedString(noticeAttributedString), - isBlockquote: false)], + isBlockquote: false, + isReply: false)], timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let emoteAttributedString = "EmoteAttributed" let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", text: "EmoteString", attributedComponents: [.init(attributedString: AttributedString(emoteAttributedString), - isBlockquote: false)], + isBlockquote: false, + isReply: false)], timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", text: "ImageString", timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, diff --git a/changelog.d/430.change b/changelog.d/430.change index e7f8cef42..8a15eed51 100644 --- a/changelog.d/430.change +++ b/changelog.d/430.change @@ -1 +1 @@ -Update the designs for the timeline. \ No newline at end of file +Finish the design review ready for a public TestFlight. \ No newline at end of file