From 17568a6a2ebb76aab4401ae19c458d2c5c69889f Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Tue, 31 Jan 2023 13:27:35 +0000 Subject: [PATCH] Initial timeline design tweaks (#505) --- .../en.lproj/Untranslated.strings | 3 + .../Generated/Strings+Untranslated.swift | 4 + .../HomeScreen/View/HomeScreenRoomCell.swift | 5 +- .../Screens/RoomScreen/RoomScreenModels.swift | 9 ++ .../RoomScreen/RoomScreenViewModel.swift | 13 ++- .../RoomScreen/View/MessageComposer.swift | 75 +++++++++++----- .../View/MessageComposerTextField.swift | 39 +++------ .../RoomScreen/View/RoomHeaderView.swift | 4 +- .../Screens/RoomScreen/View/RoomScreen.swift | 11 +-- .../Style/TimelineItemBubbledStylerView.swift | 85 +++++++++---------- .../Style/TimelineItemPlainStylerView.swift | 12 ++- .../Supplementary/TimelineReactionsView.swift | 10 +-- .../View/Timeline/EmoteRoomTimelineView.swift | 6 +- .../Timeline/EncryptedRoomTimelineView.swift | 44 ++++------ .../View/Timeline/FileRoomTimelineView.swift | 6 +- .../View/Timeline/FormattedBodyText.swift | 22 +++-- .../View/Timeline/ImageRoomTimelineView.swift | 7 +- .../Timeline/NoticeRoomTimelineView.swift | 8 +- .../Timeline/RedactedRoomTimelineView.swift | 3 + .../Timeline/SeparatorRoomTimelineView.swift | 3 +- .../View/Timeline/StateRoomTimelineView.swift | 13 ++- .../Timeline/StickerRoomTimelineView.swift | 7 +- .../View/Timeline/TextRoomTimelineView.swift | 6 +- .../UnsupportedRoomTimelineView.swift | 8 +- .../View/Timeline/VideoRoomTimelineView.swift | 7 +- .../View/TimelineItemContextMenu.swift | 34 +++++--- .../View/TimelineTableViewController.swift | 10 +-- .../RoomScreen/View/TimelineView.swift | 10 +-- .../Settings/View/SettingsScreen.swift | 8 +- .../RoomTimelineItemFactory.swift | 2 +- changelog.d/430.change | 1 + 31 files changed, 271 insertions(+), 204 deletions(-) create mode 100644 changelog.d/430.change diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 328756c4e..37311f6ad 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -4,6 +4,8 @@ "action_confirm" = "Confirm"; "action_match" = "Match"; +"new_message" = "New message"; + "screenshot_detected_title" = "You took a screenshot"; "screenshot_detected_message" = "Would you like to submit a bug report?"; @@ -20,6 +22,7 @@ "room_timeline_replying_to" = "Replying to %@"; "room_timeline_editing" = "Editing"; "room_timeline_syncing" = "Syncing"; +"room_timeline_unable_to_decrypt" = "Unable to decrypt"; "room_timeline_context_menu_retry_decryption" = "Retry decryption"; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 57c72dd68..990a1ab8f 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -40,6 +40,8 @@ extension ElementL10n { public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device") /// Tablet public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device") + /// New message + public static let newMessage = ElementL10n.tr("Untranslated", "new_message") /// %1$@ accepted the invite public static func noticeRoomInviteAccepted(_ p1: Any) -> String { return ElementL10n.tr("Untranslated", "noticeRoomInviteAccepted", String(describing: p1)) @@ -110,6 +112,8 @@ extension ElementL10n { public static let roomTimelineStylePlainLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_plain_long_description") /// Syncing public static let roomTimelineSyncing = ElementL10n.tr("Untranslated", "room_timeline_syncing") + /// Unable to decrypt + public static let roomTimelineUnableToDecrypt = ElementL10n.tr("Untranslated", "room_timeline_unable_to_decrypt") /// Would you like to submit a bug report? public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message") /// You took a screenshot diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift index 4546903cc..872a65a88 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift @@ -70,7 +70,6 @@ struct HomeScreenRoomCell: View { Text(timestamp) .font(.element.footnote) .foregroundColor(room.hasUnreads ? .element.brand : .element.tertiaryContent) - .id(timestamp) } } } @@ -85,7 +84,9 @@ struct HomeScreenRoomCell: View { if let lastMessage = room.lastMessage, !String(lastMessage.characters).isEmpty { Text(lastMessage) .lastMessageFormatting() - .id(lastMessage) + } else { + Text(ElementL10n.newMessage) + .lastMessageFormatting() } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 23fe76d0b..39f198490 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -28,6 +28,15 @@ enum RoomScreenComposerMode: Equatable { case `default` case reply(id: String, displayName: String) case edit(originalItemId: String) + + var isEdit: Bool { + switch self { + case .edit: + return true + default: + return false + } + } } enum RoomScreenViewAction { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 97d8a30e0..76d56b86a 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -211,7 +211,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } var actions: [TimelineItemContextMenuAction] = [ - .react, .copy, .quote, .copyPermalink, .reply + .react, .copy, .reply + // Disabled for FOSDEM + // .quote, .copyPermalink ] if item.isEditable { @@ -280,3 +282,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } } + +// MARK: - Mocks + +extension RoomScreenViewModel { + static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + timelineViewFactory: RoomTimelineViewFactory(), + mediaProvider: MockMediaProvider(), + roomName: "Preview room") +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift index 4570bbcc3..737bcde85 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift @@ -27,38 +27,48 @@ struct MessageComposer: View { let editCancellationAction: () -> Void var body: some View { - let rect = RoundedRectangle(cornerRadius: borderRadius) - VStack(alignment: .leading, spacing: 4.0) { + let roundedRectangle = RoundedRectangle(cornerRadius: borderRadius) + VStack(alignment: .leading, spacing: 0) { header - HStack(alignment: .center) { + HStack(alignment: .bottom) { MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder, text: $text, focused: $focused, maxHeight: 300, onEnterKeyHandler: sendAction) + .tint(.element.brand) .padding(.vertical, 12) Button { sendAction() } label: { - Image(systemName: "paperplane") - .font(.element.title3) - .foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground) - .padding(8.0) - .background( + submitButtonImage + .symbolVariant(.fill) + .font(.element.body) + .foregroundColor(sendingDisabled ? .element.quaternaryContent : .element.background) + .padding(5) + .background { Circle() - .foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground) - ) + .foregroundColor(sendingDisabled ? .clear : .element.brand) + } } .disabled(sendingDisabled) .animation(.elementDefault, value: sendingDisabled) .keyboardShortcut(.return, modifiers: [.command]) - .padding(4.0) + .padding(8) } } .padding(.leading, 12.0) - .background(.thinMaterial) - .clipShape(rect) + .background { + ZStack { + roundedRectangle + .fill(Color.element.system) + roundedRectangle + .stroke(Color.element.quinaryContent, lineWidth: 1) + .opacity(focused ? 1 : 0) + } + } + .clipShape(roundedRectangle) .animation(.elementDefault, value: type) } @@ -74,6 +84,17 @@ struct MessageComposer: View { } } + private var submitButtonImage: some View { + // ZStack with opacity so the button size is consistent. + ZStack { + Image(systemName: "checkmark") + .opacity(type.isEdit ? 1 : 0) + .fontWeight(.medium) + Image(systemName: "paperplane") + .opacity(type.isEdit ? 0 : 1) + } + } + private var borderRadius: CGFloat { switch type { case .default: @@ -90,18 +111,18 @@ private struct MessageComposerReplyHeader: View { var body: some View { HStack(alignment: .center) { - Label(ElementL10n.roomTimelineReplyingTo(displayName), systemImage: "arrow.uturn.left") - .font(.element.caption2) + Label(ElementL10n.roomTimelineReplyingTo(displayName), systemImage: "arrowshape.turn.up.left") + .font(.element.caption1) .foregroundColor(.element.secondaryContent) .lineLimit(1) Spacer() Button { action() } label: { - Image(systemName: "x.circle") - .font(.element.callout) + Image(systemName: "xmark") + .font(.element.caption2.weight(.medium)) .foregroundColor(.element.secondaryContent) - .padding(4.0) + .padding(12.0) } } } @@ -112,18 +133,18 @@ private struct MessageComposerEditHeader: View { var body: some View { HStack(alignment: .center) { - Label(ElementL10n.roomTimelineEditing, systemImage: "pencil") - .font(.element.caption2) + Label(ElementL10n.roomTimelineEditing, systemImage: "pencil.line") + .font(.element.caption1) .foregroundColor(.element.secondaryContent) .lineLimit(1) Spacer() Button { action() } label: { - Image(systemName: "x.circle") - .font(.element.callout) + Image(systemName: "xmark") + .font(.element.caption2.weight(.medium)) .foregroundColor(.element.secondaryContent) - .padding(4.0) + .padding(12.0) } } } @@ -140,6 +161,14 @@ struct MessageComposer_Previews: PreviewProvider { replyCancellationAction: { }, editCancellationAction: { }) + MessageComposer(text: .constant("This is a short message."), + focused: .constant(false), + sendingDisabled: false, + type: .default, + sendAction: { }, + replyCancellationAction: { }, + editCancellationAction: { }) + MessageComposer(text: .constant("This is a very long message that will wrap to 2 lines on an iPhone 14."), focused: .constant(false), sendingDisabled: false, diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift index bc9441d63..9409f76fc 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift @@ -22,8 +22,6 @@ struct MessageComposerTextField: View { @Binding private var text: String @Binding private var focused: Bool - @State private var dynamicHeight: CGFloat = 100 - private let placeholder: String private let maxHeight: CGFloat private let onEnterKeyHandler: OnEnterKeyHandler @@ -50,11 +48,9 @@ struct MessageComposerTextField: View { var body: some View { UITextViewWrapper(text: $text, - calculatedHeight: $dynamicHeight, focused: $focused, maxHeight: maxHeight, onEnterKeyHandler: onEnterKeyHandler) - .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) .background(placeholderView, alignment: .topLeading) } @@ -71,7 +67,6 @@ private struct UITextViewWrapper: UIViewRepresentable { typealias UIViewType = UITextView @Binding var text: String - @Binding var calculatedHeight: CGFloat @Binding var focused: Bool let maxHeight: CGFloat @@ -84,7 +79,7 @@ private struct UITextViewWrapper: UIViewRepresentable { textView.keyDelegate = context.coordinator textView.textColor = .element.primaryContent textView.isEditable = true - textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.font = .preferredFont(forTextStyle: .body) textView.isSelectable = true textView.isUserInteractionEnabled = true textView.backgroundColor = UIColor.clear @@ -97,6 +92,15 @@ private struct UITextViewWrapper: UIViewRepresentable { return textView } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + // Note: Coalescing a width of zero here returns a size for the view with 1 line of text visible. + let newSize = uiView.sizeThatFits(CGSize(width: proposal.width ?? .zero, + height: CGFloat.greatestFiniteMagnitude)) + let width = proposal.width ?? newSize.width + let height = min(maxHeight, newSize.height) + return CGSize(width: width, height: height) + } func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext) { if textView.text != text { @@ -114,8 +118,6 @@ private struct UITextViewWrapper: UIViewRepresentable { } } - UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight) - DispatchQueue.main.async { // Avoid cycle detected through attribute warnings if focused, textView.window != nil, !textView.isFirstResponder { textView.becomeFirstResponder() @@ -125,36 +127,21 @@ private struct UITextViewWrapper: UIViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator(text: $text, - height: $calculatedHeight, focused: $focused, maxHeight: maxHeight, onEnterKeyHandler: onEnterKeyHandler) } - fileprivate static func recalculateHeight(view: UIView, result: Binding, maxHeight: CGFloat) { - let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude)) - - let height = min(maxHeight, newSize.height) - - if result.wrappedValue != height { - DispatchQueue.main.async { - result.wrappedValue = height // Must be called asynchronously - } - } - } - final class Coordinator: NSObject, UITextViewDelegate, TextViewWithKeyDetectionDelegate { private var text: Binding - private var calculatedHeight: Binding private var focused: Binding private let maxHeight: CGFloat private let onEnterKeyHandler: OnEnterKeyHandler - init(text: Binding, height: Binding, focused: Binding, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) { + init(text: Binding, focused: Binding, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) { self.text = text - calculatedHeight = height self.focused = focused self.maxHeight = maxHeight self.onEnterKeyHandler = onEnterKeyHandler @@ -162,9 +149,6 @@ private struct UITextViewWrapper: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text - UITextViewWrapper.recalculateHeight(view: textView, - result: calculatedHeight, - maxHeight: maxHeight) } func textViewDidBeginEditing(_ textView: UITextView) { @@ -212,6 +196,7 @@ struct MessageComposerTextField_Previews: PreviewProvider { VStack { PreviewWrapper(text: "123") PreviewWrapper(text: "") + PreviewWrapper(text: "A really long message that will wrap to multiple lines on a phone in portrait.") } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift index 7f1f2c289..b375655b3 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift @@ -24,13 +24,15 @@ struct RoomHeaderView: View { @ObservedObject var context: RoomScreenViewModel.Context var body: some View { - HStack(spacing: 8) { + HStack(spacing: 12) { roomAvatar .accessibilityHidden(true) Text(context.viewState.roomTitle) .font(.element.headline) .accessibilityIdentifier("roomNameLabel") } + // Leading align whilst using the principal toolbar position. + .frame(maxWidth: .infinity, alignment: .leading) // Using a button stops is from getting truncated in the navigation bar .onTapGesture { context.send(viewAction: .displayRoomDetails) diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index bee5b570a..927fc0d35 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -23,9 +23,12 @@ struct RoomScreen: View { var body: some View { timeline + .background(Color.element.background) // Kills the toolbar translucency. .safeAreaInset(edge: .bottom) { messageComposer } .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } + .toolbarRole(.editor) // Hide the back button title. + .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. .overlay { loadingIndicator } .alert(item: $context.alertInfo) { $0.alert } .sheet(item: $context.debugInfo) { DebugScreen(info: $0) } @@ -89,14 +92,6 @@ struct RoomScreen: View { ToolbarItem(placement: .principal) { RoomHeaderView(context: context) } - - ToolbarItem(id: "RoomDetailsAction", placement: .secondaryAction, showsByDefault: false) { - Button { - context.send(viewAction: .displayRoomDetails) - } label: { - Label("Room Details", systemImage: "info.circle") - } - } } private func sendMessage() { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index fd78cef66..ed826f28d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -25,6 +25,7 @@ struct TimelineItemBubbledStylerView: View { @Environment(\.colorScheme) private var colorScheme @ScaledMetric private var senderNameVerticalPadding = 3 + private let cornerRadius: CGFloat = 12 var body: some View { ZStack(alignment: .trailingFirstTextBaseline) { @@ -39,7 +40,7 @@ struct TimelineItemBubbledStylerView: View { Spacer() } - styledContentWithReactions + messageBubbleWithReactions } .padding(.horizontal, 16.0) .padding(timelineItem.isOutgoing ? .leading : .trailing, 40) // Extra padding to differentiate alignment. @@ -71,11 +72,11 @@ struct TimelineItemBubbledStylerView: View { } } - private var styledContentWithReactions: some View { + private var messageBubbleWithReactions: some View { // Figma has a spacing of -4 but it doesn't take into account // the centre aligned stroke width so we use -5 here VStack(alignment: alignment, spacing: -5) { - styledContent + messageBubble .accessibilityElement(children: .combine) if !timelineItem.properties.reactions.isEmpty { @@ -86,24 +87,25 @@ struct TimelineItemBubbledStylerView: View { } } } - + + var messageBubble: some View { + styledContent + .contentShape(.contextMenuPreview, RoundedCornerShape(radius: cornerRadius, corners: timelineItem.roundedCorners)) // Rounded corners for the context menu animation. + .contextMenu { [weak context] in + context?.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in + TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions) + } + } + .padding(.top, messageBubbleTopPadding) + } + @ViewBuilder var styledContent: some View { - if timelineItem.isOutgoing { - styledContentOutgoing - } else { - styledContentIncoming - } - } - - @ViewBuilder - var styledContentOutgoing: some View { - let topPadding: CGFloat? = timelineItem.groupState == .single || timelineItem.groupState == .beginning ? 8 : 0 - if shouldAvoidBubbling { content() - .cornerRadius(12, corners: timelineItem.roundedCorners) - .padding(.top, topPadding) + .bubbleStyle(inset: false, + cornerRadius: cornerRadius, + corners: timelineItem.roundedCorners) } else { VStack(alignment: .trailing, spacing: 4) { content() @@ -114,36 +116,20 @@ struct TimelineItemBubbledStylerView: View { .foregroundColor(.element.tertiaryContent) } } - .padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) - .background(Color.element.systemGray5) - .cornerRadius(12, corners: timelineItem.roundedCorners) - .padding(.top, topPadding) + .bubbleStyle(inset: true, + color: timelineItem.isOutgoing ? .element.systemGray5 : .element.systemGray6, + cornerRadius: cornerRadius, + corners: timelineItem.roundedCorners) } } - - @ViewBuilder - var styledContentIncoming: some View { - if shouldAvoidBubbling { - content() - .cornerRadius(12, corners: timelineItem.roundedCorners) - } else { - VStack(alignment: .trailing, spacing: 4) { - content() - - if timelineItem.properties.isEdited { - Text(ElementL10n.editedSuffix) - .font(.element.caption2) - .foregroundColor(.element.tertiaryContent) - } - } - .padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) - .background(Color.element.systemGray6) - .cornerRadius(12, corners: timelineItem.roundedCorners) - } + + private var messageBubbleTopPadding: CGFloat { + guard timelineItem.isOutgoing else { return 0 } + return timelineItem.groupState == .single || timelineItem.groupState == .beginning ? 8 : 0 } private var shouldAvoidBubbling: Bool { - timelineItem is ImageRoomTimelineItem || timelineItem is VideoRoomTimelineItem + timelineItem is ImageRoomTimelineItem || timelineItem is VideoRoomTimelineItem || timelineItem is StickerRoomTimelineItem } private var alignment: HorizontalAlignment { @@ -151,16 +137,27 @@ struct TimelineItemBubbledStylerView: View { } } +private extension View { + func bubbleStyle(inset: Bool, color: Color? = nil, cornerRadius: CGFloat, corners: UIRectCorner) -> some View { + padding(inset ? 8 : 0) + .background(inset ? color : nil) + .cornerRadius(cornerRadius, corners: corners) + } +} + struct TimelineItemBubbledStylerView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 0) { ForEach(1..: View { @ViewBuilder let content: () -> Content var body: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { header VStack(alignment: .leading, spacing: 4) { @@ -48,7 +48,7 @@ struct TimelineItemPlainStylerView: View { HStack { TimelineSenderAvatarView(timelineItem: timelineItem) Text(timelineItem.sender.displayName ?? timelineItem.sender.id) - .font(.body) + .font(.subheadline) .foregroundColor(.element.primaryContent) .fontWeight(.semibold) .lineLimit(1) @@ -57,6 +57,7 @@ struct TimelineItemPlainStylerView: View { .foregroundColor(Color.element.tertiaryContent) .font(.element.caption2) } + .padding(.top, 16) } } @@ -80,15 +81,18 @@ struct TimelineItemPlainStylerView: View { } struct TimelineItemPlainStylerView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { ForEach(1.. some View { + HStack(spacing: 8) { + configuration.icon + configuration.title } } } struct EncryptedRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift index e91373831..b53abd23b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift @@ -34,9 +34,11 @@ struct FileRoomTimelineView: View { } struct FileRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift index b5a05842e..0fba9e48a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift @@ -38,14 +38,16 @@ struct FormattedBodyText: View { } else { Text(component.attributedString.mergingAttributes(blockquoteAttributes)) .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.element.primaryContent) - .padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + .foregroundColor(.element.tertiaryContent) + .lineLimit(3) // FIXME: Quotes vs replies + .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) .clipped() - .background(Color.element.systemGray4) - .cornerRadius(13) + .background(Color.element.background) + .cornerRadius(8) } } else { Text(component.attributedString) + .padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) // FIXME: Configurable .fixedSize(horizontal: false, vertical: true) .foregroundColor(.element.primaryContent) } @@ -56,7 +58,7 @@ struct FormattedBodyText: View { private var blockquoteAttributes: AttributeContainer { var container = AttributeContainer() - container.font = .element.caption1 + container.font = .element.subheadline return container } } @@ -70,7 +72,12 @@ extension FormattedBodyText { struct FormattedBodyText_Previews: PreviewProvider { static var previews: some View { body - body.timelineStyle(.plain) + .padding(8) + .background(Color.element.systemGray6) + .cornerRadius(12) + + body + .timelineStyle(.plain) } @ViewBuilder @@ -80,8 +87,7 @@ struct FormattedBodyText_Previews: PreviewProvider { Text before blockquote
bold italic -
- Text after blockquote + Text after blockquote """, """
First blockquote with a link in it
diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 604abefb3..a10335b7c 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -28,6 +28,7 @@ struct ImageRoomTimelineView: View { imageProvider: context.imageProvider) { placeholder } + .frame(maxHeight: 300) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } } @@ -45,9 +46,11 @@ struct ImageRoomTimelineView: View { } struct ImageRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index 09fa1aae7..167b6839e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -22,7 +22,7 @@ struct NoticeRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - HStack(alignment: .top) { + HStack(alignment: .firstTextBaseline) { Image(systemName: "exclamationmark.bubble").padding(.top, 2.0) if let attributedComponents = timelineItem.attributedComponents { FormattedBodyText(attributedComponents: attributedComponents) @@ -35,9 +35,11 @@ struct NoticeRoomTimelineView: View { } struct NoticeRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift index daf16497a..0d7db67ad 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift @@ -31,12 +31,15 @@ struct RedactedRoomTimelineView: View { } struct RedactedRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { VStack(alignment: .leading, spacing: 20.0) { RedactedRoomTimelineView(timelineItem: itemWith(text: ElementL10n.eventRedacted, timestamp: "Later", senderId: "Anne")) } + .environmentObject(viewModel.context) } private static func itemWith(text: String, timestamp: String, senderId: String) -> RedactedRoomTimelineItem { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift index 4aec204eb..3a205d80d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/SeparatorRoomTimelineView.swift @@ -23,7 +23,8 @@ struct SeparatorRoomTimelineView: View { Text(timelineItem.text) .font(.element.footnote) .foregroundColor(.element.secondaryContent) - .padding(.vertical, 24) + .padding(.top, 12) + .padding(.bottom, 8) .frame(maxWidth: .infinity) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift index d558bf4e4..f3fcd1f62 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift @@ -21,7 +21,7 @@ struct StateRoomTimelineView: View { var body: some View { Text(timelineItem.text) - .font(.element.caption1Bold) + .font(.element.footnote) .multilineTextAlignment(.center) .foregroundColor(.element.secondaryContent) .frame(maxWidth: .infinity, alignment: .center) @@ -37,7 +37,14 @@ struct StateRoomTimelineView_Previews: PreviewProvider { } static var body: some View { - let item = StateRoomTimelineItem(id: UUID().uuidString, text: "Alice joined", timestamp: "Now", groupState: .beginning, isOutgoing: false, isEditable: false, sender: .init(id: "")) - return StateRoomTimelineView(timelineItem: item) + StateRoomTimelineView(timelineItem: item) } + + static let item = StateRoomTimelineItem(id: UUID().uuidString, + text: "Alice joined", + timestamp: "Now", + groupState: .beginning, + isOutgoing: false, + isEditable: false, + sender: .init(id: "")) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift index 49c4184d6..000c4daa3 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift @@ -28,6 +28,7 @@ struct StickerRoomTimelineView: View { imageProvider: context.imageProvider) { placeholder } + .frame(maxHeight: 300) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } .accessibilityLabel(timelineItem.text) @@ -46,9 +47,11 @@ struct StickerRoomTimelineView: View { } struct StickerRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index a8bf63e8b..cc202b0d6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -32,9 +32,11 @@ struct TextRoomTimelineView: View { } struct TextRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift index f127c823d..2e10be47a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift @@ -34,16 +34,18 @@ struct UnsupportedRoomTimelineView: View { } } icon: { Image(systemName: "exclamationmark.bubble") - .foregroundColor(.red) + .foregroundColor(.element.secondaryContent) } } } } struct UnsupportedRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index 3905dd557..b0b9c9b13 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -31,6 +31,7 @@ struct VideoRoomTimelineView: View { } placeholder: { placeholder } + .frame(maxHeight: 300) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) } } @@ -56,9 +57,11 @@ struct VideoRoomTimelineView: View { } struct VideoRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + static var previews: some View { - body - body.timelineStyle(.plain) + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) } static var body: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift index 8d6360d04..a8b525d84 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift @@ -54,8 +54,10 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable { } public struct TimelineItemContextMenu: View { + @EnvironmentObject private var context: RoomScreenViewModel.Context + + let itemID: String let contextMenuActions: TimelineItemContextMenuActions - let callback: (TimelineItemContextMenuAction) -> Void public var body: some View { viewsForActions(contextMenuActions.actions) @@ -67,45 +69,49 @@ public struct TimelineItemContextMenu: View { } private func viewsForActions(_ actions: [TimelineItemContextMenuAction]) -> some View { - ForEach(actions, id: \.self) { item in - switch item { + ForEach(actions, id: \.self) { action in + switch action { case .react: - Button { callback(item) } label: { + Button { send(action) } label: { Label(ElementL10n.reactions, systemImage: "face.smiling") } case .copy: - Button { callback(item) } label: { + Button { send(action) } label: { Label(ElementL10n.actionCopy, systemImage: "doc.on.doc") } case .edit: - Button { callback(item) } label: { - Label(ElementL10n.edit, systemImage: "pencil") + Button { send(action) } label: { + Label(ElementL10n.edit, systemImage: "pencil.line") } case .quote: - Button { callback(item) } label: { + Button { send(action) } label: { Label(ElementL10n.actionQuote, systemImage: "quote.bubble") } case .copyPermalink: - Button { callback(item) } label: { + Button { send(action) } label: { Label(ElementL10n.permalink, systemImage: "link") } case .reply: - Button { callback(item) } label: { - Label(ElementL10n.reply, systemImage: "arrow.uturn.left") + Button { send(action) } label: { + Label(ElementL10n.reply, systemImage: "arrowshape.turn.up.left") } case .redact: - Button(role: .destructive) { callback(item) } label: { + Button(role: .destructive) { send(action) } label: { Label(ElementL10n.messageActionItemRedact, systemImage: "trash") } case .viewSource: - Button { callback(item) } label: { + Button { send(action) } label: { Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg") } case .retryDecryption: - Button { callback(item) } label: { + Button { send(action) } label: { Label(ElementL10n.roomTimelineContextMenuRetryDecryption, systemImage: "arrow.down.message") } } } } + + private func send(_ action: TimelineItemContextMenuAction) { + context.send(viewAction: .contextMenuAction(itemID: itemID, action: action)) + } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift index c43184c0c..e569db060 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift @@ -108,6 +108,7 @@ class TimelineTableViewController: UIViewController { tableView.separatorStyle = .none tableView.allowsSelection = false tableView.keyboardDismissMode = .onDrag + tableView.backgroundColor = .element.background view.addSubview(tableView) scrollToBottomPublisher @@ -189,7 +190,6 @@ class TimelineTableViewController: UIViewController { // A local reference to avoid capturing self in the cell configuration. let coordinator = self.coordinator let opacity = self.opacity(for: timelineItem) - let contextMenuActionProvider = self.contextMenuActionProvider cell.item = timelineItem cell.contentConfiguration = UIHostingConfiguration { @@ -197,13 +197,6 @@ class TimelineTableViewController: UIViewController { .id(timelineItem.id) .frame(maxWidth: .infinity, alignment: .leading) .opacity(opacity) - .contextMenu { - contextMenuActionProvider?(timelineItem.id).map { actions in - TimelineItemContextMenu(contextMenuActions: actions) { action in - coordinator.send(viewAction: .contextMenuAction(itemID: timelineItem.id, action: action)) - } - } - } .onAppear { coordinator.send(viewAction: .itemAppeared(id: timelineItem.id)) } @@ -223,6 +216,7 @@ class TimelineTableViewController: UIViewController { } .margins(.all, self.timelineStyle.rowInsets) .minSize(height: 1) + .background(Color.clear) return cell } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 88862a914..886cacfcc 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -82,12 +82,12 @@ struct TimelineView: UIViewControllerRepresentable { // MARK: - Previews struct TimelineTableView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + timelineViewFactory: RoomTimelineViewFactory(), + mediaProvider: MockMediaProvider(), + roomName: "Preview room") + static var previews: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), - mediaProvider: MockMediaProvider(), - roomName: "Preview room") - NavigationView { RoomScreen(context: viewModel.context) } diff --git a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift index bdf100deb..647646920 100644 --- a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift @@ -50,8 +50,8 @@ struct SettingsScreen: View { .navigationBarTitleDisplayMode(.inline) .background(backgroundColor, ignoresSafeAreaEdges: .all) .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - closeButton + ToolbarItem(placement: .confirmationAction) { + doneButton } } } @@ -152,8 +152,8 @@ struct SettingsScreen: View { } } - private var closeButton: some View { - Button(ElementL10n.actionCancel, action: close) + private var doneButton: some View { + Button(ElementL10n.done, action: close) .accessibilityIdentifier("closeButton") } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 4ac4df5e7..ecf8884aa 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -152,7 +152,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } return EncryptedRoomTimelineItem(id: eventItemProxy.id, - text: ElementL10n.encryptionInformationDecryptionError, + text: ElementL10n.roomTimelineUnableToDecrypt, encryptionType: encryptionType, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), groupState: groupState, diff --git a/changelog.d/430.change b/changelog.d/430.change new file mode 100644 index 000000000..e7f8cef42 --- /dev/null +++ b/changelog.d/430.change @@ -0,0 +1 @@ +Update the designs for the timeline. \ No newline at end of file