Composer mode UI updates (#984)

* Fixes #975 - Update editing message UI

* Fixes #976 - Update reply composer mode UI to include message being replied to

* Use different icon corner radii in the timeline reply view depending on its placement
This commit is contained in:
Stefan Ceriu
2023-05-31 14:51:51 +03:00
committed by GitHub
parent 87075eecfd
commit 83e3d828cd
19 changed files with 230 additions and 93 deletions
@@ -31,7 +31,7 @@ enum RoomScreenViewModelAction {
enum RoomScreenComposerMode: Equatable { enum RoomScreenComposerMode: Equatable {
case `default` case `default`
case reply(id: String, displayName: String) case reply(itemID: String, replyDetails: TimelineItemReplyDetails)
case edit(originalItemId: String) case edit(originalItemId: String)
var isEdit: Bool { var isEdit: Bool {
@@ -339,7 +339,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
case .reply: case .reply:
state.bindings.composerFocused = true state.bindings.composerFocused = true
state.composerMode = .reply(id: eventTimelineItem.id, displayName: eventTimelineItem.sender.displayName ?? eventTimelineItem.sender.id)
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: buildReplyContent(for: eventTimelineItem))
state.composerMode = .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails)
case .viewSource: case .viewSource:
let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id) let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id)
MXLog.info(debugInfo) MXLog.info(debugInfo)
@@ -410,6 +413,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
} }
private func buildReplyContent(for item: EventBasedTimelineItemProtocol) -> EventBasedMessageTimelineItemContentType {
guard let messageItem = item as? EventBasedMessageTimelineItemProtocol else {
return .text(.init(body: item.body))
}
return messageItem.contentType
}
} }
// MARK: - Mocks // MARK: - Mocks
@@ -20,7 +20,7 @@ struct MessageComposer: View {
@Binding var text: String @Binding var text: String
@Binding var focused: Bool @Binding var focused: Bool
let sendingDisabled: Bool let sendingDisabled: Bool
let type: RoomScreenComposerMode let mode: RoomScreenComposerMode
let sendAction: EnterKeyHandler let sendAction: EnterKeyHandler
let pasteAction: PasteHandler let pasteAction: PasteHandler
@@ -75,14 +75,14 @@ struct MessageComposer: View {
} }
// Explicitly disable all animations to fix weirdness with the header immediately // Explicitly disable all animations to fix weirdness with the header immediately
// appearing whilst the text field and keyboard are still animating up to it. // appearing whilst the text field and keyboard are still animating up to it.
.animation(.noAnimation, value: type) .animation(.noAnimation, value: mode)
} }
@ViewBuilder @ViewBuilder
private var header: some View { private var header: some View {
switch type { switch mode {
case .reply(_, let displayName): case .reply(_, let replyDetails):
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction) MessageComposerReplyHeader(replyDetails: replyDetails, action: replyCancellationAction)
case .edit: case .edit:
MessageComposerEditHeader(action: editCancellationAction) MessageComposerEditHeader(action: editCancellationAction)
case .default: case .default:
@@ -94,22 +94,22 @@ struct MessageComposer: View {
// ZStack with opacity so the button size is consistent. // ZStack with opacity so the button size is consistent.
ZStack { ZStack {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.opacity(type.isEdit ? 1 : 0) .opacity(mode.isEdit ? 1 : 0)
.fontWeight(.medium) .fontWeight(.medium)
.accessibilityLabel(L10n.actionConfirm) .accessibilityLabel(L10n.actionConfirm)
.accessibilityHidden(!type.isEdit) .accessibilityHidden(!mode.isEdit)
Image(asset: Asset.Images.timelineComposerSendMessage) Image(asset: Asset.Images.timelineComposerSendMessage)
.resizable() .resizable()
.frame(width: sendButtonIconSize, height: sendButtonIconSize) .frame(width: sendButtonIconSize, height: sendButtonIconSize)
.padding(EdgeInsets(top: 7, leading: 8, bottom: 7, trailing: 6)) .padding(EdgeInsets(top: 7, leading: 8, bottom: 7, trailing: 6))
.opacity(type.isEdit ? 0 : 1) .opacity(mode.isEdit ? 0 : 1)
.accessibilityLabel(L10n.actionSend) .accessibilityLabel(L10n.actionSend)
.accessibilityHidden(type.isEdit) .accessibilityHidden(mode.isEdit)
} }
} }
private var borderRadius: CGFloat { private var borderRadius: CGFloat {
switch type { switch mode {
case .default: case .default:
return isMultiline ? 20 : 28 return isMultiline ? 20 : 28
case .reply, .edit: case .reply, .edit:
@@ -119,21 +119,25 @@ struct MessageComposer: View {
} }
private struct MessageComposerReplyHeader: View { private struct MessageComposerReplyHeader: View {
let displayName: String let replyDetails: TimelineItemReplyDetails
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
HStack(alignment: .center) { TimelineReplyView(placement: .composer, timelineItemReplyDetails: replyDetails)
Label(L10n.commonReplyingTo(displayName), systemImage: "arrowshape.turn.up.left") .frame(maxWidth: .infinity, alignment: .leading)
.labelStyle(MessageComposerHeaderLabelStyle()) .padding(4.0)
Spacer() .background(.white)
Button(action: action) { .cornerRadius(13.0)
Image(systemName: "xmark") .padding([.trailing, .vertical], 8.0)
.font(.caption2.weight(.medium)) .padding([.leading], -4.0)
.foregroundColor(.element.secondaryContent) .overlay(alignment: .topTrailing) {
.padding(12.0) Button(action: action) {
Image(systemName: "xmark")
.font(.caption2.weight(.medium))
.foregroundColor(.element.tertiaryContent)
.padding(16.0)
}
} }
}
} }
} }
@@ -148,7 +152,7 @@ private struct MessageComposerEditHeader: View {
Button(action: action) { Button(action: action) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundColor(.element.secondaryContent) .foregroundColor(.element.tertiaryContent)
.padding(12.0) .padding(12.0)
} }
} }
@@ -161,19 +165,21 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
configuration.icon configuration.icon
configuration.title configuration.title
} }
.font(.compound.bodyXS) .font(.compound.bodySM)
.foregroundColor(.element.secondaryContent) .foregroundColor(.element.secondaryContent)
.lineLimit(1) .lineLimit(1)
} }
} }
struct MessageComposer_Previews: PreviewProvider { struct MessageComposer_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View { static var previews: some View {
VStack { VStack {
MessageComposer(text: .constant(""), MessageComposer(text: .constant(""),
focused: .constant(false), focused: .constant(false),
sendingDisabled: true, sendingDisabled: true,
type: .default, mode: .default,
sendAction: { }, sendAction: { },
pasteAction: { _ in }, pasteAction: { _ in },
replyCancellationAction: { }, replyCancellationAction: { },
@@ -182,7 +188,7 @@ struct MessageComposer_Previews: PreviewProvider {
MessageComposer(text: .constant("This is a short message."), MessageComposer(text: .constant("This is a short message."),
focused: .constant(false), focused: .constant(false),
sendingDisabled: false, sendingDisabled: false,
type: .default, mode: .default,
sendAction: { }, sendAction: { },
pasteAction: { _ in }, pasteAction: { _ in },
replyCancellationAction: { }, replyCancellationAction: { },
@@ -191,7 +197,7 @@ struct MessageComposer_Previews: PreviewProvider {
MessageComposer(text: .constant("This is a very long message that will wrap to 2 lines on an iPhone 14."), MessageComposer(text: .constant("This is a very long message that will wrap to 2 lines on an iPhone 14."),
focused: .constant(false), focused: .constant(false),
sendingDisabled: false, sendingDisabled: false,
type: .default, mode: .default,
sendAction: { }, sendAction: { },
pasteAction: { _ in }, pasteAction: { _ in },
replyCancellationAction: { }, replyCancellationAction: { },
@@ -200,7 +206,7 @@ struct MessageComposer_Previews: PreviewProvider {
MessageComposer(text: .constant("This is an even longer message that will wrap to 3 lines on an iPhone 14, just to see the difference it makes."), MessageComposer(text: .constant("This is an even longer message that will wrap to 3 lines on an iPhone 14, just to see the difference it makes."),
focused: .constant(false), focused: .constant(false),
sendingDisabled: false, sendingDisabled: false,
type: .default, mode: .default,
sendAction: { }, sendAction: { },
pasteAction: { _ in }, pasteAction: { _ in },
replyCancellationAction: { }, replyCancellationAction: { },
@@ -209,22 +215,47 @@ struct MessageComposer_Previews: PreviewProvider {
MessageComposer(text: .constant("Some message"), MessageComposer(text: .constant("Some message"),
focused: .constant(false), focused: .constant(false),
sendingDisabled: false, sendingDisabled: false,
type: .reply(id: UUID().uuidString, mode: .edit(originalItemId: UUID().uuidString),
displayName: "John Doe"),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant("Some message"),
focused: .constant(false),
sendingDisabled: false,
type: .edit(originalItemId: UUID().uuidString),
sendAction: { }, sendAction: { },
pasteAction: { _ in }, pasteAction: { _ in },
replyCancellationAction: { }, replyCancellationAction: { },
editCancellationAction: { }) editCancellationAction: { })
} }
.padding(.horizontal) .padding(.horizontal)
ScrollView {
VStack {
let replyTypes: [TimelineItemReplyDetails] = [
.loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, source: nil, contentType: nil))),
.loaded(sender: .init(id: "James"), contentType: .emote(.init(body: "Emote: James thinks he's the phantom lord"))),
.loaded(sender: .init(id: "Robert"), contentType: .file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil))),
.loaded(sender: .init(id: "Cliff"), contentType: .image(.init(body: "Image: Pushead",
source: .init(url: .picturesDirectory, mimeType: nil),
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))),
.loaded(sender: .init(id: "Jason"), contentType: .notice(.init(body: "Notice: Too far gone?"))),
.loaded(sender: .init(id: "Kirk"), contentType: .text(.init(body: "Text: Where the wild things are"))),
.loaded(sender: .init(id: "Lars"), contentType: .video(.init(body: "Video: Through the never",
duration: 100,
source: nil,
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))),
.loading(eventID: "")
]
ForEach(replyTypes, id: \.self) { replyDetails in
MessageComposer(text: .constant(""),
focused: .constant(false),
sendingDisabled: false,
mode: .reply(itemID: UUID().uuidString,
replyDetails: replyDetails),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
}
}
}
.padding(.horizontal)
.environmentObject(viewModel.context)
.previewDisplayName("Replying")
} }
} }
@@ -16,7 +16,13 @@
import SwiftUI import SwiftUI
enum TimelineReplyViewPlacement {
case timeline
case composer
}
struct TimelineReplyView: View { struct TimelineReplyView: View {
let placement: TimelineReplyViewPlacement
let timelineItemReplyDetails: TimelineItemReplyDetails? let timelineItemReplyDetails: TimelineItemReplyDetails?
var body: some View { var body: some View {
@@ -25,19 +31,37 @@ struct TimelineReplyView: View {
case .loaded(let sender, let content): case .loaded(let sender, let content):
switch content { switch content {
case .audio(let content): case .audio(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: nil, systemIconName: "waveform") ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(systemIconName: "waveform", cornerRadii: iconCornerRadii))
case .emote(let content): case .emote(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: content.formattedBody) ReplyView(sender: sender,
plainBody: content.body,
formattedBody: content.formattedBody)
case .file(let content): case .file(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: nil, systemIconName: "doc.text.fill") ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(systemIconName: "doc.text.fill", cornerRadii: iconCornerRadii))
case .image(let content): case .image(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: nil, mediaSource: content.thumbnailSource ?? content.source) ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(mediaSource: content.thumbnailSource ?? content.source, cornerRadii: iconCornerRadii))
case .notice(let content): case .notice(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: content.formattedBody) ReplyView(sender: sender,
plainBody: content.body,
formattedBody: content.formattedBody)
case .text(let content): case .text(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: content.formattedBody) ReplyView(sender: sender,
plainBody: content.body,
formattedBody: content.formattedBody)
case .video(let content): case .video(let content):
ReplyView(sender: sender, plainBody: content.body, formattedBody: nil, mediaSource: content.thumbnailSource) ReplyView(sender: sender,
plainBody: content.body,
formattedBody: nil,
icon: .init(mediaSource: content.thumbnailSource, cornerRadii: iconCornerRadii))
} }
default: default:
LoadingReplyView() LoadingReplyView()
@@ -45,6 +69,15 @@ struct TimelineReplyView: View {
} }
} }
private var iconCornerRadii: Double {
switch placement {
case .composer:
return 9.0
case .timeline:
return 4.0
}
}
private struct LoadingReplyView: View { private struct LoadingReplyView: View {
var body: some View { var body: some View {
ReplyView(sender: .init(id: "@alice:matrix.org"), plainBody: "Hello world", formattedBody: nil) ReplyView(sender: .init(id: "@alice:matrix.org"), plainBody: "Hello world", formattedBody: nil)
@@ -53,6 +86,12 @@ struct TimelineReplyView: View {
} }
private struct ReplyView: View { private struct ReplyView: View {
struct Icon {
var mediaSource: MediaSourceProxy?
var systemIconName: String?
let cornerRadii: Double
}
@EnvironmentObject private var context: RoomScreenViewModel.Context @EnvironmentObject private var context: RoomScreenViewModel.Context
@ScaledMetric private var imageContainerSize = 36.0 @ScaledMetric private var imageContainerSize = 36.0
@@ -60,16 +99,19 @@ struct TimelineReplyView: View {
let plainBody: String let plainBody: String
let formattedBody: AttributedString? let formattedBody: AttributedString?
var systemIconName: String? var icon: Icon?
var mediaSource: MediaSourceProxy?
var body: some View { var body: some View {
HStack { HStack {
icon iconView
.frame(width: imageContainerSize, height: imageContainerSize) .frame(width: imageContainerSize, height: imageContainerSize)
.foregroundColor(.element.primaryContent) .foregroundColor(.element.primaryContent)
.background(Color.compound.bgSubtlePrimary) .background(Color.compound.bgSubtlePrimary)
.cornerRadius(9.0, corners: .allCorners) .cornerRadius(icon?.cornerRadii ?? 0.0, corners: .allCorners)
if icon?.mediaSource == nil, icon?.systemIconName == nil {
Spacer().frame(width: 4.0)
}
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(sender.displayName ?? sender.id) Text(sender.displayName ?? sender.id)
@@ -86,8 +128,8 @@ struct TimelineReplyView: View {
} }
@ViewBuilder @ViewBuilder
private var icon: some View { private var iconView: some View {
if let mediaSource { if let mediaSource = icon?.mediaSource {
LoadableImage(mediaSource: mediaSource, LoadableImage(mediaSource: mediaSource,
size: .init(width: imageContainerSize, size: .init(width: imageContainerSize,
height: imageContainerSize), height: imageContainerSize),
@@ -98,11 +140,11 @@ struct TimelineReplyView: View {
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} }
if let systemIconName { if let systemIconName = icon?.systemIconName {
Image(systemName: systemIconName) Image(systemName: systemIconName)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.padding(4.0) .padding(8.0)
} }
} }
} }
@@ -113,30 +155,50 @@ struct TimelineReplyView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
TimelineReplyView(timelineItemReplyDetails: .notLoaded(eventID: "")) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .notLoaded(eventID: ""))
TimelineReplyView(timelineItemReplyDetails: .loading(eventID: "")) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loading(eventID: ""))
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), TimelineReplyView(placement: .timeline,
content: .text(.init(body: "This is a reply")))) timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "This is a reply"))))
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), TimelineReplyView(placement: .timeline,
content: .emote(.init(body: "says hello")))) timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .emote(.init(body: "says hello"))))
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bot"), TimelineReplyView(placement: .timeline,
content: .notice(.init(body: "Hello world")))) timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bot"),
contentType: .notice(.init(body: "Hello world"))))
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), TimelineReplyView(placement: .timeline,
content: .audio(.init(body: "Some audio", duration: 0, source: nil, contentType: nil)))) timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .audio(.init(body: "Some audio",
duration: 0,
source: nil,
contentType: nil))))
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), TimelineReplyView(placement: .timeline,
content: .file(.init(body: "Some file", source: nil, thumbnailSource: nil, contentType: nil)))) timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .file(.init(body: "Some file",
source: nil,
thumbnailSource: nil,
contentType: nil))))
let imageSource = MediaSourceProxy(url: .init(staticString: "https://mock.com"), mimeType: "image/png") let imageSource = MediaSourceProxy(url: .init(staticString: "https://mock.com"), mimeType: "image/png")
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), content: .image(.init(body: "Some image", source: imageSource, thumbnailSource: imageSource)))) TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .image(.init(body: "Some image",
source: imageSource,
thumbnailSource: imageSource))))
TimelineReplyView(timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), content: .video(.init(body: "Some video", duration: 0, source: nil, thumbnailSource: imageSource)))) TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .video(.init(body: "Some video",
duration: 0,
source: nil,
thumbnailSource: imageSource))))
} }
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
} }
@@ -28,6 +28,7 @@ struct RoomScreen: View {
HStack(spacing: 4.0) { HStack(spacing: 4.0) {
RoomAttachmentPicker(context: context) RoomAttachmentPicker(context: context)
messageComposer messageComposer
.environmentObject(context)
} }
.padding([.horizontal, .bottom]) .padding([.horizontal, .bottom])
.padding(.top, 8) .padding(.top, 8)
@@ -69,7 +70,7 @@ struct RoomScreen: View {
MessageComposer(text: $context.composerText, MessageComposer(text: $context.composerText,
focused: $context.composerFocused, focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled, sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) { mode: context.viewState.composerMode) {
sendMessage() sendMessage()
} pasteAction: { provider in } pasteAction: { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider)) context.send(viewAction: .handlePasteOrDrop(provider: provider))
@@ -162,7 +162,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
let replyDetails = messageTimelineItem.replyDetails { let replyDetails = messageTimelineItem.replyDetails {
// The rendered reply bubble with a greedy width. The custom layout prevents // The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view. // the infinite width from increasing the overall width of the view.
TimelineReplyView(timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.foregroundColor(.compound.textPlaceholder) .foregroundColor(.compound.textPlaceholder)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(4.0) .padding(4.0)
@@ -172,7 +172,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
// Add a fixed width reply bubble that is used for layout calculations but won't be rendered. // Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(4.0) .padding(4.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote) .layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
@@ -264,7 +264,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
sender: .init(id: "whoever"), sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."), content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
content: .text(.init(body: "Short")))), contentType: .text(.init(body: "Short")))),
.single) .single)
RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "", RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
@@ -274,7 +274,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
sender: .init(id: "whoever"), sender: .init(id: "whoever"),
content: .init(body: "Short message"), content: .init(body: "Short message"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
content: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))), contentType: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))),
.single) .single)
} }
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
@@ -52,7 +52,8 @@ struct TimelineItemPlainStylerView<Content: View>: View {
Rectangle() Rectangle()
.foregroundColor(.global.melon) .foregroundColor(.global.melon)
.frame(width: 4.0) .frame(width: 4.0)
TimelineReplyView(timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: replyDetails)
} }
} }
@@ -16,19 +16,9 @@
import Foundation import Foundation
enum TimelineItemReplyContent: Hashable {
case audio(AudioRoomTimelineItemContent)
case emote(EmoteRoomTimelineItemContent)
case file(FileRoomTimelineItemContent)
case image(ImageRoomTimelineItemContent)
case notice(NoticeRoomTimelineItemContent)
case text(TextRoomTimelineItemContent)
case video(VideoRoomTimelineItemContent)
}
enum TimelineItemReplyDetails: Hashable { enum TimelineItemReplyDetails: Hashable {
case notLoaded(eventID: String) case notLoaded(eventID: String)
case loading(eventID: String) case loading(eventID: String)
case loaded(sender: TimelineItemSender, content: TimelineItemReplyContent) case loaded(sender: TimelineItemSender, contentType: EventBasedMessageTimelineItemContentType)
case error(eventID: String, message: String) case error(eventID: String, message: String)
} }
@@ -16,7 +16,17 @@
import Foundation import Foundation
protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol { enum EventBasedMessageTimelineItemContentType: Hashable {
var body: String { get } case audio(AudioRoomTimelineItemContent)
var replyDetails: TimelineItemReplyDetails? { get } case emote(EmoteRoomTimelineItemContent)
case file(FileRoomTimelineItemContent)
case image(ImageRoomTimelineItemContent)
case notice(NoticeRoomTimelineItemContent)
case text(TextRoomTimelineItemContent)
case video(VideoRoomTimelineItemContent)
}
protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
var replyDetails: TimelineItemReplyDetails? { get }
var contentType: EventBasedMessageTimelineItemContentType { get }
} }
@@ -24,6 +24,8 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringC
var sender: TimelineItemSender { get } var sender: TimelineItemSender { get }
var body: String { get }
var properties: RoomTimelineItemProperties { get } var properties: RoomTimelineItemProperties { get }
} }
@@ -32,4 +32,8 @@ struct AudioRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiabl
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.audio(content)
}
} }
@@ -33,4 +33,8 @@ struct EmoteRoomTimelineItem: TextBasedRoomTimelineItem, Identifiable, Hashable
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.emote(content)
}
} }
@@ -34,4 +34,8 @@ struct FileRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.file(content)
}
} }
@@ -34,4 +34,8 @@ struct ImageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiabl
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.image(content)
}
} }
@@ -33,4 +33,8 @@ struct NoticeRoomTimelineItem: TextBasedRoomTimelineItem, Identifiable, Hashable
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.notice(content)
}
} }
@@ -34,4 +34,8 @@ struct TextRoomTimelineItem: TextBasedRoomTimelineItem, Identifiable, Hashable {
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.text(content)
}
} }
@@ -34,4 +34,8 @@ struct VideoRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiabl
var body: String { var body: String {
content.body content.body
} }
var contentType: EventBasedMessageTimelineItemContentType {
.video(content)
}
} }
@@ -443,7 +443,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
avatarURL: nil) avatarURL: nil)
} }
let replyContent: TimelineItemReplyContent let replyContent: EventBasedMessageTimelineItemContentType
switch message.msgtype() { switch message.msgtype() {
case .audio(let content): case .audio(let content):
replyContent = .audio(buildAudioTimelineItemContent(content)) replyContent = .audio(buildAudioTimelineItemContent(content))
@@ -463,7 +463,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return nil return nil
} }
return .loaded(sender: sender, content: replyContent) return .loaded(sender: sender, contentType: replyContent)
case let .error(message): case let .error(message):
return .error(eventID: details.eventId, message: message) return .error(eventID: details.eventId, message: message)
} }
+1
View File
@@ -0,0 +1 @@
Update reply composer mode UI to include message being replied to