diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f1540e2be6..4473debb5f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -377,6 +377,7 @@ 5710AAB27D5D866292C1FE06 /* SessionVerificationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF848B41DAF1066F3054D4A1 /* SessionVerificationScreenModels.swift */; }; 5732395A4F71F51F9C754C5A /* ElementCallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33AE897D86784CCA5E4E9227 /* ElementCallService.swift */; }; 5780E444F405AA1304E1C23E /* DeveloperOptionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */; }; + 57B9562E6FE788FC172D4AAF /* TimelineItemSendInfoLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15447A39D91D2EF536C74DD /* TimelineItemSendInfoLabel.swift */; }; 57E115A8C33E599DE564F8C3 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDEB27575FEBCF414D4DEE31 /* TimelineView.swift */; }; 588411C8FD72B2A2DFE5F7DE /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E992D7B8BE54B2AB454613AF /* XCUIElement.swift */; }; 5894C2514400A4FBC9327632 /* ServerConfirmationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03277E40D0E0DE0712021A71 /* ServerConfirmationScreenCoordinator.swift */; }; @@ -2014,6 +2015,7 @@ D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = ""; }; D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineView.swift; sourceTree = ""; }; D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = ""; }; + D15447A39D91D2EF536C74DD /* TimelineItemSendInfoLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemSendInfoLabel.swift; sourceTree = ""; }; D162B2280A15ACAF35360554 /* HighlightedTimelineItemModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightedTimelineItemModifier.swift; sourceTree = ""; }; D1896F6288D80E1F3EFB3DF8 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ka; path = ka.lproj/Localizable.stringsdict; sourceTree = ""; }; D196116D2DD3F2757D45FCB7 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/SAS.strings; sourceTree = ""; }; @@ -4368,6 +4370,7 @@ E2DCA495ED42D2463DDAA94D /* TimelineBubbleLayout.swift */, 74C6F3DAD167F972702C8893 /* TimelineItemAccessibilityModifier.swift */, 98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */, + D15447A39D91D2EF536C74DD /* TimelineItemSendInfoLabel.swift */, 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */, 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */, ); @@ -6640,6 +6643,7 @@ 1C815DD79B401DEBA2914773 /* TimelineItemMock.swift in Sources */, 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */, 9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */, + 57B9562E6FE788FC172D4AAF /* TimelineItemSendInfoLabel.swift in Sources */, 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */, A680F54935A6ADEA4ED6C38F /* TimelineItemStatusView.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 2b31da559c..3cf57ed4b7 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -4,6 +4,10 @@ /* Used for testing */ "untranslated" = "Untranslated"; +// MARK: - Shields + +"send_info_not_encrypted" = "Not encrypted"; + // MARK: - Soft logout "soft_logout_signin_title" = "Sign in"; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 55cfabb3ea..b9029e983d 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -10,6 +10,8 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum UntranslatedL10n { + /// Not encrypted + internal static var sendInfoNotEncrypted: String { return UntranslatedL10n.tr("Untranslated", "send_info_not_encrypted") } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index fff5b30f53..b719c36c2f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -14,10 +14,8 @@ // limitations under the License. // -import Foundation -import SwiftUI - import Compound +import SwiftUI struct TimelineItemBubbledStylerView: View { @EnvironmentObject private var context: RoomScreenViewModel.Context @@ -34,9 +32,9 @@ struct TimelineItemBubbledStylerView: 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: 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: 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: 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) -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemSendInfoLabel.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemSendInfoLabel.swift new file mode 100644 index 0000000000..0c020d4ec8 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemSendInfoLabel.swift @@ -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? { + 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())) + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 8363ed4a33..22e4291e6a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -42,17 +42,17 @@ extension EventBasedTimelineItemProtocol { var isRemoteMessage: Bool { id.eventID != nil } - - var hasFailedToSend: Bool { - properties.deliveryStatus == .sendingFailed + + var isRedacted: Bool { + self is RedactedRoomTimelineItem } - + var pollIfAvailable: Poll? { (self as? PollRoomTimelineItem)?.poll } - var isRedacted: Bool { - self is RedactedRoomTimelineItem + var hasFailedToSend: Bool { + properties.deliveryStatus == .sendingFailed } var hasFailedDecryption: Bool { diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPad-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPad-en-GB.1.png new file mode 100644 index 0000000000..6736fd1f16 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPad-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f04ababd10ae20834830511223418d25b49030ed0be58cb649c437acd4ed7b9 +size 81479 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPad-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPad-pseudo.1.png new file mode 100644 index 0000000000..6736fd1f16 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPad-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f04ababd10ae20834830511223418d25b49030ed0be58cb649c437acd4ed7b9 +size 81479 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPhone-15-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPhone-15-en-GB.1.png new file mode 100644 index 0000000000..c5054a9f11 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPhone-15-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1b0ecbae3765e60c67f4a468d61586652c31a5d0f4dcdc32dd35de886694288 +size 38900 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPhone-15-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPhone-15-pseudo.1.png new file mode 100644 index 0000000000..c5054a9f11 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_timelineItemSendInfoLabel-iPhone-15-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1b0ecbae3765e60c67f4a468d61586652c31a5d0f4dcdc32dd35de886694288 +size 38900