// // Copyright 2022 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 import SwiftUI import Compound struct TimelineItemBubbledStylerView: View { @EnvironmentObject private var context: RoomScreenViewModel.Context @Environment(\.timelineGroupStyle) private var timelineGroupStyle let timelineItem: EventBasedTimelineItemProtocol let adjustedDeliveryStatus: TimelineItemDeliveryStatus? @ViewBuilder let content: () -> Content @State private var showItemActionMenu = false private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom } /// 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 /// Additional padding applied to outgoing bubbles when the avatar is shown var bubbleAvatarPadding: CGFloat { guard !timelineItem.isOutgoing, !isEncryptedOneToOneRoom else { return 0 } return 8 } var body: some View { ZStack(alignment: .trailingFirstTextBaseline) { VStack(alignment: alignment, spacing: -12) { if !timelineItem.isOutgoing, !isEncryptedOneToOneRoom { header .zIndex(1) } VStack(alignment: alignment, spacing: 0) { HStack(spacing: 0) { if timelineItem.isOutgoing { Spacer() } messageBubbleWithReactions } .padding(timelineItem.isOutgoing ? .leading : .trailing, 48) // Additional padding to differentiate alignment. HStack(spacing: 0) { if !timelineItem.isOutgoing { Spacer() } TimelineItemStatusView(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus) .environmentObject(context) .padding(.top, 8) .padding(.bottom, 3) } } .padding(.horizontal, bubbleHorizontalPadding) .padding(.leading, bubbleAvatarPadding) } } } @ViewBuilder private var header: some View { if shouldShowSenderDetails { HStack(alignment: .top, spacing: 4) { TimelineSenderAvatarView(timelineItem: timelineItem) Text(timelineItem.sender.displayName ?? timelineItem.sender.id) .font(.compound.bodySMSemibold) .foregroundColor(.compound.avatarColor(for: timelineItem.sender.id).foreground) .lineLimit(1) .scaledPadding(.vertical, 3) } // sender info are read inside the `TimelineAccessibilityModifier` .accessibilityHidden(true) .onTapGesture { context.send(viewAction: .tappedOnUser(userID: timelineItem.sender.id)) } .padding(.top, 8) } } private var messageBubbleWithReactions: some View { // Figma overlaps reactions by 3 VStack(alignment: alignment, spacing: -3) { messageBubble .timelineItemAccessibility(timelineItem) { if adjustedDeliveryStatus == .sendingFailed { context.sendFailedConfirmationDialogInfo = .init(itemID: timelineItem.id) } else { context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id)) } } if !timelineItem.properties.reactions.isEmpty { TimelineReactionsView(context: context, itemID: timelineItem.id, reactions: timelineItem.properties.reactions, isLayoutRTL: timelineItem.isOutgoing) // Workaround to stop the message long press stealing the touch from the reaction buttons .onTapGesture { } } } } var messageBubble: some View { styledContent .onTapGesture(count: 2) { context.send(viewAction: .displayEmojiPicker(itemID: timelineItem.id)) } .onTapGesture { context.send(viewAction: .itemTapped(itemID: timelineItem.id)) } // We need a tap gesture before this long one so that it doesn't // steal away the gestures from the scroll view .longPressWithFeedback { context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { SwipeToReplyView(timelineItem: timelineItem) } shouldStartAction: { context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false } action: { let isThread = (timelineItem as? EventBasedMessageTimelineItemProtocol)?.isThreaded ?? false context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: isThread))) } .contextMenu { TimelineItemMacContextMenu(item: timelineItem, actionProvider: context.viewState.timelineItemMenuActionProvider) { action in context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: action)) } } .padding(.top, messageBubbleTopPadding) } @ViewBuilder var styledContent: some View { contentWithTimestamp .bubbleStyle(insets: timelineItem.bubbleInsets, color: timelineItem.bubbleBackgroundColor, corners: roundedCorners) } @ViewBuilder var contentWithTimestamp: some View { timelineItem.bubbleSendInfoLayoutType .layout { contentWithReply interactiveLocalizedSendInfo } } @ViewBuilder var interactiveLocalizedSendInfo: some View { if adjustedDeliveryStatus == .sendingFailed { layoutedLocalizedSendInfo .onTapGesture { context.sendFailedConfirmationDialogInfo = .init(itemID: timelineItem.id) } } else { 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 { TimelineBubbleLayout(spacing: 8) { if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol { if messageTimelineItem.isThreaded { ThreadDecorator() .padding(.leading, 4) .layoutPriority(TimelineBubbleLayout.Priority.regularText) } if let replyDetails = messageTimelineItem.replyDetails { // The rendered reply bubble with a greedy width. The custom layout prevents // the infinite width from increasing the overall width of the view. TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) .fixedSize(horizontal: false, vertical: true) .padding(4.0) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.compound.bgCanvasDefault) .cornerRadius(8) .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) // Add a fixed width reply bubble that is used for layout calculations but won't be rendered. TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) .fixedSize(horizontal: false, vertical: true) .padding(4.0) .layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote) .hidden() } } content() .layoutPriority(TimelineBubbleLayout.Priority.regularText) .cornerRadius(timelineItem.contentCornerRadius) } } private var messageBubbleTopPadding: CGFloat { guard timelineItem.isOutgoing || isEncryptedOneToOneRoom else { return 0 } return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0 } private var alignment: HorizontalAlignment { timelineItem.isOutgoing ? .trailing : .leading } private var roundedCorners: UIRectCorner { switch timelineGroupStyle { case .single: return .allCorners case .first: if timelineItem.isOutgoing { return [.topLeft, .topRight, .bottomLeft] } else { return [.topLeft, .topRight, .bottomRight] } case .middle: return timelineItem.isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight] case .last: if timelineItem.isOutgoing { return [.topLeft, .bottomLeft, .bottomRight] } else { return [.topRight, .bottomLeft, .bottomRight] } } } private var shouldShowSenderDetails: Bool { timelineGroupStyle.shouldShowSenderDetails } } private extension View { func bubbleStyle(insets: EdgeInsets, color: Color? = nil, cornerRadius: CGFloat = 12, corners: UIRectCorner) -> some View { padding(insets) .background(color) .cornerRadius(cornerRadius, corners: corners) } } // 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 switch self { case let self as EventBasedMessageTimelineItemProtocol: switch self { case is ImageRoomTimelineItem, is VideoRoomTimelineItem: // In case a reply detail or a thread decorator is present we render the color and the padding return self.replyDetails != nil || self.isThreaded ? defaultColor : nil default: return defaultColor } case is StickerRoomTimelineItem: return nil default: return defaultColor } } // The insets for the full bubble content. // Padding affecting just the "send info" should be added inside `layoutedLocalizedSendInfo` var bubbleInsets: EdgeInsets { let defaultInsets: EdgeInsets = .init(around: 8) switch self { case is StickerRoomTimelineItem: return .zero case is PollRoomTimelineItem: return .init(top: 12, leading: 12, bottom: 4, trailing: 12) case let self as EventBasedMessageTimelineItemProtocol: switch self { // In case a reply detail or a thread decorator is present we render the color and the padding case is ImageRoomTimelineItem, is VideoRoomTimelineItem: return self.replyDetails != nil || self.isThreaded ? defaultInsets : .zero case let locationTimelineItem as LocationRoomTimelineItem: return locationTimelineItem.content.geoURI == nil || self.replyDetails != nil || self.isThreaded ? defaultInsets : .zero default: return defaultInsets } default: 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 } switch message { case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is LocationRoomTimelineItem: return message.replyDetails != nil || message.isThreaded ? 8 : .zero default: return .zero } } } struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel.mock static var previews: some View { mockTimeline .previewDisplayName("Mock Timeline") mockTimeline .environment(\.layoutDirection, .rightToLeft) .previewDisplayName("Mock Timeline RTL") replies .previewDisplayName("Replies") threads .previewDisplayName("Thread decorator") } // These akwats include a reply static var threads: some View { ScrollView { RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, canBeRepliedTo: true, isThreaded: true, sender: .init(id: "whoever"), content: .init(body: "A long message that should be on multiple lines."), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short"))))), groupStyle: .single)) AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, canBeRepliedTo: true, isThreaded: true, sender: .init(id: ""), content: .init(body: "audio.ogg", duration: 100, waveform: EstimatedWaveform.mockWaveform, source: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short")))))) FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: false, isEditable: false, canBeRepliedTo: true, isThreaded: true, sender: .init(id: ""), content: .init(body: "File", source: nil, thumbnailSource: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short")))))) ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: true, canBeRepliedTo: true, isThreaded: true, sender: .init(id: ""), content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short")))))) LocationRoomTimelineView(timelineItem: .init(id: .random, timestamp: "Now", isOutgoing: false, isEditable: false, canBeRepliedTo: true, isThreaded: true, sender: .init(id: "Bob"), content: .init(body: "Fallback geo uri description", geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short")))))) LocationRoomTimelineView(timelineItem: .init(id: .random, timestamp: "Now", isOutgoing: false, isEditable: false, canBeRepliedTo: true, isThreaded: true, sender: .init(id: "Bob"), content: .init(body: "Fallback geo uri description", geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short")))))) VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, canBeRepliedTo: true, isThreaded: true, sender: .init(id: ""), content: .init(body: "audio.ogg", duration: 100, waveform: EstimatedWaveform.mockWaveform, source: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short"))))), playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform)) } .environmentObject(viewModel.context) } static var mockTimeline: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in RoomTimelineItemView(viewState: viewState) .padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells } } } .environment(\.timelineStyle, .bubbles) .environmentObject(viewModel.context) } static var replies: some View { VStack(spacing: 0) { RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, canBeRepliedTo: true, isThreaded: false, sender: .init(id: "whoever"), content: .init(body: "A long message that should be on multiple lines."), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "Short"))))), groupStyle: .single)) RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, canBeRepliedTo: true, isThreaded: false, sender: .init(id: "whoever"), content: .init(body: "Short message"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), eventContent: .message(.text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout."))))), groupStyle: .single)) } .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) }