mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Further design updates to the timeline. (#524)
- Decryption banner on the timeline - Bubble colours - Background insets - Multiline composer corner radius - Read marker - GIF indicator - Reactions shape - Use RoomTimelineViewLabelStyle everywhere possible - Only set a line limit on reply quotes, allow all the lines for regular quotes. - Message label icons - Yes/No button casing - Make sender profiles immutable. - Enable copy link and invite buttons. - Fix layout loop detecting multiline composer text. - Add animation to the encryption banner.
This commit is contained in:
@@ -53,6 +53,7 @@ enum RoomScreenViewAction {
|
||||
case cancelEdit
|
||||
/// Mark the entire room as read - this is heavy handed as a starting point for now.
|
||||
case markRoomAsRead
|
||||
case retryDecryption
|
||||
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ struct RoomScreenViewState: BindableState {
|
||||
var items: [RoomTimelineViewProvider] = []
|
||||
var canBackPaginate = true
|
||||
var isBackPaginating = false
|
||||
var showEncryptionBanner = false
|
||||
var showLoading = false
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
private let timelineController: RoomTimelineControllerProtocol
|
||||
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
init(timelineController: RoomTimelineControllerProtocol,
|
||||
timelineViewFactory: RoomTimelineViewFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
@@ -66,6 +67,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
if self.state.isBackPaginating != isBackPaginating {
|
||||
self.state.isBackPaginating = isBackPaginating
|
||||
}
|
||||
case .hasEncryptedItems(let hasEncryptedItems):
|
||||
if self.state.showEncryptionBanner != hasEncryptedItems {
|
||||
self.state.showEncryptionBanner = hasEncryptedItems
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -113,6 +118,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
state.bindings.composerText = ""
|
||||
case .markRoomAsRead:
|
||||
await markRoomAsRead()
|
||||
case .retryDecryption:
|
||||
retryDecryption()
|
||||
case .contextMenuAction(let itemID, let action):
|
||||
processContentMenuAction(action, itemID: itemID)
|
||||
}
|
||||
@@ -182,6 +189,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry decrypting any encrypted items in the timeline.
|
||||
private func retryDecryption() {
|
||||
Task {
|
||||
let firstEncryptedItem = state.items.first { $0.sessionID != nil }
|
||||
|
||||
if let sessionID = firstEncryptedItem?.sessionID {
|
||||
// Request for the first encrypted item to be decrypted as the SDK
|
||||
// will continue to decrypt any following items automatically.
|
||||
await timelineController.retryDecryption(for: sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func displayError(_ type: RoomScreenErrorType) {
|
||||
switch type {
|
||||
case .alert(let message):
|
||||
@@ -224,12 +244,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
actions.append(.redact)
|
||||
}
|
||||
|
||||
var debugActions: [TimelineItemContextMenuAction] = [.viewSource]
|
||||
|
||||
if let item = timelineItem as? EncryptedRoomTimelineItem,
|
||||
case let .megolmV1AesSha2(sessionId) = item.encryptionType {
|
||||
debugActions.append(.retryDecryption(sessionId: sessionId))
|
||||
}
|
||||
let debugActions: [TimelineItemContextMenuAction] = [.viewSource]
|
||||
|
||||
return .init(actions: actions, debugActions: debugActions)
|
||||
}
|
||||
@@ -271,10 +286,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
let debugDescription = timelineController.debugDescription(for: item.id)
|
||||
MXLog.info(debugDescription)
|
||||
state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription)
|
||||
case .retryDecryption(let sessionId):
|
||||
Task {
|
||||
await timelineController.retryDecryption(for: sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if action.switchToDefaultComposer {
|
||||
@@ -291,3 +302,14 @@ extension RoomScreenViewModel {
|
||||
mediaProvider: MockMediaProvider(),
|
||||
roomName: "Preview room")
|
||||
}
|
||||
|
||||
private extension RoomTimelineViewProvider {
|
||||
/// The item's session ID if it was unable to decrypt and uses megolm.
|
||||
/// This will be nil for items that have already been decrypted.
|
||||
var sessionID: String? {
|
||||
guard case let .encrypted(item) = self,
|
||||
case let .megolmV1AesSha2(sessionID) = item.encryptionType
|
||||
else { return nil }
|
||||
return sessionID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct MessageComposer: View {
|
||||
@Binding var focused: Bool
|
||||
let sendingDisabled: Bool
|
||||
let type: RoomScreenComposerMode
|
||||
@State private var isMultiline = false
|
||||
|
||||
let sendAction: () -> Void
|
||||
let replyCancellationAction: () -> Void
|
||||
@@ -34,10 +35,11 @@ struct MessageComposer: View {
|
||||
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
|
||||
text: $text,
|
||||
focused: $focused,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
onEnterKeyHandler: sendAction)
|
||||
.tint(.element.brand)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Button {
|
||||
sendAction()
|
||||
@@ -55,7 +57,8 @@ struct MessageComposer: View {
|
||||
.disabled(sendingDisabled)
|
||||
.animation(.elementDefault, value: sendingDisabled)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.padding(8)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 12.0)
|
||||
@@ -67,8 +70,8 @@ struct MessageComposer: View {
|
||||
.stroke(Color.element.quinaryContent, lineWidth: 1)
|
||||
.opacity(focused ? 1 : 0)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.25), value: isMultiline)
|
||||
}
|
||||
.clipShape(roundedRectangle)
|
||||
.animation(.elementDefault, value: type)
|
||||
}
|
||||
|
||||
@@ -98,9 +101,9 @@ struct MessageComposer: View {
|
||||
private var borderRadius: CGFloat {
|
||||
switch type {
|
||||
case .default:
|
||||
return 28.0
|
||||
return isMultiline ? 8 : 28
|
||||
case .reply, .edit:
|
||||
return 12.0
|
||||
return 8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,29 +19,18 @@ import SwiftUI
|
||||
typealias OnEnterKeyHandler = () -> Void
|
||||
|
||||
struct MessageComposerTextField: View {
|
||||
@Binding private var text: String
|
||||
@Binding private var focused: Bool
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
@Binding var focused: Bool
|
||||
@Binding var isMultiline: Bool
|
||||
|
||||
private let placeholder: String
|
||||
private let maxHeight: CGFloat
|
||||
private let onEnterKeyHandler: OnEnterKeyHandler
|
||||
let maxHeight: CGFloat
|
||||
let onEnterKeyHandler: OnEnterKeyHandler
|
||||
|
||||
private var showingPlaceholder: Bool {
|
||||
text.isEmpty
|
||||
}
|
||||
|
||||
init(placeholder: String,
|
||||
text: Binding<String>,
|
||||
focused: Binding<Bool>,
|
||||
maxHeight: CGFloat,
|
||||
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
|
||||
self.placeholder = placeholder
|
||||
_text = text
|
||||
_focused = focused
|
||||
self.maxHeight = maxHeight
|
||||
self.onEnterKeyHandler = onEnterKeyHandler
|
||||
}
|
||||
|
||||
private var placeholderColor: Color {
|
||||
.element.secondaryContent
|
||||
}
|
||||
@@ -49,6 +38,7 @@ struct MessageComposerTextField: View {
|
||||
var body: some View {
|
||||
UITextViewWrapper(text: $text,
|
||||
focused: $focused,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: maxHeight,
|
||||
onEnterKeyHandler: onEnterKeyHandler)
|
||||
.background(placeholderView, alignment: .topLeading)
|
||||
@@ -68,18 +58,22 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
|
||||
@Binding var text: String
|
||||
@Binding var focused: Bool
|
||||
@Binding var isMultiline: Bool
|
||||
|
||||
let maxHeight: CGFloat
|
||||
|
||||
let onEnterKeyHandler: OnEnterKeyHandler
|
||||
|
||||
private let font = UIFont.preferredFont(forTextStyle: .body)
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
|
||||
let textView = TextViewWithKeyDetection()
|
||||
textView.isMultiline = $isMultiline
|
||||
textView.delegate = context.coordinator
|
||||
textView.keyDelegate = context.coordinator
|
||||
textView.textColor = .element.primaryContent
|
||||
textView.isEditable = true
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
textView.font = font
|
||||
textView.isSelectable = true
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.backgroundColor = UIColor.clear
|
||||
@@ -99,6 +93,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
height: CGFloat.greatestFiniteMagnitude))
|
||||
let width = proposal.width ?? newSize.width
|
||||
let height = min(maxHeight, newSize.height)
|
||||
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
@@ -140,7 +135,10 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
|
||||
private let onEnterKeyHandler: OnEnterKeyHandler
|
||||
|
||||
init(text: Binding<String>, focused: Binding<Bool>, maxHeight: CGFloat, onEnterKeyHandler: @escaping OnEnterKeyHandler) {
|
||||
init(text: Binding<String>,
|
||||
focused: Binding<Bool>,
|
||||
maxHeight: CGFloat,
|
||||
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
|
||||
self.text = text
|
||||
self.focused = focused
|
||||
self.maxHeight = maxHeight
|
||||
@@ -177,6 +175,8 @@ private protocol TextViewWithKeyDetectionDelegate: AnyObject {
|
||||
private class TextViewWithKeyDetection: UITextView {
|
||||
weak var keyDelegate: TextViewWithKeyDetectionDelegate?
|
||||
|
||||
var isMultiline: Binding<Bool>?
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
|
||||
UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))]
|
||||
@@ -189,6 +189,23 @@ private class TextViewWithKeyDetection: UITextView {
|
||||
@objc func enterKeyPressed(sender: UIKeyCommand) {
|
||||
keyDelegate?.enterKeyWasPressed(textView: self)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let isMultiline, let font else { return }
|
||||
|
||||
let numberOfLines = frame.height / font.lineHeight
|
||||
if numberOfLines > 1.5 {
|
||||
if !isMultiline.wrappedValue {
|
||||
isMultiline.wrappedValue = true
|
||||
}
|
||||
} else {
|
||||
if isMultiline.wrappedValue {
|
||||
isMultiline.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageComposerTextField_Previews: PreviewProvider {
|
||||
@@ -203,14 +220,21 @@ struct MessageComposerTextField_Previews: PreviewProvider {
|
||||
struct PreviewWrapper: View {
|
||||
@State var text: String
|
||||
@State var focused: Bool
|
||||
@State var isMultiline: Bool
|
||||
|
||||
init(text: String) {
|
||||
_text = .init(initialValue: text)
|
||||
_focused = .init(initialValue: false)
|
||||
_isMultiline = .init(initialValue: false)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300, onEnterKeyHandler: { })
|
||||
MessageComposerTextField(placeholder: "Placeholder",
|
||||
text: $text,
|
||||
focused: $focused,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
onEnterKeyHandler: { })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ struct RoomScreen: View {
|
||||
|
||||
var body: some View {
|
||||
timeline
|
||||
.background(Color.element.background) // Kills the toolbar translucency.
|
||||
.safeAreaInset(edge: .bottom) { messageComposer }
|
||||
.background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency.
|
||||
.overlay(alignment: .top) { encryptionBanner } // Overlay for now, safeAreaInset breaks timeline scroll offset.
|
||||
.animation(.spring(), value: context.viewState.showEncryptionBanner)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { messageComposer }
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.toolbarRole(.editor) // Hide the back button title.
|
||||
@@ -49,6 +51,44 @@ struct RoomScreen: View {
|
||||
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var encryptionBanner: some View {
|
||||
if context.viewState.showEncryptionBanner {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label {
|
||||
Text("Unable to decrypt all messages")
|
||||
.foregroundColor(.element.primaryContent)
|
||||
} icon: {
|
||||
Image(systemName: "lock.shield")
|
||||
.foregroundColor(.element.background)
|
||||
.padding(4)
|
||||
.background(Color.element.tertiaryContent)
|
||||
.cornerRadius(5)
|
||||
}
|
||||
.font(.element.bodyBold)
|
||||
|
||||
Text("Accessing your encrypted message history is not fully supported yet.")
|
||||
.font(.element.subheadline)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Button(ElementL10n.globalRetry) {
|
||||
context.send(viewAction: .retryDecryption)
|
||||
}
|
||||
.buttonStyle(.elementCapsuleProminent)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.element.system)
|
||||
}
|
||||
.padding([.horizontal, .top], 16)
|
||||
.background(Color.element.background)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
var messageComposer: some View {
|
||||
MessageComposer(text: $context.composerText,
|
||||
focused: $context.composerFocused,
|
||||
@@ -110,7 +150,8 @@ struct RoomScreen_Previews: PreviewProvider {
|
||||
roomName: "Preview room")
|
||||
|
||||
NavigationView {
|
||||
RoomScreen(context: viewModel.context)
|
||||
RoomScreen(context: viewModel.context).encryptionBanner
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
}
|
||||
}
|
||||
.bubbleStyle(inset: true,
|
||||
color: timelineItem.isOutgoing ? .element.systemGray5 : .element.systemGray6,
|
||||
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
|
||||
cornerRadius: cornerRadius,
|
||||
corners: timelineItem.roundedCorners)
|
||||
}
|
||||
|
||||
@@ -51,12 +51,16 @@ struct TimelineReactionButton: View {
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
backgroundShape
|
||||
.strokeBorder(reaction.isHighlighted ? Color.element.secondaryContent : .element.background, lineWidth: 2)
|
||||
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: Capsule())
|
||||
.background(reaction.isHighlighted ? Color.element.accent.opacity(0.1) : .element.system, in: backgroundShape)
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
var backgroundShape: some InsettableShape {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionView_Previews: PreviewProvider {
|
||||
|
||||
@@ -21,23 +21,24 @@ struct EncryptedRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
Label {
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
} icon: {
|
||||
Image(systemName: "lock.shield")
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
}
|
||||
.labelStyle(RoomTimelineViewLabelStyle())
|
||||
Label(timelineItem.text, systemImage: "lock.shield")
|
||||
.labelStyle(RoomTimelineViewLabelStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomTimelineViewLabelStyle: LabelStyle {
|
||||
@Environment(\.timelineStyle) private var timelineStyle
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
configuration.icon
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
configuration.title
|
||||
.font(.body)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ struct FormattedBodyText: View {
|
||||
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
.lineLimit(3) // FIXME: Quotes vs replies
|
||||
.lineLimit(component.isReply ? 3 : nil)
|
||||
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
|
||||
.clipped()
|
||||
.background(Color.element.background)
|
||||
@@ -47,7 +47,7 @@ struct FormattedBodyText: View {
|
||||
}
|
||||
} else {
|
||||
Text(component.attributedString)
|
||||
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0) // FIXME: Configurable
|
||||
.padding(.horizontal, timelineStyle == .bubbles ? 4 : 0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ struct FormattedBodyText: View {
|
||||
|
||||
extension FormattedBodyText {
|
||||
init(text: String) {
|
||||
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)]
|
||||
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false, isReply: false)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ struct ImageRoomTimelineView: View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
LoadableImage(mediaSource: timelineItem.source,
|
||||
blurhash: timelineItem.blurhash,
|
||||
imageProvider: context.imageProvider) {
|
||||
imageProvider: context.imageProvider) { image in
|
||||
image.overlay { overlay }
|
||||
} placeholder: {
|
||||
placeholder
|
||||
}
|
||||
.frame(maxHeight: 300)
|
||||
@@ -43,6 +45,20 @@ struct ImageRoomTimelineView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var overlay: some View {
|
||||
if timelineItem.type == .gif {
|
||||
Text(ElementL10n.roomTimelineImageGif)
|
||||
.font(.element.bodyBold)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(.thinMaterial)
|
||||
.cornerRadius(8)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||
@@ -82,7 +98,8 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||
sender: .init(id: "Bob"),
|
||||
source: nil,
|
||||
aspectRatio: 0.7,
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
|
||||
type: .gif))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,21 @@ struct NoticeRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
|
||||
// Don't use RoomTimelineViewLabelStyle with FormattedBodyText as the formatted text
|
||||
// adds additional padding so the spacing between the icon and text is inconsistent.
|
||||
|
||||
// Spacing: 6 = label spacing - formatted text padding
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: "info.bubble").padding(.top, 2.0)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,18 +21,47 @@ struct ReadMarkerRoomTimelineView: View {
|
||||
let timelineItem: ReadMarkerRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer(minLength: 4.0)
|
||||
Divider()
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(Color.element.accent)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(ElementL10n.roomTimelineReadMarkerTitle)
|
||||
.textCase(.uppercase)
|
||||
.font(.element.caption2Bold)
|
||||
.foregroundColor(.element.quaternaryContent)
|
||||
Rectangle()
|
||||
.frame(height: 0.5)
|
||||
.foregroundColor(.element.quaternaryContent)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let item = ReadMarkerRoomTimelineItem()
|
||||
static var previews: some View {
|
||||
let item = ReadMarkerRoomTimelineItem()
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
RoomTimelineViewProvider.separator(.init(text: "Today"))
|
||||
RoomTimelineViewProvider.text(.init(id: "",
|
||||
text: "This is another message",
|
||||
timestamp: "",
|
||||
groupState: .single,
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "1", displayName: "Bob")))
|
||||
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
|
||||
RoomTimelineViewProvider.separator(.init(text: "Today"))
|
||||
RoomTimelineViewProvider.text(.init(id: "",
|
||||
text: "This is a message",
|
||||
timestamp: "",
|
||||
groupState: .single,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Alice")))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,9 @@ struct RedactedRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
}
|
||||
Label(timelineItem.text, systemImage: "trash")
|
||||
.labelStyle(RoomTimelineViewLabelStyle())
|
||||
.imageScale(.small) // Smaller icon so that the bubble remains rounded on the outside.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ struct StateRoomTimelineView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 36)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,15 @@ struct UnsupportedRoomTimelineView: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(timelineItem.text): \(timelineItem.eventType)")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
||||
Text(timelineItem.error)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.element.footnote)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.bubble")
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
}
|
||||
.labelStyle(RoomTimelineViewLabelStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable {
|
||||
case redact
|
||||
case reply
|
||||
case viewSource
|
||||
case retryDecryption(sessionId: String)
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
@@ -97,16 +96,12 @@ public struct TimelineItemContextMenu: View {
|
||||
}
|
||||
case .redact:
|
||||
Button(role: .destructive) { send(action) } label: {
|
||||
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")
|
||||
Label(ElementL10n.actionRemove, systemImage: "trash")
|
||||
}
|
||||
case .viewSource:
|
||||
Button { send(action) } label: {
|
||||
Label(ElementL10n.viewSource, systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
case .retryDecryption:
|
||||
Button { send(action) } label: {
|
||||
Label(ElementL10n.roomTimelineContextMenuRetryDecryption, systemImage: "arrow.down.message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user