mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Inlined Timestamp in bubble style for messages that have a bubble (#947)
* web-like solution * not super polished but working implementation * polishing the spacing and sizes * removing unused code * code improvements * adding a test case in the preview for RTL * addressing some PR comments * added some tests and polished the code * better naming * code improvement * RTL fix * Revert "RTL fix" This reverts commit 14e4468a5358769daa57891f4991e9e32da1c985. * better RTL fix * updated UI tests * separated some files * addressed some PR comments * some more tests
This commit is contained in:
+50
-23
@@ -17,17 +17,20 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemBubbledStylerView<Content: View, DeliveryStatus: View>: View {
|
||||
struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
@ViewBuilder let deliveryStatus: () -> DeliveryStatus
|
||||
|
||||
@ScaledMetric private var senderNameVerticalPadding = 3
|
||||
private let cornerRadius: CGFloat = 12
|
||||
|
||||
private var isTextItem: Bool {
|
||||
timelineItem is TextBasedRoomTimelineItem
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailingFirstTextBaseline) {
|
||||
VStack(alignment: alignment, spacing: -12) {
|
||||
@@ -83,7 +86,8 @@ struct TimelineItemBubbledStylerView<Content: View, DeliveryStatus: View>: View
|
||||
}
|
||||
}
|
||||
|
||||
deliveryStatus()
|
||||
TimelineReceiptView(timelineItem: timelineItem)
|
||||
.environmentObject(context)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
@@ -108,28 +112,48 @@ struct TimelineItemBubbledStylerView<Content: View, DeliveryStatus: View>: View
|
||||
cornerRadius: cornerRadius,
|
||||
corners: roundedCorners)
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
contentWithReply
|
||||
|
||||
if timelineItem.properties.isEdited {
|
||||
Text(L10n.commonEditedSuffix)
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
|
||||
if timelineItem.properties.deliveryStatus == .sendingFailed {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.element.alert)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
.bubbleStyle(inset: true,
|
||||
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
|
||||
cornerRadius: cornerRadius,
|
||||
corners: roundedCorners)
|
||||
contentWithTimestamp
|
||||
.bubbleStyle(inset: true,
|
||||
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
|
||||
cornerRadius: cornerRadius,
|
||||
corners: roundedCorners)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentWithTimestamp: some View {
|
||||
if isTextItem {
|
||||
ZStack(alignment: .topLeading) {
|
||||
contentWithReply
|
||||
.layoutPriority(1)
|
||||
localizedSendInfo
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
contentWithReply
|
||||
localizedSendInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var localizedSendInfo: some View {
|
||||
HStack(spacing: 4) {
|
||||
if let timelineItem = timelineItem as? TextBasedRoomTimelineItem {
|
||||
Text(timelineItem.localizedSendInfo)
|
||||
} else {
|
||||
Text(timelineItem.timestamp)
|
||||
}
|
||||
|
||||
if timelineItem.properties.deliveryStatus == .sendingFailed {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
}
|
||||
}
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(timelineItem.properties.deliveryStatus == .sendingFailed ? .element.alert : .element.secondaryContent)
|
||||
.padding(.bottom, -4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentWithReply: some View {
|
||||
@@ -213,6 +237,9 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
mockTimeline
|
||||
.previewDisplayName("Mock Timeline")
|
||||
mockTimeline
|
||||
.environment(\.layoutDirection, .rightToLeft)
|
||||
.previewDisplayName("Mock Timeline RTL")
|
||||
replies
|
||||
.previewDisplayName("Replies")
|
||||
}
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemPlainStylerView<Content: View, DeliveryStatus: View>: View {
|
||||
struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
@ViewBuilder let deliveryStatus: () -> DeliveryStatus
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
@@ -39,7 +38,8 @@ struct TimelineItemPlainStylerView<Content: View, DeliveryStatus: View>: View {
|
||||
supplementaryViews
|
||||
}
|
||||
}
|
||||
deliveryStatus()
|
||||
TimelineReceiptView(timelineItem: timelineItem)
|
||||
.environmentObject(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ import SwiftUI
|
||||
|
||||
struct TimelineStyler<Content: View>: View {
|
||||
@Environment(\.timelineStyle) private var style
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
context.viewState.items.last(where: { !$0.isUnsent })?.id == timelineItem.id &&
|
||||
timelineItem.isOutgoing
|
||||
}
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
@@ -34,36 +28,9 @@ struct TimelineStyler<Content: View>: View {
|
||||
var body: some View {
|
||||
switch style {
|
||||
case .plain:
|
||||
TimelineItemPlainStylerView(timelineItem: timelineItem, content: content) {
|
||||
deliveryStatusView
|
||||
}
|
||||
TimelineItemPlainStylerView(timelineItem: timelineItem, content: content)
|
||||
case .bubbles:
|
||||
TimelineItemBubbledStylerView(timelineItem: timelineItem, content: content) {
|
||||
deliveryStatusView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var deliveryStatusView: some View {
|
||||
switch timelineItem.properties.deliveryStatus {
|
||||
case .sending:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sending)
|
||||
case .sent:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
case .none:
|
||||
if isLastOutgoingMessage {
|
||||
// We always display the sent icon for the latest echoed outgoing message
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
}
|
||||
case .sendingFailed:
|
||||
if style == .plain {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.element.alert)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
// The bubbles handle the failure internally
|
||||
TimelineItemBubbledStylerView(timelineItem: timelineItem, content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +38,7 @@ struct TimelineStyler<Content: View>: View {
|
||||
struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let base = TextRoomTimelineItem(id: UUID().uuidString, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: ""))
|
||||
static let base = TextRoomTimelineItem(id: UUID().uuidString, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||
|
||||
static let sent: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
@@ -93,27 +60,17 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
|
||||
static let last: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.items.last?.id ?? UUID().uuidString
|
||||
let result = TextRoomTimelineItem(id: id, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: ""))
|
||||
let result = TextRoomTimelineItem(id: id, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||
return result
|
||||
}()
|
||||
|
||||
static var testView: some View {
|
||||
VStack {
|
||||
TimelineStyler(timelineItem: sent) {
|
||||
Text("Sent")
|
||||
}
|
||||
TimelineStyler(timelineItem: sending) {
|
||||
Text("Sending")
|
||||
}
|
||||
TimelineStyler(timelineItem: base) {
|
||||
Text("Normal")
|
||||
}
|
||||
TimelineStyler(timelineItem: last) {
|
||||
Text("Last")
|
||||
}
|
||||
TimelineStyler(timelineItem: failed) {
|
||||
Text("Failed")
|
||||
}
|
||||
TextRoomTimelineView(timelineItem: sent)
|
||||
TextRoomTimelineView(timelineItem: sending)
|
||||
TextRoomTimelineView(timelineItem: base)
|
||||
TextRoomTimelineView(timelineItem: last)
|
||||
TextRoomTimelineView(timelineItem: failed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// This is also going to be used for read receipts in the future
|
||||
struct TimelineReceiptView: View {
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@Environment(\.timelineStyle) private var style
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
|
||||
@State private var shouldShowDeliveryStatus = true
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
context.viewState.items.last(where: { !$0.isUnsent })?.id == timelineItem.id &&
|
||||
timelineItem.isOutgoing
|
||||
}
|
||||
|
||||
private var isLast: Bool {
|
||||
context.viewState.items.last?.id == timelineItem.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if shouldShowDeliveryStatus {
|
||||
deliveryStatus
|
||||
.onChange(of: timelineItem.properties.deliveryStatus) { newValue in
|
||||
if newValue == .sent, !isLast {
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
withAnimation {
|
||||
shouldShowDeliveryStatus = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryStatus: some View {
|
||||
switch timelineItem.properties.deliveryStatus {
|
||||
case .sending:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sending)
|
||||
case .sent:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
case .none:
|
||||
if isLastOutgoingMessage {
|
||||
// We always display the sent icon for the latest echoed outgoing message
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
}
|
||||
case .sendingFailed:
|
||||
if style == .plain {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.element.alert)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
// The bubbles handle the failure internally
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,16 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct EmoteRoomTimelineView: View {
|
||||
struct EmoteRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
|
||||
@Environment(\.timelineStyle) var timelineStyle
|
||||
let timelineItem: EmoteRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedString = timelineItem.content.formattedBody {
|
||||
FormattedBodyText(attributedString: attributedString)
|
||||
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: additionalWhitespaces)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: additionalWhitespaces)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,25 @@ import SwiftUI
|
||||
|
||||
struct FormattedBodyText: View {
|
||||
@Environment(\.timelineStyle) private var timelineStyle
|
||||
@Environment(\.layoutDirection) private var layoutDirection
|
||||
|
||||
private let attributedString: AttributedString
|
||||
private let additionalWhitespacesCount: Int
|
||||
|
||||
private var attributedComponents: [AttributedStringBuilderComponent] {
|
||||
var attributedString = attributedString
|
||||
attributedString.append(AttributedString(stringLiteral: additionalWhitespacesSuffix))
|
||||
return attributedString.formattedComponents
|
||||
}
|
||||
|
||||
private let attributedComponents: [AttributedStringBuilderComponent]
|
||||
|
||||
init(attributedString: AttributedString) {
|
||||
attributedComponents = attributedString.formattedComponents
|
||||
init(attributedString: AttributedString, additionalWhitespacesCount: Int = 0) {
|
||||
self.attributedString = attributedString
|
||||
self.additionalWhitespacesCount = additionalWhitespacesCount
|
||||
}
|
||||
|
||||
// These is needed to create the slightly off inlined timestamp effect
|
||||
private var additionalWhitespacesSuffix: String {
|
||||
.generateBreakableWhitespaceEnd(whitespaceCount: additionalWhitespacesCount, isRTL: layoutDirection == .rightToLeft)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -109,8 +123,8 @@ struct FormattedBodyText: View {
|
||||
}
|
||||
|
||||
extension FormattedBodyText {
|
||||
init(text: String) {
|
||||
self.init(attributedString: AttributedString(text))
|
||||
init(text: String, additionalWhitespacesCount: Int = 0) {
|
||||
self.init(attributedString: AttributedString(text), additionalWhitespacesCount: additionalWhitespacesCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct NoticeRoomTimelineView: View {
|
||||
struct NoticeRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
|
||||
let timelineItem: NoticeRoomTimelineItem
|
||||
@Environment(\.timelineStyle) var timelineStyle
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
@@ -32,9 +33,9 @@ struct NoticeRoomTimelineView: View {
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
|
||||
if let attributedString = timelineItem.content.formattedBody {
|
||||
FormattedBodyText(attributedString: attributedString)
|
||||
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: additionalWhitespaces)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: additionalWhitespaces)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol TextBasedRoomTimelineItem: EventBasedMessageTimelineItemProtocol { }
|
||||
|
||||
extension TextBasedRoomTimelineItem {
|
||||
/// contains the timestamp and an optional edited localised prefix
|
||||
/// example: (edited) 12:17 PM
|
||||
var localizedSendInfo: String {
|
||||
var start = ""
|
||||
if properties.isEdited {
|
||||
start = "\(L10n.commonEditedSuffix) "
|
||||
}
|
||||
return start + timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// generated with auto mockable and customised to support the generic
|
||||
class TextBasedRoomTimelineViewMock<TimelineItemType: TextBasedRoomTimelineItem>: TextBasedRoomTimelineViewProtocol {
|
||||
var timelineItem: TimelineItemType {
|
||||
get { underlyingTimelineItem }
|
||||
set(value) { underlyingTimelineItem = value }
|
||||
}
|
||||
|
||||
var underlyingTimelineItem: TimelineItemType!
|
||||
var timelineStyle: TimelineStyle {
|
||||
get { underlyingTimelineStyle }
|
||||
set(value) { underlyingTimelineStyle = value }
|
||||
}
|
||||
|
||||
var underlyingTimelineStyle: TimelineStyle!
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
protocol TextBasedRoomTimelineViewProtocol {
|
||||
associatedtype TimelineItemType: TextBasedRoomTimelineItem
|
||||
|
||||
var timelineItem: TimelineItemType { get }
|
||||
var timelineStyle: TimelineStyle { get }
|
||||
}
|
||||
|
||||
extension TextBasedRoomTimelineViewProtocol {
|
||||
var additionalWhitespaces: Int {
|
||||
guard timelineStyle == .bubbles else {
|
||||
return 0
|
||||
}
|
||||
var whiteSpaces = 1
|
||||
timelineItem.localizedSendInfo.forEach { _ in
|
||||
whiteSpaces += 1
|
||||
}
|
||||
|
||||
// To account for the extra spacing created by the alert icon
|
||||
if timelineItem.properties.deliveryStatus == .sendingFailed {
|
||||
whiteSpaces += 3
|
||||
}
|
||||
|
||||
return whiteSpaces
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,16 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TextRoomTimelineView: View {
|
||||
struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
|
||||
let timelineItem: TextRoomTimelineItem
|
||||
@Environment(\.timelineStyle) var timelineStyle
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedString = timelineItem.content.formattedBody {
|
||||
FormattedBodyText(attributedString: attributedString)
|
||||
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: additionalWhitespaces)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
FormattedBodyText(text: timelineItem.body, additionalWhitespacesCount: additionalWhitespaces)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user