mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Initial timeline design tweaks (#505)
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
"action_confirm" = "Confirm";
|
"action_confirm" = "Confirm";
|
||||||
"action_match" = "Match";
|
"action_match" = "Match";
|
||||||
|
|
||||||
|
"new_message" = "New message";
|
||||||
|
|
||||||
"screenshot_detected_title" = "You took a screenshot";
|
"screenshot_detected_title" = "You took a screenshot";
|
||||||
"screenshot_detected_message" = "Would you like to submit a bug report?";
|
"screenshot_detected_message" = "Would you like to submit a bug report?";
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"room_timeline_replying_to" = "Replying to %@";
|
"room_timeline_replying_to" = "Replying to %@";
|
||||||
"room_timeline_editing" = "Editing";
|
"room_timeline_editing" = "Editing";
|
||||||
"room_timeline_syncing" = "Syncing";
|
"room_timeline_syncing" = "Syncing";
|
||||||
|
"room_timeline_unable_to_decrypt" = "Unable to decrypt";
|
||||||
|
|
||||||
"room_timeline_context_menu_retry_decryption" = "Retry decryption";
|
"room_timeline_context_menu_retry_decryption" = "Retry decryption";
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ extension ElementL10n {
|
|||||||
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
|
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
|
||||||
/// Tablet
|
/// Tablet
|
||||||
public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device")
|
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
|
/// %1$@ accepted the invite
|
||||||
public static func noticeRoomInviteAccepted(_ p1: Any) -> String {
|
public static func noticeRoomInviteAccepted(_ p1: Any) -> String {
|
||||||
return ElementL10n.tr("Untranslated", "noticeRoomInviteAccepted", String(describing: p1))
|
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")
|
public static let roomTimelineStylePlainLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_plain_long_description")
|
||||||
/// Syncing
|
/// Syncing
|
||||||
public static let roomTimelineSyncing = ElementL10n.tr("Untranslated", "room_timeline_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?
|
/// Would you like to submit a bug report?
|
||||||
public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message")
|
public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message")
|
||||||
/// You took a screenshot
|
/// You took a screenshot
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ struct HomeScreenRoomCell: View {
|
|||||||
Text(timestamp)
|
Text(timestamp)
|
||||||
.font(.element.footnote)
|
.font(.element.footnote)
|
||||||
.foregroundColor(room.hasUnreads ? .element.brand : .element.tertiaryContent)
|
.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 {
|
if let lastMessage = room.lastMessage, !String(lastMessage.characters).isEmpty {
|
||||||
Text(lastMessage)
|
Text(lastMessage)
|
||||||
.lastMessageFormatting()
|
.lastMessageFormatting()
|
||||||
.id(lastMessage)
|
} else {
|
||||||
|
Text(ElementL10n.newMessage)
|
||||||
|
.lastMessageFormatting()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ enum RoomScreenComposerMode: Equatable {
|
|||||||
case `default`
|
case `default`
|
||||||
case reply(id: String, displayName: String)
|
case reply(id: String, displayName: String)
|
||||||
case edit(originalItemId: String)
|
case edit(originalItemId: String)
|
||||||
|
|
||||||
|
var isEdit: Bool {
|
||||||
|
switch self {
|
||||||
|
case .edit:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RoomScreenViewAction {
|
enum RoomScreenViewAction {
|
||||||
|
|||||||
@@ -211,7 +211,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
var actions: [TimelineItemContextMenuAction] = [
|
var actions: [TimelineItemContextMenuAction] = [
|
||||||
.react, .copy, .quote, .copyPermalink, .reply
|
.react, .copy, .reply
|
||||||
|
// Disabled for FOSDEM
|
||||||
|
// .quote, .copyPermalink
|
||||||
]
|
]
|
||||||
|
|
||||||
if item.isEditable {
|
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
|
let editCancellationAction: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let rect = RoundedRectangle(cornerRadius: borderRadius)
|
let roundedRectangle = RoundedRectangle(cornerRadius: borderRadius)
|
||||||
VStack(alignment: .leading, spacing: 4.0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .bottom) {
|
||||||
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
|
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
|
||||||
text: $text,
|
text: $text,
|
||||||
focused: $focused,
|
focused: $focused,
|
||||||
maxHeight: 300,
|
maxHeight: 300,
|
||||||
onEnterKeyHandler: sendAction)
|
onEnterKeyHandler: sendAction)
|
||||||
|
.tint(.element.brand)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
sendAction()
|
sendAction()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "paperplane")
|
submitButtonImage
|
||||||
.font(.element.title3)
|
.symbolVariant(.fill)
|
||||||
.foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground)
|
.font(.element.body)
|
||||||
.padding(8.0)
|
.foregroundColor(sendingDisabled ? .element.quaternaryContent : .element.background)
|
||||||
.background(
|
.padding(5)
|
||||||
|
.background {
|
||||||
Circle()
|
Circle()
|
||||||
.foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground)
|
.foregroundColor(sendingDisabled ? .clear : .element.brand)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
.disabled(sendingDisabled)
|
.disabled(sendingDisabled)
|
||||||
.animation(.elementDefault, value: sendingDisabled)
|
.animation(.elementDefault, value: sendingDisabled)
|
||||||
.keyboardShortcut(.return, modifiers: [.command])
|
.keyboardShortcut(.return, modifiers: [.command])
|
||||||
.padding(4.0)
|
.padding(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 12.0)
|
.padding(.leading, 12.0)
|
||||||
.background(.thinMaterial)
|
.background {
|
||||||
.clipShape(rect)
|
ZStack {
|
||||||
|
roundedRectangle
|
||||||
|
.fill(Color.element.system)
|
||||||
|
roundedRectangle
|
||||||
|
.stroke(Color.element.quinaryContent, lineWidth: 1)
|
||||||
|
.opacity(focused ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(roundedRectangle)
|
||||||
.animation(.elementDefault, value: type)
|
.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 {
|
private var borderRadius: CGFloat {
|
||||||
switch type {
|
switch type {
|
||||||
case .default:
|
case .default:
|
||||||
@@ -90,18 +111,18 @@ private struct MessageComposerReplyHeader: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Label(ElementL10n.roomTimelineReplyingTo(displayName), systemImage: "arrow.uturn.left")
|
Label(ElementL10n.roomTimelineReplyingTo(displayName), systemImage: "arrowshape.turn.up.left")
|
||||||
.font(.element.caption2)
|
.font(.element.caption1)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
action()
|
action()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "x.circle")
|
Image(systemName: "xmark")
|
||||||
.font(.element.callout)
|
.font(.element.caption2.weight(.medium))
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.padding(4.0)
|
.padding(12.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,18 +133,18 @@ private struct MessageComposerEditHeader: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Label(ElementL10n.roomTimelineEditing, systemImage: "pencil")
|
Label(ElementL10n.roomTimelineEditing, systemImage: "pencil.line")
|
||||||
.font(.element.caption2)
|
.font(.element.caption1)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
action()
|
action()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "x.circle")
|
Image(systemName: "xmark")
|
||||||
.font(.element.callout)
|
.font(.element.caption2.weight(.medium))
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.padding(4.0)
|
.padding(12.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +161,14 @@ struct MessageComposer_Previews: PreviewProvider {
|
|||||||
replyCancellationAction: { },
|
replyCancellationAction: { },
|
||||||
editCancellationAction: { })
|
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."),
|
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,
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ struct MessageComposerTextField: View {
|
|||||||
@Binding private var text: String
|
@Binding private var text: String
|
||||||
@Binding private var focused: Bool
|
@Binding private var focused: Bool
|
||||||
|
|
||||||
@State private var dynamicHeight: CGFloat = 100
|
|
||||||
|
|
||||||
private let placeholder: String
|
private let placeholder: String
|
||||||
private let maxHeight: CGFloat
|
private let maxHeight: CGFloat
|
||||||
private let onEnterKeyHandler: OnEnterKeyHandler
|
private let onEnterKeyHandler: OnEnterKeyHandler
|
||||||
@@ -50,11 +48,9 @@ struct MessageComposerTextField: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
UITextViewWrapper(text: $text,
|
UITextViewWrapper(text: $text,
|
||||||
calculatedHeight: $dynamicHeight,
|
|
||||||
focused: $focused,
|
focused: $focused,
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
onEnterKeyHandler: onEnterKeyHandler)
|
onEnterKeyHandler: onEnterKeyHandler)
|
||||||
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
|
|
||||||
.background(placeholderView, alignment: .topLeading)
|
.background(placeholderView, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +67,6 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
typealias UIViewType = UITextView
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var calculatedHeight: CGFloat
|
|
||||||
@Binding var focused: Bool
|
@Binding var focused: Bool
|
||||||
|
|
||||||
let maxHeight: CGFloat
|
let maxHeight: CGFloat
|
||||||
@@ -84,7 +79,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
textView.keyDelegate = context.coordinator
|
textView.keyDelegate = context.coordinator
|
||||||
textView.textColor = .element.primaryContent
|
textView.textColor = .element.primaryContent
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
textView.font = UIFont.preferredFont(forTextStyle: .body)
|
textView.font = .preferredFont(forTextStyle: .body)
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
textView.isUserInteractionEnabled = true
|
textView.isUserInteractionEnabled = true
|
||||||
textView.backgroundColor = UIColor.clear
|
textView.backgroundColor = UIColor.clear
|
||||||
@@ -97,6 +92,15 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
return textView
|
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>) {
|
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
|
||||||
if textView.text != text {
|
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
|
DispatchQueue.main.async { // Avoid cycle detected through attribute warnings
|
||||||
if focused, textView.window != nil, !textView.isFirstResponder {
|
if focused, textView.window != nil, !textView.isFirstResponder {
|
||||||
textView.becomeFirstResponder()
|
textView.becomeFirstResponder()
|
||||||
@@ -125,36 +127,21 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(text: $text,
|
Coordinator(text: $text,
|
||||||
height: $calculatedHeight,
|
|
||||||
focused: $focused,
|
focused: $focused,
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
onEnterKeyHandler: onEnterKeyHandler)
|
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 {
|
final class Coordinator: NSObject, UITextViewDelegate, TextViewWithKeyDetectionDelegate {
|
||||||
private var text: Binding<String>
|
private var text: Binding<String>
|
||||||
private var calculatedHeight: Binding<CGFloat>
|
|
||||||
private var focused: Binding<Bool>
|
private var focused: Binding<Bool>
|
||||||
|
|
||||||
private let maxHeight: CGFloat
|
private let maxHeight: CGFloat
|
||||||
|
|
||||||
private let onEnterKeyHandler: OnEnterKeyHandler
|
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
|
self.text = text
|
||||||
calculatedHeight = height
|
|
||||||
self.focused = focused
|
self.focused = focused
|
||||||
self.maxHeight = maxHeight
|
self.maxHeight = maxHeight
|
||||||
self.onEnterKeyHandler = onEnterKeyHandler
|
self.onEnterKeyHandler = onEnterKeyHandler
|
||||||
@@ -162,9 +149,6 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
text.wrappedValue = textView.text
|
text.wrappedValue = textView.text
|
||||||
UITextViewWrapper.recalculateHeight(view: textView,
|
|
||||||
result: calculatedHeight,
|
|
||||||
maxHeight: maxHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
@@ -212,6 +196,7 @@ struct MessageComposerTextField_Previews: PreviewProvider {
|
|||||||
VStack {
|
VStack {
|
||||||
PreviewWrapper(text: "123")
|
PreviewWrapper(text: "123")
|
||||||
PreviewWrapper(text: "")
|
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
|
@ObservedObject var context: RoomScreenViewModel.Context
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 12) {
|
||||||
roomAvatar
|
roomAvatar
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
Text(context.viewState.roomTitle)
|
Text(context.viewState.roomTitle)
|
||||||
.font(.element.headline)
|
.font(.element.headline)
|
||||||
.accessibilityIdentifier("roomNameLabel")
|
.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
|
// Using a button stops is from getting truncated in the navigation bar
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
context.send(viewAction: .displayRoomDetails)
|
context.send(viewAction: .displayRoomDetails)
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ struct RoomScreen: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
timeline
|
timeline
|
||||||
|
.background(Color.element.background) // Kills the toolbar translucency.
|
||||||
.safeAreaInset(edge: .bottom) { messageComposer }
|
.safeAreaInset(edge: .bottom) { messageComposer }
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
|
.toolbarRole(.editor) // Hide the back button title.
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
|
||||||
.overlay { loadingIndicator }
|
.overlay { loadingIndicator }
|
||||||
.alert(item: $context.alertInfo) { $0.alert }
|
.alert(item: $context.alertInfo) { $0.alert }
|
||||||
.sheet(item: $context.debugInfo) { DebugScreen(info: $0) }
|
.sheet(item: $context.debugInfo) { DebugScreen(info: $0) }
|
||||||
@@ -89,14 +92,6 @@ struct RoomScreen: View {
|
|||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
RoomHeaderView(context: context)
|
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() {
|
private func sendMessage() {
|
||||||
|
|||||||
+41
-44
@@ -25,6 +25,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@ScaledMetric private var senderNameVerticalPadding = 3
|
@ScaledMetric private var senderNameVerticalPadding = 3
|
||||||
|
private let cornerRadius: CGFloat = 12
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .trailingFirstTextBaseline) {
|
ZStack(alignment: .trailingFirstTextBaseline) {
|
||||||
@@ -39,7 +40,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
styledContentWithReactions
|
messageBubbleWithReactions
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16.0)
|
.padding(.horizontal, 16.0)
|
||||||
.padding(timelineItem.isOutgoing ? .leading : .trailing, 40) // Extra padding to differentiate alignment.
|
.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
|
// Figma has a spacing of -4 but it doesn't take into account
|
||||||
// the centre aligned stroke width so we use -5 here
|
// the centre aligned stroke width so we use -5 here
|
||||||
VStack(alignment: alignment, spacing: -5) {
|
VStack(alignment: alignment, spacing: -5) {
|
||||||
styledContent
|
messageBubble
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
|
|
||||||
if !timelineItem.properties.reactions.isEmpty {
|
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
|
@ViewBuilder
|
||||||
var styledContent: some View {
|
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 {
|
if shouldAvoidBubbling {
|
||||||
content()
|
content()
|
||||||
.cornerRadius(12, corners: timelineItem.roundedCorners)
|
.bubbleStyle(inset: false,
|
||||||
.padding(.top, topPadding)
|
cornerRadius: cornerRadius,
|
||||||
|
corners: timelineItem.roundedCorners)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
content()
|
content()
|
||||||
@@ -114,36 +116,20 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
.foregroundColor(.element.tertiaryContent)
|
.foregroundColor(.element.tertiaryContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
.bubbleStyle(inset: true,
|
||||||
.background(Color.element.systemGray5)
|
color: timelineItem.isOutgoing ? .element.systemGray5 : .element.systemGray6,
|
||||||
.cornerRadius(12, corners: timelineItem.roundedCorners)
|
cornerRadius: cornerRadius,
|
||||||
.padding(.top, topPadding)
|
corners: timelineItem.roundedCorners)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var messageBubbleTopPadding: CGFloat {
|
||||||
var styledContentIncoming: some View {
|
guard timelineItem.isOutgoing else { return 0 }
|
||||||
if shouldAvoidBubbling {
|
return timelineItem.groupState == .single || timelineItem.groupState == .beginning ? 8 : 0
|
||||||
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 shouldAvoidBubbling: Bool {
|
private var shouldAvoidBubbling: Bool {
|
||||||
timelineItem is ImageRoomTimelineItem || timelineItem is VideoRoomTimelineItem
|
timelineItem is ImageRoomTimelineItem || timelineItem is VideoRoomTimelineItem || timelineItem is StickerRoomTimelineItem
|
||||||
}
|
}
|
||||||
|
|
||||||
private var alignment: HorizontalAlignment {
|
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 {
|
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
||||||
let item = MockRoomTimelineController().timelineItems[index]
|
let item = MockRoomTimelineController().timelineItems[index]
|
||||||
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
|
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
|
||||||
|
.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.timelineStyle(.bubbles)
|
.timelineStyle(.bubbles)
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
.environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
|||||||
@ViewBuilder let content: () -> Content
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
header
|
header
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -48,7 +48,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
|||||||
HStack {
|
HStack {
|
||||||
TimelineSenderAvatarView(timelineItem: timelineItem)
|
TimelineSenderAvatarView(timelineItem: timelineItem)
|
||||||
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
|
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
|
||||||
.font(.body)
|
.font(.subheadline)
|
||||||
.foregroundColor(.element.primaryContent)
|
.foregroundColor(.element.primaryContent)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -57,6 +57,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
|||||||
.foregroundColor(Color.element.tertiaryContent)
|
.foregroundColor(Color.element.tertiaryContent)
|
||||||
.font(.element.caption2)
|
.font(.element.caption2)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,15 +81,18 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
|
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
||||||
let item = MockRoomTimelineController().timelineItems[index]
|
let item = MockRoomTimelineController().timelineItems[index]
|
||||||
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
|
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
|
||||||
|
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.timelineStyle(.plain)
|
.timelineStyle(.plain)
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
.environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,16 +43,16 @@ struct TimelineReactionButton: View {
|
|||||||
var label: some View {
|
var label: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(reaction.key)
|
Text(reaction.key)
|
||||||
.font(.element.caption1)
|
.font(.element.subheadline)
|
||||||
Text(String(reaction.count))
|
Text(String(reaction.count))
|
||||||
.font(.element.caption1)
|
.font(.element.subheadline)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 8)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
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())
|
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: Capsule())
|
||||||
)
|
)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ struct EmoteRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct EmoteRoomTimelineView_Previews: PreviewProvider {
|
struct EmoteRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -17,46 +17,36 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct EncryptedRoomTimelineView: View {
|
struct EncryptedRoomTimelineView: View {
|
||||||
@State private var showEncryptionInfo = false
|
|
||||||
|
|
||||||
let timelineItem: EncryptedRoomTimelineItem
|
let timelineItem: EncryptedRoomTimelineItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
Button {
|
Label {
|
||||||
showEncryptionInfo = !showEncryptionInfo
|
FormattedBodyText(text: timelineItem.text)
|
||||||
} label: {
|
} icon: {
|
||||||
Label {
|
Image(systemName: "lock.shield")
|
||||||
if showEncryptionInfo {
|
.foregroundColor(.element.secondaryContent)
|
||||||
FormattedBodyText(text: encryptionDetails)
|
|
||||||
} else {
|
|
||||||
FormattedBodyText(text: timelineItem.text)
|
|
||||||
}
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "lock.shield")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
.animation(nil, value: showEncryptionInfo)
|
|
||||||
}
|
}
|
||||||
|
.labelStyle(RoomTimelineViewLabelStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private var encryptionDetails: String {
|
|
||||||
switch timelineItem.encryptionType {
|
struct RoomTimelineViewLabelStyle: LabelStyle {
|
||||||
case .unknown:
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
return "Unknown"
|
HStack(spacing: 8) {
|
||||||
case .megolmV1AesSha2(let sessionId):
|
configuration.icon
|
||||||
return "Megolm session id: \(sessionId)"
|
configuration.title
|
||||||
case .olmV1Curve25519AesSha2(let senderKey):
|
|
||||||
return "Olm sender key: \(senderKey)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EncryptedRoomTimelineView_Previews: PreviewProvider {
|
struct EncryptedRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ struct FileRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FileRoomTimelineView_Previews: PreviewProvider {
|
struct FileRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ struct FormattedBodyText: View {
|
|||||||
} else {
|
} else {
|
||||||
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.foregroundColor(.element.primaryContent)
|
.foregroundColor(.element.tertiaryContent)
|
||||||
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
.lineLimit(3) // FIXME: Quotes vs replies
|
||||||
|
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
|
||||||
.clipped()
|
.clipped()
|
||||||
.background(Color.element.systemGray4)
|
.background(Color.element.background)
|
||||||
.cornerRadius(13)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(component.attributedString)
|
Text(component.attributedString)
|
||||||
|
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) // FIXME: Configurable
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.foregroundColor(.element.primaryContent)
|
.foregroundColor(.element.primaryContent)
|
||||||
}
|
}
|
||||||
@@ -56,7 +58,7 @@ struct FormattedBodyText: View {
|
|||||||
|
|
||||||
private var blockquoteAttributes: AttributeContainer {
|
private var blockquoteAttributes: AttributeContainer {
|
||||||
var container = AttributeContainer()
|
var container = AttributeContainer()
|
||||||
container.font = .element.caption1
|
container.font = .element.subheadline
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,12 @@ extension FormattedBodyText {
|
|||||||
struct FormattedBodyText_Previews: PreviewProvider {
|
struct FormattedBodyText_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body
|
||||||
body.timelineStyle(.plain)
|
.padding(8)
|
||||||
|
.background(Color.element.systemGray6)
|
||||||
|
.cornerRadius(12)
|
||||||
|
|
||||||
|
body
|
||||||
|
.timelineStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -80,8 +87,7 @@ struct FormattedBodyText_Previews: PreviewProvider {
|
|||||||
Text before blockquote
|
Text before blockquote
|
||||||
<blockquote>
|
<blockquote>
|
||||||
<b>bold</b> <i>italic</i>
|
<b>bold</b> <i>italic</i>
|
||||||
</blockquote>
|
</blockquote>Text after blockquote
|
||||||
Text after blockquote
|
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
|
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct ImageRoomTimelineView: View {
|
|||||||
imageProvider: context.imageProvider) {
|
imageProvider: context.imageProvider) {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
|
.frame(maxHeight: 300)
|
||||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,9 +46,11 @@ struct ImageRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ImageRoomTimelineView_Previews: PreviewProvider {
|
struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct NoticeRoomTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineStyler(timelineItem: timelineItem) {
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
|
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
|
||||||
if let attributedComponents = timelineItem.attributedComponents {
|
if let attributedComponents = timelineItem.attributedComponents {
|
||||||
FormattedBodyText(attributedComponents: attributedComponents)
|
FormattedBodyText(attributedComponents: attributedComponents)
|
||||||
@@ -35,9 +35,11 @@ struct NoticeRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct NoticeRoomTimelineView_Previews: PreviewProvider {
|
struct NoticeRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ struct RedactedRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RedactedRoomTimelineView_Previews: PreviewProvider {
|
struct RedactedRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VStack(alignment: .leading, spacing: 20.0) {
|
VStack(alignment: .leading, spacing: 20.0) {
|
||||||
RedactedRoomTimelineView(timelineItem: itemWith(text: ElementL10n.eventRedacted,
|
RedactedRoomTimelineView(timelineItem: itemWith(text: ElementL10n.eventRedacted,
|
||||||
timestamp: "Later",
|
timestamp: "Later",
|
||||||
senderId: "Anne"))
|
senderId: "Anne"))
|
||||||
}
|
}
|
||||||
|
.environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func itemWith(text: String, timestamp: String, senderId: String) -> RedactedRoomTimelineItem {
|
private static func itemWith(text: String, timestamp: String, senderId: String) -> RedactedRoomTimelineItem {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ struct SeparatorRoomTimelineView: View {
|
|||||||
Text(timelineItem.text)
|
Text(timelineItem.text)
|
||||||
.font(.element.footnote)
|
.font(.element.footnote)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.padding(.vertical, 24)
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct StateRoomTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(timelineItem.text)
|
Text(timelineItem.text)
|
||||||
.font(.element.caption1Bold)
|
.font(.element.footnote)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.element.secondaryContent)
|
.foregroundColor(.element.secondaryContent)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
@@ -37,7 +37,14 @@ struct StateRoomTimelineView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
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: ""))
|
StateRoomTimelineView(timelineItem: item)
|
||||||
return 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) {
|
imageProvider: context.imageProvider) {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
|
.frame(maxHeight: 300)
|
||||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(timelineItem.text)
|
.accessibilityLabel(timelineItem.text)
|
||||||
@@ -46,9 +47,11 @@ struct StickerRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct StickerRoomTimelineView_Previews: PreviewProvider {
|
struct StickerRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ struct TextRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TextRoomTimelineView_Previews: PreviewProvider {
|
struct TextRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ struct UnsupportedRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: "exclamationmark.bubble")
|
Image(systemName: "exclamationmark.bubble")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.element.secondaryContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnsupportedRoomTimelineView_Previews: PreviewProvider {
|
struct UnsupportedRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct VideoRoomTimelineView: View {
|
|||||||
} placeholder: {
|
} placeholder: {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
|
.frame(maxHeight: 300)
|
||||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,9 +57,11 @@ struct VideoRoomTimelineView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct VideoRoomTimelineView_Previews: PreviewProvider {
|
struct VideoRoomTimelineView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel.mock
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body
|
body.environmentObject(viewModel.context)
|
||||||
body.timelineStyle(.plain)
|
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct TimelineItemContextMenu: View {
|
public struct TimelineItemContextMenu: View {
|
||||||
|
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||||
|
|
||||||
|
let itemID: String
|
||||||
let contextMenuActions: TimelineItemContextMenuActions
|
let contextMenuActions: TimelineItemContextMenuActions
|
||||||
let callback: (TimelineItemContextMenuAction) -> Void
|
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
viewsForActions(contextMenuActions.actions)
|
viewsForActions(contextMenuActions.actions)
|
||||||
@@ -67,45 +69,49 @@ public struct TimelineItemContextMenu: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func viewsForActions(_ actions: [TimelineItemContextMenuAction]) -> some View {
|
private func viewsForActions(_ actions: [TimelineItemContextMenuAction]) -> some View {
|
||||||
ForEach(actions, id: \.self) { item in
|
ForEach(actions, id: \.self) { action in
|
||||||
switch item {
|
switch action {
|
||||||
case .react:
|
case .react:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.reactions, systemImage: "face.smiling")
|
Label(ElementL10n.reactions, systemImage: "face.smiling")
|
||||||
}
|
}
|
||||||
case .copy:
|
case .copy:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.actionCopy, systemImage: "doc.on.doc")
|
Label(ElementL10n.actionCopy, systemImage: "doc.on.doc")
|
||||||
}
|
}
|
||||||
case .edit:
|
case .edit:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.edit, systemImage: "pencil")
|
Label(ElementL10n.edit, systemImage: "pencil.line")
|
||||||
}
|
}
|
||||||
case .quote:
|
case .quote:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.actionQuote, systemImage: "quote.bubble")
|
Label(ElementL10n.actionQuote, systemImage: "quote.bubble")
|
||||||
}
|
}
|
||||||
case .copyPermalink:
|
case .copyPermalink:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.permalink, systemImage: "link")
|
Label(ElementL10n.permalink, systemImage: "link")
|
||||||
}
|
}
|
||||||
case .reply:
|
case .reply:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.reply, systemImage: "arrow.uturn.left")
|
Label(ElementL10n.reply, systemImage: "arrowshape.turn.up.left")
|
||||||
}
|
}
|
||||||
case .redact:
|
case .redact:
|
||||||
Button(role: .destructive) { callback(item) } label: {
|
Button(role: .destructive) { send(action) } label: {
|
||||||
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")
|
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")
|
||||||
}
|
}
|
||||||
case .viewSource:
|
case .viewSource:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg")
|
Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg")
|
||||||
}
|
}
|
||||||
case .retryDecryption:
|
case .retryDecryption:
|
||||||
Button { callback(item) } label: {
|
Button { send(action) } label: {
|
||||||
Label(ElementL10n.roomTimelineContextMenuRetryDecryption, systemImage: "arrow.down.message")
|
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.separatorStyle = .none
|
||||||
tableView.allowsSelection = false
|
tableView.allowsSelection = false
|
||||||
tableView.keyboardDismissMode = .onDrag
|
tableView.keyboardDismissMode = .onDrag
|
||||||
|
tableView.backgroundColor = .element.background
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
|
||||||
scrollToBottomPublisher
|
scrollToBottomPublisher
|
||||||
@@ -189,7 +190,6 @@ class TimelineTableViewController: UIViewController {
|
|||||||
// A local reference to avoid capturing self in the cell configuration.
|
// A local reference to avoid capturing self in the cell configuration.
|
||||||
let coordinator = self.coordinator
|
let coordinator = self.coordinator
|
||||||
let opacity = self.opacity(for: timelineItem)
|
let opacity = self.opacity(for: timelineItem)
|
||||||
let contextMenuActionProvider = self.contextMenuActionProvider
|
|
||||||
|
|
||||||
cell.item = timelineItem
|
cell.item = timelineItem
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
cell.contentConfiguration = UIHostingConfiguration {
|
||||||
@@ -197,13 +197,6 @@ class TimelineTableViewController: UIViewController {
|
|||||||
.id(timelineItem.id)
|
.id(timelineItem.id)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.opacity(opacity)
|
.opacity(opacity)
|
||||||
.contextMenu {
|
|
||||||
contextMenuActionProvider?(timelineItem.id).map { actions in
|
|
||||||
TimelineItemContextMenu(contextMenuActions: actions) { action in
|
|
||||||
coordinator.send(viewAction: .contextMenuAction(itemID: timelineItem.id, action: action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
|
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||||
}
|
}
|
||||||
@@ -223,6 +216,7 @@ class TimelineTableViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
.margins(.all, self.timelineStyle.rowInsets)
|
.margins(.all, self.timelineStyle.rowInsets)
|
||||||
.minSize(height: 1)
|
.minSize(height: 1)
|
||||||
|
.background(Color.clear)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ struct TimelineView: UIViewControllerRepresentable {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct TimelineTableView_Previews: PreviewProvider {
|
struct TimelineTableView_Previews: PreviewProvider {
|
||||||
|
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||||
|
timelineViewFactory: RoomTimelineViewFactory(),
|
||||||
|
mediaProvider: MockMediaProvider(),
|
||||||
|
roomName: "Preview room")
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
|
||||||
timelineViewFactory: RoomTimelineViewFactory(),
|
|
||||||
mediaProvider: MockMediaProvider(),
|
|
||||||
roomName: "Preview room")
|
|
||||||
|
|
||||||
NavigationView {
|
NavigationView {
|
||||||
RoomScreen(context: viewModel.context)
|
RoomScreen(context: viewModel.context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ struct SettingsScreen: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.background(backgroundColor, ignoresSafeAreaEdges: .all)
|
.background(backgroundColor, ignoresSafeAreaEdges: .all)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
closeButton
|
doneButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,8 +152,8 @@ struct SettingsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var closeButton: some View {
|
private var doneButton: some View {
|
||||||
Button(ElementL10n.actionCancel, action: close)
|
Button(ElementL10n.done, action: close)
|
||||||
.accessibilityIdentifier("closeButton")
|
.accessibilityIdentifier("closeButton")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return EncryptedRoomTimelineItem(id: eventItemProxy.id,
|
return EncryptedRoomTimelineItem(id: eventItemProxy.id,
|
||||||
text: ElementL10n.encryptionInformationDecryptionError,
|
text: ElementL10n.roomTimelineUnableToDecrypt,
|
||||||
encryptionType: encryptionType,
|
encryptionType: encryptionType,
|
||||||
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
|
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
|
||||||
groupState: groupState,
|
groupState: groupState,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Update the designs for the timeline.
|
||||||
Reference in New Issue
Block a user