Refactor TimelineItemSendInfo out of the styler. (#3100)

* Reorder some properties.

No code changes.

* Refactor TimelineItemSendInfo out of the styler.

* Add (unused) examples of encryption shields.

* Rename file.
This commit is contained in:
Doug
2024-07-30 09:43:17 +01:00
committed by GitHub
parent 0fab3a8afc
commit a2ea9c4a06
10 changed files with 235 additions and 114 deletions
@@ -14,10 +14,8 @@
// limitations under the License.
//
import Foundation
import SwiftUI
import Compound
import SwiftUI
struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@@ -34,9 +32,9 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
/// The base padding applied to bubbles on either side.
///
/// **Note:** This is on top of the insets applied to the cells by the table view.
let bubbleHorizontalPadding: CGFloat = 8
private let bubbleHorizontalPadding: CGFloat = 8
/// Additional padding applied to outgoing bubbles when the avatar is shown
var bubbleAvatarPadding: CGFloat {
private var bubbleAvatarPadding: CGFloat {
guard !timelineItem.isOutgoing, !isEncryptedOneToOneRoom else { return 0 }
return 8
}
@@ -108,7 +106,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
private var messageBubbleWithReactions: some View {
// Figma overlaps reactions by 3
VStack(alignment: alignment, spacing: -3) {
messageBubble
messageBubbleWithActions
.timelineItemAccessibility(timelineItem) {
context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id))
}
@@ -124,8 +122,8 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
}
}
var messageBubble: some View {
styledContent
var messageBubbleWithActions: some View {
messageBubble
.onTapGesture {
context.send(viewAction: .itemTapped(itemID: timelineItem.id))
}
@@ -156,59 +154,14 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
}
.padding(.top, messageBubbleTopPadding)
}
@ViewBuilder
var styledContent: some View {
contentWithTimestamp
var messageBubble: some View {
contentWithReply
.timelineItemSendInfo(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus)
.bubbleStyle(insets: timelineItem.bubbleInsets,
color: timelineItem.bubbleBackgroundColor,
corners: roundedCorners)
}
@ViewBuilder
var contentWithTimestamp: some View {
timelineItem.bubbleSendInfoLayoutType
.layout {
contentWithReply
layoutedLocalizedSendInfo
}
}
@ViewBuilder
var layoutedLocalizedSendInfo: some View {
switch timelineItem.bubbleSendInfoLayoutType {
case .overlay(capsuleStyle: true):
localizedSendInfo
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.compound.bgSubtleSecondary)
.cornerRadius(10)
.padding(.trailing, 4)
.padding(.bottom, 4)
case .horizontal, .overlay(capsuleStyle: false):
localizedSendInfo
.padding(.bottom, -4)
case .vertical:
GridRow {
localizedSendInfo
.gridColumnAlignment(.trailing)
}
}
}
@ViewBuilder
var localizedSendInfo: some View {
HStack(spacing: 4) {
Text(timelineItem.localizedSendInfo)
if adjustedDeliveryStatus == .sendingFailed {
CompoundIcon(\.error, size: .xSmall, relativeTo: .compound.bodyXS)
.accessibilityLabel(L10n.commonSendingFailed)
}
}
.font(.compound.bodyXS)
.foregroundColor(adjustedDeliveryStatus == .sendingFailed ? .compound.textCriticalPrimary : .compound.textSecondary)
}
@ViewBuilder
var contentWithReply: some View {
@@ -293,28 +246,6 @@ private extension View {
}
}
// Describes how the content and the send info should be arranged inside a bubble
private enum BubbleSendInfoLayoutType {
case horizontal(spacing: CGFloat = 4)
case vertical(spacing: CGFloat = 4)
case overlay(capsuleStyle: Bool)
var layout: AnyLayout {
let layout: any Layout
switch self {
case .horizontal(let spacing):
layout = HStackLayout(alignment: .bottom, spacing: spacing)
case .vertical(let spacing):
layout = GridLayout(alignment: .leading, verticalSpacing: spacing)
case .overlay:
layout = ZStackLayout(alignment: .bottomTrailing)
}
return AnyLayout(layout)
}
}
private extension EventBasedTimelineItemProtocol {
var bubbleBackgroundColor: Color? {
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
@@ -335,8 +266,8 @@ private extension EventBasedTimelineItemProtocol {
}
}
// The insets for the full bubble content.
// Padding affecting just the "send info" should be added inside `layoutedLocalizedSendInfo`
/// The insets for the full bubble content.
/// Padding affecting just the "send info" should be added inside `TimelineItemSendInfoView`
var bubbleInsets: EdgeInsets {
let defaultInsets: EdgeInsets = .init(around: 8)
@@ -363,25 +294,6 @@ private extension EventBasedTimelineItemProtocol {
return defaultInsets
}
}
var bubbleSendInfoLayoutType: BubbleSendInfoLayoutType {
let defaultTimestampLayout: BubbleSendInfoLayoutType = .horizontal()
switch self {
case is TextBasedRoomTimelineItem:
return .overlay(capsuleStyle: false)
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
return .overlay(capsuleStyle: true)
case let locationTimelineItem as LocationRoomTimelineItem:
return .overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil)
case is PollRoomTimelineItem:
return .vertical(spacing: 16)
default:
return defaultTimestampLayout
}
}
var contentCornerRadius: CGFloat {
guard let message = self as? EventBasedMessageTimelineItemProtocol else { return .zero }
@@ -395,6 +307,16 @@ private extension EventBasedTimelineItemProtocol {
}
}
private extension EdgeInsets {
init(around: CGFloat) {
self.init(top: around, leading: around, bottom: around, trailing: around)
}
static var zero: Self = .init(around: 0)
}
// MARK: - Previews
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
@@ -556,11 +478,3 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
.environmentObject(viewModel.context)
}
}
private extension EdgeInsets {
init(around: CGFloat) {
self.init(top: around, leading: around, bottom: around, trailing: around)
}
static var zero: Self = .init(around: 0)
}
@@ -0,0 +1,185 @@
//
// Copyright 2024 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 Compound
import SwiftUI
extension View {
/// Adds the send info (timestamp along indicators for edits and delivery/encryption issues) for the given timeline item to this view.
func timelineItemSendInfo(timelineItem: EventBasedTimelineItemProtocol,
adjustedDeliveryStatus: TimelineItemDeliveryStatus?) -> some View {
modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem,
adjustedDeliveryStatus: adjustedDeliveryStatus)))
}
}
/// Adds the send info to a view with the correct layout.
private struct TimelineItemSendInfoModifier: ViewModifier {
let sendInfo: TimelineItemSendInfo
var layout: AnyLayout {
switch sendInfo.layoutType {
case .horizontal(let spacing):
AnyLayout(HStackLayout(alignment: .bottom, spacing: spacing))
case .vertical(let spacing):
AnyLayout(GridLayout(alignment: .leading, verticalSpacing: spacing))
case .overlay:
AnyLayout(ZStackLayout(alignment: .bottomTrailing))
}
}
func body(content: Content) -> some View {
layout {
content
TimelineItemSendInfoLabel(sendInfo: sendInfo)
}
}
}
/// The label shown for a timeline item with info about it's timestamp and various other indicators.
private struct TimelineItemSendInfoLabel: View {
let sendInfo: TimelineItemSendInfo
var statusIcon: KeyPath<CompoundIcons, Image>? {
switch sendInfo.status {
case .sendingFailed: \.error
case .unverifiedSession, .authenticityUnknown: \.admin
case .unencrypted: \.keyOff
case .none: nil
}
}
var statusIconAccessibilityLabel: String? {
switch sendInfo.status {
case .sendingFailed: L10n.commonSendingFailed
case .none: nil
// Temporary testing strings.
case .unverifiedSession: L10n.eventShieldReasonUnsignedDevice
case .authenticityUnknown: L10n.eventShieldReasonAuthenticityNotGuaranteed
case .unencrypted: UntranslatedL10n.sendInfoNotEncrypted
}
}
var body: some View {
switch sendInfo.layoutType {
case .overlay(capsuleStyle: true):
content
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.compound.bgSubtleSecondary)
.cornerRadius(10)
.padding(.trailing, 4)
.padding(.bottom, 4)
case .horizontal, .overlay(capsuleStyle: false):
content
.padding(.bottom, -4)
case .vertical:
GridRow {
content
.gridColumnAlignment(.trailing)
}
}
}
@ViewBuilder
var content: some View {
HStack(spacing: 4) {
Text(sendInfo.localizedString)
if let statusIcon, let statusIconAccessibilityLabel {
CompoundIcon(statusIcon, size: .xSmall, relativeTo: .compound.bodyXS)
.accessibilityLabel(statusIconAccessibilityLabel)
}
}
.font(.compound.bodyXS)
.foregroundStyle(sendInfo.foregroundStyle)
}
}
/// All the data needed to render a timeline item's send info label.
private struct TimelineItemSendInfo {
enum Status { case sendingFailed, unverifiedSession, authenticityUnknown, unencrypted }
/// Describes how the content and the send info should be arranged inside a bubble
enum LayoutType {
case horizontal(spacing: CGFloat = 4)
case vertical(spacing: CGFloat = 4)
case overlay(capsuleStyle: Bool)
}
let localizedString: String
var status: Status?
let layoutType: LayoutType
var foregroundStyle: Color {
switch status {
case .sendingFailed, .unverifiedSession:
.compound.textCriticalPrimary
case .authenticityUnknown, .unencrypted, .none:
.compound.textSecondary
}
}
}
private extension TimelineItemSendInfo {
init(timelineItem: EventBasedTimelineItemProtocol, adjustedDeliveryStatus: TimelineItemDeliveryStatus?) {
localizedString = timelineItem.localizedSendInfo
status = if adjustedDeliveryStatus == .sendingFailed {
.sendingFailed
} else {
nil
}
layoutType = switch timelineItem {
case is TextBasedRoomTimelineItem:
.overlay(capsuleStyle: false)
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
.overlay(capsuleStyle: true)
case let locationTimelineItem as LocationRoomTimelineItem:
.overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil)
case is PollRoomTimelineItem:
.vertical(spacing: 16)
default:
.horizontal()
}
}
}
// MARK: - Previews
struct TimelineItemSendInfoLabel_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 16) {
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
status: .sendingFailed,
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
status: .unverifiedSession,
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
status: .authenticityUnknown,
layoutType: .horizontal()))
TimelineItemSendInfoLabel(sendInfo: .init(localizedString: "09:47 AM",
status: .unencrypted,
layoutType: .horizontal()))
}
}
}