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:
Mauro
2023-05-25 12:13:45 +02:00
committed by GitHub
parent 6b13c52cd0
commit c59921103d
44 changed files with 468 additions and 152 deletions
@@ -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!
}
@@ -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)
}
}
}