Initial timeline design tweaks (#505)

This commit is contained in:
Doug
2023-01-31 13:27:35 +00:00
committed by GitHub
parent d51a9b3a2b
commit 17568a6a2e
31 changed files with 271 additions and 204 deletions
@@ -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";
@@ -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
@@ -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()
}
}
@@ -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 {
@@ -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")
}
@@ -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,
@@ -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<UITextViewWrapper>) {
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<CGFloat>, 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<String>
private var calculatedHeight: Binding<CGFloat>
private var focused: Binding<Bool>
private let maxHeight: CGFloat
private let onEnterKeyHandler: OnEnterKeyHandler
init(text: Binding<String>, height: Binding<CGFloat>, focused: Binding<Bool>, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) {
init(text: Binding<String>, focused: Binding<Bool>, 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.")
}
}
@@ -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)
@@ -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() {
@@ -25,6 +25,7 @@ struct TimelineItemBubbledStylerView<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
}
}
.timelineStyle(.bubbles)
.padding(.horizontal, 8)
.previewLayout(.sizeThatFits)
.environmentObject(viewModel.context)
}
}
@@ -24,7 +24,7 @@ struct TimelineItemPlainStylerView<Content: View>: 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<Content: View>: 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<Content: View>: View {
.foregroundColor(Color.element.tertiaryContent)
.font(.element.caption2)
}
.padding(.top, 16)
}
}
@@ -80,15 +81,18 @@ struct TimelineItemPlainStylerView<Content: View>: 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..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
}
}
.timelineStyle(.plain)
.padding(.horizontal, 8)
.previewLayout(.sizeThatFits)
.environmentObject(viewModel.context)
}
}
@@ -43,16 +43,16 @@ struct TimelineReactionButton: View {
var label: some View {
HStack(spacing: 4) {
Text(reaction.key)
.font(.element.caption1)
.font(.element.subheadline)
Text(String(reaction.count))
.font(.element.caption1)
.font(.element.subheadline)
.foregroundColor(.element.secondaryContent)
}
.padding(.vertical, 5)
.padding(.horizontal, 6)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
Capsule()
.strokeBorder(reaction.isHighlighted ? Color.element.accent : .element.background, lineWidth: 2)
.strokeBorder(reaction.isHighlighted ? Color.element.secondaryContent : .element.background, lineWidth: 2)
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: Capsule())
)
.accessibilityElement(children: .combine)
@@ -32,9 +32,11 @@ struct EmoteRoomTimelineView: View {
}
struct EmoteRoomTimelineView_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 {
@@ -17,46 +17,36 @@
import SwiftUI
struct EncryptedRoomTimelineView: View {
@State private var showEncryptionInfo = false
let timelineItem: EncryptedRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Button {
showEncryptionInfo = !showEncryptionInfo
} label: {
Label {
if showEncryptionInfo {
FormattedBodyText(text: encryptionDetails)
} else {
FormattedBodyText(text: timelineItem.text)
}
} icon: {
Image(systemName: "lock.shield")
.foregroundColor(.red)
}
.animation(nil, value: showEncryptionInfo)
Label {
FormattedBodyText(text: timelineItem.text)
} icon: {
Image(systemName: "lock.shield")
.foregroundColor(.element.secondaryContent)
}
.labelStyle(RoomTimelineViewLabelStyle())
}
}
private var encryptionDetails: String {
switch timelineItem.encryptionType {
case .unknown:
return "Unknown"
case .megolmV1AesSha2(let sessionId):
return "Megolm session id: \(sessionId)"
case .olmV1Curve25519AesSha2(let senderKey):
return "Olm sender key: \(senderKey)"
}
struct RoomTimelineViewLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> 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 {
@@ -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 {
@@ -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
<blockquote>
<b>bold</b> <i>italic</i>
</blockquote>
Text after blockquote
</blockquote>Text after blockquote
""",
"""
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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)
}
}
@@ -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: ""))
}
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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))
}
}
@@ -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
}
@@ -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)
}
@@ -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")
}
@@ -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,
+1
View File
@@ -0,0 +1 @@
Update the designs for the timeline.