From 45035a8ad48828a035ed554e4e3aa4967c259b7a Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:00:53 -0800 Subject: [PATCH 01/50] Moved MessageMenu.swift into a MessageMenu subfolder --- .../MessageMenu/MessageMenu+Action.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+Action.swift diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+Action.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+Action.swift new file mode 100644 index 00000000..a6762c88 --- /dev/null +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+Action.swift @@ -0,0 +1,55 @@ +// +// MessageMenu+Action.swift +// Chat +// + +import SwiftUI + +public protocol MessageMenuAction: Equatable, CaseIterable { + func title() -> String + func icon() -> Image +} + +public enum DefaultMessageMenuAction: MessageMenuAction { + + case copy + case reply + case edit(saveClosure: (String) -> Void) + + public func title() -> String { + switch self { + case .copy: + "Copy" + case .reply: + "Reply" + case .edit: + "Edit" + } + } + + public func icon() -> Image { + switch self { + case .copy: + Image(systemName: "doc.on.doc") + case .reply: + Image(systemName: "arrowshape.turn.up.left") + case .edit: + Image(systemName: "bubble.and.pencil") + } + } + + public static func == (lhs: DefaultMessageMenuAction, rhs: DefaultMessageMenuAction) -> Bool { + switch (lhs, rhs) { + case (.copy, .copy), + (.reply, .reply), + (.edit(_), .edit(_)): + return true + default: + return false + } + } + + public static var allCases: [DefaultMessageMenuAction] = [ + .copy, .reply, .edit(saveClosure: {_ in}) + ] +} From f5b82f6782d16adb297e362e864204b36686672f Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:01:46 -0800 Subject: [PATCH 02/50] Moved MessageMenu.swift into the MessageMenu subfolder --- .../MessageView/MessageMenu/MessageMenu.swift | 679 ++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift new file mode 100644 index 00000000..d8a77b40 --- /dev/null +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift @@ -0,0 +1,679 @@ +// +// MessageMenu.swift +// +// +// Created by Alisa Mylnikova on 20.03.2023. +// + +import SwiftUI + +enum MessageMenuAlignment { + case left + case right +} + +struct MessageMenu: View { + + struct ReactionConfig { + /// The delegate used to configure our Reaction views on a per message basis + var delegate: ReactionDelegate? + /// Our internal didReact handler that allows for proper view dismissal + var didReact: (ReactionType?) -> () + } + + @Environment(\.safeAreaInsets) private var safeAreaInsets + @Environment(\.chatTheme) private var theme + @Environment(\.dismiss) var dismiss + + @StateObject private var keyboardState = KeyboardState() + @StateObject var viewModel: ChatViewModel + + @Binding var isShowingMenu: Bool + + /// Overall ChatView Frame + let chatViewFrame: CGRect = UIScreen.main.bounds + + /// The max height for the menu + /// - Note: menus that exceed this value will be placed in a ScrollView + let maxMenuHeight: CGFloat = 200 + + /// The vertical spacing between the main three components in out VStack (ReactionSelection, Message and Menu) + let verticalSpacing:CGFloat = 0 + + /// The message whose menu we're presenting + var message: Message + /// The original message frame (the row / cell) + var cellFrame: CGRect + /// Leading / Trailing message alignment + var alignment: MessageMenuAlignment + /// Leading padding (includes space for avatar) + var leadingPadding: CGFloat + /// Trailing padding + var trailingPadding: CGFloat + /// The font we should use to render our message menu button + var font: UIFont? = nil + /// The duration most of our animations take + /// - Note: This value is more akin to 'how snappy' the menu feels. Good values are between 0.15 - 0.5 + var animationDuration: Double = 0.3 + /// The animation to use for displaying / dismissing this view + var defaultTransition: AnyTransition = .scaleAndFade + /// The menu button actions to be rendered + var onAction: (ActionEnum) -> () + /// The current reaction configuration (delegate and callback) + var reactionHandler: ReactionConfig + /// The main message, rendered as a button + var mainButton: () -> MainButton + + /// The vertical offset necessary to ensure the message, and it's surrounding components, + /// are visible on screen, and within the vertical safeAreaInsets. + @State private var verticalOffset: CGFloat = 0 + /// The horizontal offset necessary to ensure the message, and it's surrounding components, + /// are visible on screen, and within the horizontal safeAreaInsets. + @State private var horizontalOffset: CGFloat = 0 + /// Used to store the previous `verticalOffset` when launching the emoji keyboard + @State private var lastVerticalOffset: CGFloat = 0 + + /// The current state that this view is in + @State private var viewState: ViewState = .initial + + /// The style the message menu should be presented in + /// Either a VStack or a ScrollView + @State private var messageMenuStyle: MenuStyle = .vStack + + /// The style the menu should be presented in + /// Either a VStack or a ScrollView + @State private var menuStyle: MenuStyle = .vStack + + /// The Rendered MessageFrame Size + /// - Note: These get populated during the `.prepare` viewState + @State private var messageFrame: CGRect = .zero + @State private var messageMenuFrame: CGRect = .zero + @State private var reactionSelectionHeight: CGFloat = .zero + @State private var reactionOverviewHeight: CGFloat = .zero + @State private var menuHeight: CGFloat = .zero + + /// Controls whether or not the reaction selection view is rendered + @State private var reactionSelectionIsVisible: Bool = true + /// Controls whether or not the reaction overview is rendered + @State private var reactionOverviewIsVisible: Bool = false + /// Controls whether or not the menu view is rendered + @State private var menuIsVisible: Bool = true + + /// Dynamic padding amounts + @State private var reactionSelectionBottomPadding: CGFloat = 0 + @State private var messageTopPadding: CGFloat = 0 + /// Dynamic opacity vars + @State private var messageMenuOpacity: CGFloat = 0.0 + @State private var backgroundOpacity: CGFloat = 0.0 + + /// This flag is used to adjust the dismiss animation + @State private var didReact:Bool = false + /// We use this `onReaction` handler in order to set our `didReact` flag and kick off the dismissal sequence + private func handleOnReaction(_ rt:ReactionType?) { + guard let rt else { transitionViewState(to: .ready); return } + didReact = true + dismissSelf(rt) + } + + /// The max height for the entire message menu and surrounding views + var maxEntireHeight: CGFloat { + self.chatViewFrame.height + } + + /// Unwraps and returns our optional `UIFont` as a `Font` or `nil` + var getFont: Font? { + if let font { + return Font(font) + } else { + return nil + } + } + + var shouldShowReactionSelectionView:Bool { + guard let delegate = reactionHandler.delegate else { return false } + return delegate.canReact(to: message) + } + + var shouldShowReactionOverviewView:Bool { + guard let delegate = reactionHandler.delegate else { return false } + return delegate.shouldShowOverview(for: message) + } + + var shouldAllowEmojiSearch:Bool { + guard let delegate = reactionHandler.delegate else { return false } + return delegate.allowEmojiSearch(for: message) + } + + var reactions:[ReactionType]? { + guard let delegate = reactionHandler.delegate else { return nil } + return delegate.reactions(for: message) + } + + public var body: some View { + ZStack(alignment: .top) { + + // Reaction Overview Rectangle + if reactionOverviewIsVisible, case .vStack = messageMenuStyle { + ReactionOverview(viewModel: viewModel, message: message, backgroundColor: theme.colors.messageFriendBG) + .frame(maxWidth: chatViewFrame.width - safeAreaInsets.leading - safeAreaInsets.trailing) + .maxHeightGetter($reactionOverviewHeight) + .offset(y: safeAreaInsets.top) + .transition(defaultTransition) + .opacity(messageMenuOpacity) + } + + // Some views to help debug layout and animations + //debugViews() + + // The message and menu view + messageMenuView() + .frameGetter($messageMenuFrame) + .position(x: chatViewFrame.width / 2 + horizontalOffset, y: verticalOffset) + .opacity(messageMenuOpacity) + + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + .background( + ZStack { + Rectangle() + .foregroundStyle(.ultraThinMaterial) + Rectangle() + .fill(.primary.opacity(0.1)) + } + .edgesIgnoringSafeArea(.all) + .opacity(backgroundOpacity) + .onTapGesture { + if viewState == .keyboard { + keyboardState.resignFirstResponder() + transitionViewState(to: .ready) + } else { + dismissSelf() + } + } + ) + .onAppear { + transitionViewState(to: .prepare) + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(animationDuration * 333))) { + transitionViewState(to: .original) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(animationDuration * 666))) { + transitionViewState(to: .ready) + } + } + .onChange(of: keyboardState.keyboardFrame) { _ in + if viewState == .ready, keyboardState.isShown { + transitionViewState(to: .keyboard) + } + } + } + + @MainActor + private func transitionViewState(to vs: ViewState) { + guard viewState != vs, viewState != .dismiss, vs != .initial else { return } + let oldState = viewState + viewState = vs + switch viewState { + case .initial: + fatalError("Shouldn't be called") + + case .prepare: + /// Ensure we set this to true + isShowingMenu = true + + /// Set our view state variables + reactionSelectionBottomPadding = message.reactions.isEmpty ? 2 : 6 + reactionOverviewIsVisible = shouldShowReactionOverviewView + reactionSelectionIsVisible = shouldShowReactionSelectionView + menuIsVisible = true + verticalOffset = UIScreen.main.bounds.height * 2 + + /// Kick off the background animation + withAnimation(.easeInOut(duration: animationDuration)) { + backgroundOpacity = 1.0 + } + + case .original: + /// Our views were rendered transparently in the `.prepare` state allowing us to + /// capture their preferred sizes (based on the devices dynamic text), so now we can configure our view + + /// If we have a detailed message frame stored in the viewModel use it, otherwise fall back to the cell's frame + /// - Note: this optional message frame allows us to place the reaction at the correct spot on the message (top left / right corner) + if viewModel.messageFrame == .zero { + //print("WARNING::ViewModel.MessageFrame not set") + viewModel.messageFrame = cellFrame + } + + /// If we're in landscape mode, adjust the `horizontalOffset` appropriately + if UIScreen.main.bounds.width > UIScreen.main.bounds.height { + switch alignment { + case .left: + horizontalOffset = safeAreaInsets.leading + case .right: + horizontalOffset = -safeAreaInsets.trailing + default: + horizontalOffset = 0 + } + } + messageFrame = .init( + x: viewModel.messageFrame.origin.x + horizontalOffset, + y: cellFrame.maxY - (viewModel.messageFrame.height), + width: viewModel.messageFrame.width, + height: viewModel.messageFrame.height + ) + + // - TODO: Reference the cells PositionInUserGroup for the exact padding amount + messageTopPadding = cellFrame.height - messageFrame.height + if !message.reactions.isEmpty { messageTopPadding = 4 } + + /// Calculate our vertical safe area insets + let safeArea = safeAreaInsets.top + safeAreaInsets.bottom + /// Calculate our ReactionOverview height + let rOHeight:CGFloat = reactionOverviewIsVisible ? reactionOverviewHeight : 0 + /// We calculate the total height here, instead of using messageMenuFrame.height + /// messageMenuHeight renders the menu buttons in a VStack by default, and we need to account for the clamping of the menu height + let totalMenuHeight = calculateMessageMenuHeight(including: [.message, .reactionSelection]) + min(menuHeight, maxMenuHeight) + /// Compare our total menu height with our free screen space to determine if we need to place it in a ScrollView or not + if ( totalMenuHeight + rOHeight ) > maxEntireHeight - safeArea { + /// We need to place our entire view in a ScrollView + messageMenuStyle = .scrollView(height: maxEntireHeight - safeArea) + } else if menuHeight > maxMenuHeight { + /// We need to place our menu buttons in a ScrollView + menuStyle = .scrollView(height: maxMenuHeight) + } + /// Update our view state variables + /// Hide all of our views in preperation for our transition to `.ready` + reactionSelectionIsVisible = false + reactionOverviewIsVisible = false + menuIsVisible = false + /// Calculate our vertical offset so our message lines up with the message from our TableView + verticalOffset = calcVertOffset(previousState: oldState) + /// Animate our rendered message into view + withAnimation(.easeInOut(duration: animationDuration * 1.33)) { + messageMenuOpacity = 1.0 + } + + case .ready: + withAnimation(.bouncy(duration: animationDuration)) { + reactionSelectionIsVisible = shouldShowReactionSelectionView + reactionOverviewIsVisible = shouldShowReactionOverviewView + menuIsVisible = true + verticalOffset = calcVertOffset(previousState: oldState) + } + + case .keyboard: + withAnimation(.bouncy(duration: animationDuration)) { + reactionSelectionIsVisible = true + reactionOverviewIsVisible = false + menuIsVisible = false + verticalOffset = calcVertOffset(previousState: oldState) + } + + case .dismiss: + withAnimation(.snappy(duration: animationDuration * 0.66)) { + reactionSelectionIsVisible = didReact ? true : false + reactionOverviewIsVisible = false + menuIsVisible = false + verticalOffset = calcVertOffset(previousState: oldState) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(animationDuration * 333))) { + withAnimation(.easeOut) { + messageMenuOpacity = 0 + backgroundOpacity = 0 + } + } + } + } + + private func calcVertOffset(previousState: ViewState) -> CGFloat { + switch viewState { + case .initial, .prepare: + return .greatestFiniteMagnitude + case .original: + return messageFrame.midY - (messageTopPadding / 2) + case .ready: + if case .keyboard = previousState { + if case .scrollView = messageMenuStyle { + /// Ensure we still need our scroll view + let contentHeight = calculateMessageMenuHeight(including: [.message, .reactionSelection, .menu]) + let safeArea = safeAreaInsets.top + safeAreaInsets.bottom + if contentHeight > maxEntireHeight - safeArea { + messageMenuStyle = .scrollView(height: maxEntireHeight - safeArea) + } else { + messageMenuStyle = .vStack + } + } + return lastVerticalOffset + } + /// If the messageMenuStyle is a scrollView then we place it in the middle of the screen + if case .scrollView(let height) = messageMenuStyle { return (height / 2) + safeAreaInsets.top } + + /// Otherwise, calculate our offsets and move to our target + let rHeight:CGFloat = reactionSelectionIsVisible ? calculateMessageMenuHeight(including: [.reactionSelection]) : 0 + let mHeight:CGFloat = menuIsVisible ? calculateMessageMenuHeight(including: [.menu]) : 0 + let rOHeight:CGFloat = reactionOverviewIsVisible ? reactionOverviewHeight : 0 + + var ty:CGFloat = messageFrame.midY - (messageTopPadding / 2) + + if (messageFrame.minY - rHeight) < safeAreaInsets.top + rOHeight { + let off = (safeAreaInsets.top + rOHeight) - (messageFrame.minY - rHeight) + /// We need to move the message down to make room for the views above it + ty += off + } else if messageFrame.maxY + mHeight > chatViewFrame.height - safeAreaInsets.bottom { + let off = messageFrame.maxY + mHeight + safeAreaInsets.bottom - chatViewFrame.height + /// We need to move the message up to make room for the menu buttons below it + ty -= off + } + + return ty + (mHeight / 2) - (rHeight / 2) + + case .keyboard: + /// Store our vertical offset + lastVerticalOffset = verticalOffset + + /// If the messageMenuStyle is a scrollView then we place it in the middle of the screen + if case .scrollView(let height) = messageMenuStyle { + /// Update our max height + messageMenuStyle = .scrollView(height: height - keyboardState.keyboardFrame.height + safeAreaInsets.bottom) + /// And our vertical offset + return verticalOffset - (keyboardState.keyboardFrame.height / 2) + (safeAreaInsets.bottom / 2) + } else { + /// Check to make sure that we don't need a scroll view now that we have less realestate + let contentHeight = calculateMessageMenuHeight(including: [.message, .reactionSelection]) + if contentHeight + safeAreaInsets.top > keyboardState.keyboardFrame.minY { + /// Our message is too large to fit in our available screen space + /// We *should* place the content in a ScrollView + /// But we don't due to needing to preserve the ReactionSelection's view state + /// Therefore we settle with clipping the bottom of the content behind the keyboard + /// Pin the view to the top of the screen + return safeAreaInsets.top + (contentHeight / 2) + } + } + + /// At this point our messageMenuFrame height includes the menu view so we need to subtract that + let mHeight:CGFloat = calculateMessageMenuHeight(including: [.menu]) + + /// Grab our current verticalOffset + var ty:CGFloat = verticalOffset + /// Keep the message stationary while we hide / remove the menu + ty -= mHeight / 2 + /// Provide a bit of padding to lift the view off of the keyboard + let bottomPadding = safeAreaInsets.bottom / 2 + if messageMenuFrame.maxY - mHeight + bottomPadding > keyboardState.keyboardFrame.minY { + let off = messageMenuFrame.maxY - mHeight + bottomPadding - keyboardState.keyboardFrame.minY + /// We need to move the message up so it doesn't get hidden by the keyboard + ty -= off + } + return ty + + case .dismiss: + if didReact { + return messageFrame.midY - ((calculateMessageMenuHeight(including: [.reactionSelection])) / 2) + } else { + return messageFrame.midY - (messageTopPadding / 2) + } + } + } + + enum MMViews { + case message + case menu + case reactionSelection + } + + /// Attempts to provide a single call site for gathering the height of our various views + private func calculateMessageMenuHeight(including views: [MMViews]) -> CGFloat { + var height:CGFloat = 0 + for view in Set(views) { + switch view { + case .message: + height += messageFrame.height + messageTopPadding + case .menu: + height += menuStyle.height(menuHeight) + verticalSpacing + if case .scrollView = menuStyle { height += 8 } + case .reactionSelection: + height += reactionSelectionHeight + verticalSpacing + reactionSelectionBottomPadding + } + } + return height + } + + @ViewBuilder + func messageMenuView() -> some View { + VStack(spacing: verticalSpacing) { + if reactionOverviewIsVisible, case .scrollView = messageMenuStyle { + ReactionOverview(viewModel: viewModel, message: message, backgroundColor: theme.colors.messageFriendBG) + .frame(maxWidth: chatViewFrame.width - safeAreaInsets.leading - safeAreaInsets.trailing) + .offset(x: -safeAreaInsets.leading) + .transition(defaultTransition) + .opacity(messageMenuOpacity) + } + + if reactionSelectionIsVisible { + ReactionSelectionView( + viewModel: viewModel, + backgroundColor: theme.colors.messageFriendBG, + selectedColor: theme.colors.messageMyBG, + animation: .bouncy(duration: animationDuration), + animationDuration: animationDuration, + currentReactions: message.reactions.filter({ $0.user.isCurrentUser }), + customReactions: reactions, + allowEmojiSearch: shouldAllowEmojiSearch, + alignment: alignment, + leadingPadding: leadingPadding, + trailingPadding: trailingPadding, + reactionClosure: handleOnReaction + ) + .maxHeightGetter($reactionSelectionHeight) + .padding(.bottom, reactionSelectionBottomPadding) + .transition(defaultTransition) + .zIndex(2) + } + + mainButton() + .frame(maxWidth: chatViewFrame.width - safeAreaInsets.leading - safeAreaInsets.trailing) + .offset(x: (alignment == .right) ? safeAreaInsets.trailing : -safeAreaInsets.leading) + .allowsHitTesting(false) + + if menuIsVisible { + menuView(isSender: message.user.isCurrentUser) + .transition(defaultTransition) + } + } + .overflowContainer(messageMenuStyle, viewState: viewState, onTap: { + if viewState == .keyboard { + keyboardState.resignFirstResponder() + transitionViewState(to: .ready) + } else { + dismissSelf() + } + }) + } + + private struct MenuButton:Identifiable { + let id:Int + let action:ActionEnum + + init(id:Int, action:ActionEnum) { + self.id = id + self.action = action + } + } + + @ViewBuilder + func menuView(isSender: Bool) -> some View { + let buttons = ActionEnum.allCases.enumerated().map { MenuButton(id: $0, action: $1) } + HStack { + if isSender { Spacer() } + + VStack { + ForEach(buttons) { button in + menuButton(title: button.action.title(), icon: button.action.icon(), action: button.action) + } + } + .menuContainer(menuStyle) + + if !isSender { Spacer() } + } + .padding(isSender ? .trailing : .leading, isSender ? trailingPadding : leadingPadding) + .padding(.top, 8) + .maxHeightGetter($menuHeight) + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func menuButton(title: String, icon: Image, action: ActionEnum) -> some View { + HStack(spacing: 0) { + ZStack { + theme.colors.messageFriendBG + .cornerRadius(12) + HStack { + Text(title) + .foregroundColor(theme.colors.menuText) + Spacer() + icon + .renderingMode(.template) + .foregroundStyle(theme.colors.menuText) + } + .font(getFont) + .padding(.vertical, 11) + .padding(.horizontal, 12) + } + .frame(width: 208) + .fixedSize() + .onTapGesture { + onAction(action) + dismissSelf() + } + + if alignment == .right { + /// This aligns the menu buttons with the trailing edge of the message instead of the status indicator + Color.clear.viewWidth(12) + } + } + } + + private func dismissSelf(_ rt: ReactionType? = nil) { + if keyboardState.isShown { keyboardState.resignFirstResponder() } + transitionViewState(to: .dismiss) + let delay = didReact ? Int(animationDuration * 1333) : Int(animationDuration * 1000) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) { + isShowingMenu = false + reactionHandler.didReact(rt) + dismiss() + } + } +} + +enum ViewState { + case initial + case prepare + case original + case ready + case keyboard + case dismiss +} + +enum MenuStyle { + case vStack + case scrollView(height: CGFloat) + + func height(_ maxMenuHeight: CGFloat) -> CGFloat { + switch self { + case .vStack: + return maxMenuHeight + case .scrollView(let height): + return height + } + } +} + +// - MARK: Debug Views +extension MessageMenu { + @ViewBuilder + func debugViews() -> some View { + // Original Message Frame + Rectangle() + .fill(.clear) + .border(.blue) + .frame(width: cellFrame.width, height: cellFrame.height) + .position(x: cellFrame.midX, y: cellFrame.midY) + // Current Message Frame + Rectangle() + .fill(.clear) + .border(.purple) + .frame(width: messageFrame.width, height: messageFrame.height) + .position(x: messageFrame.midX, y: messageFrame.midY) + // Target + Rectangle() + .fill(.orange) + .frame(width: messageMenuFrame.width, height: 2) + .position(x: messageMenuFrame.midX, y: messageMenuFrame.midY) + // Top Safe Area + Rectangle() + .fill(.red) + .opacity(0.3) + .frame(width: chatViewFrame.width, height: safeAreaInsets.top) + .position(x: chatViewFrame.midX, y: safeAreaInsets.top / 2) + // Bottom Safe Area + Rectangle() + .fill(.red) + .opacity(0.3) + .frame(width: chatViewFrame.width, height: safeAreaInsets.bottom) + .position(x: chatViewFrame.midX, y: chatViewFrame.height - (safeAreaInsets.bottom / 2)) + } +} + +struct MenuContainerModifier: ViewModifier { + @State var style: MenuStyle + var background: Color + + func body(content: Content) -> some View { + switch style { + case .vStack: + content + case .scrollView(let height): + ScrollView { + content + } + .scrollIndicators(.hidden) + .frame(height: height) + .background(background) + } + } +} + +struct ScrollContainerModifier: ViewModifier { + var style: MenuStyle + var viewState: ViewState + var background: Color + var onTap: () -> Void + + func body(content: Content) -> some View { + if (viewState != .initial || viewState != .prepare), case .scrollView(let height) = style { + ScrollView { + Color.clear.frame(height: 16) + content + Color.clear.frame(height: 16) + } + .clipped() + .frame(maxHeight: height) + .onTapGesture(perform: onTap) + } else { + content + } + } +} + +extension View { + func overflowContainer(_ style: MenuStyle, viewState: ViewState, background: Color = .clear, onTap: @escaping () -> Void) -> some View { + modifier(ScrollContainerModifier(style: style, viewState: viewState, background: background, onTap: onTap)) + } + + func menuContainer(_ style: MenuStyle, background: Color = .clear) -> some View { + modifier(MenuContainerModifier(style: style, background: background)) + } +} From 134b7650eb848705805b6d3fd5efdfefae1a7d3c Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:02:42 -0800 Subject: [PATCH 03/50] Added Reaction Model --- Sources/ExyteChat/Model/Reaction.swift | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Sources/ExyteChat/Model/Reaction.swift diff --git a/Sources/ExyteChat/Model/Reaction.swift b/Sources/ExyteChat/Model/Reaction.swift new file mode 100644 index 00000000..31a81b37 --- /dev/null +++ b/Sources/ExyteChat/Model/Reaction.swift @@ -0,0 +1,65 @@ +// +// Reaction.swift +// Chat +// + +import Foundation + +public enum ReactionType: Codable, Equatable, Hashable { + case emoji(String) + //case sticker + //case other... + + var toString:String { + switch self { + case .emoji(let emoji): + return emoji + } + } +} + +public struct Reaction: Codable, Identifiable, Hashable { + public let id: String + public let user: User + public let createdAt: Date + public let type: ReactionType + public var status: Status + + public init(id: String = UUID().uuidString, user: User, createdAt: Date = .now, type: ReactionType, status: Status = .sending) { + self.id = id + self.user = user + self.createdAt = createdAt + self.type = type + self.status = status + } + + var emoji: String? { + switch self.type { + case .emoji(let emoji): return emoji + } + } +} + +extension Reaction { + public enum Status: Codable, Equatable, Hashable { + case sending + case sent + case read + case error(DraftReaction) + } +} + +public struct DraftReaction: Codable, Identifiable, Hashable { + public let id: String + public let messageID: String + public let createdAt: Date + public let type: ReactionType + + public init(id: String = UUID().uuidString, messageID: String, createdAt: Date = .now, type: ReactionType) { + self.id = id + self.messageID = messageID + self.createdAt = createdAt + self.type = type + } +} + From 62af29cded1023c4cd11fed93417db230ca345ad Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:03:38 -0800 Subject: [PATCH 04/50] Extended Message to support Reactions --- Sources/ExyteChat/Model/Message.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/ExyteChat/Model/Message.swift b/Sources/ExyteChat/Model/Message.swift index 379b93ff..ec7c094a 100644 --- a/Sources/ExyteChat/Model/Message.swift +++ b/Sources/ExyteChat/Model/Message.swift @@ -51,6 +51,7 @@ public struct Message: Identifiable, Hashable { public var text: String public var attachments: [Attachment] + public var reactions: [Reaction] public var recording: Recording? public var replyMessage: ReplyMessage? @@ -62,6 +63,7 @@ public struct Message: Identifiable, Hashable { createdAt: Date = Date(), text: String = "", attachments: [Attachment] = [], + reactions: [Reaction] = [], recording: Recording? = nil, replyMessage: ReplyMessage? = nil) { @@ -71,6 +73,7 @@ public struct Message: Identifiable, Hashable { self.createdAt = createdAt self.text = text self.attachments = attachments + self.reactions = reactions self.recording = recording self.replyMessage = replyMessage } @@ -114,6 +117,7 @@ extension Message: Equatable { lhs.createdAt == rhs.createdAt && lhs.text == rhs.text && lhs.attachments == rhs.attachments && + lhs.reactions == rhs.reactions && lhs.recording == rhs.recording && lhs.replyMessage == rhs.replyMessage } From 66143837a4c26ab36db59c4dd76dc49459f26c84 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:05:23 -0800 Subject: [PATCH 05/50] Added reaction equality check (we should probably drop message.text and message.reactions from this check and use the triggerRedraw functionality correctly) --- Sources/ExyteChat/Model/MessageRow.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ExyteChat/Model/MessageRow.swift b/Sources/ExyteChat/Model/MessageRow.swift index 583d53e2..e047071d 100644 --- a/Sources/ExyteChat/Model/MessageRow.swift +++ b/Sources/ExyteChat/Model/MessageRow.swift @@ -66,6 +66,7 @@ struct MessageRow: Equatable { && lhs.message.status == rhs.message.status && lhs.message.triggerRedraw == rhs.message.triggerRedraw && lhs.message.text == rhs.message.text + && lhs.message.reactions == rhs.message.reactions } } From c9ec30d6ed53bf4a7dda4e28e6c56f5cd2875ef1 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:07:06 -0800 Subject: [PATCH 06/50] Added a Reaction Selection view (essentially just a hstack of emojis). --- .../MessageMenu+ReactionSelectionView.swift | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift new file mode 100644 index 00000000..958fc72e --- /dev/null +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift @@ -0,0 +1,430 @@ +// +// ReactionSelectionView.swift +// Chat +// + +import SwiftUI + +struct ReactionSelectionView: View { + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + static let MaxSelectionRowWidth:CGFloat = 400 + + @StateObject private var keyboardState = KeyboardState() + + @StateObject var viewModel: ChatViewModel + + @State private var selectedEmoji: String = "" + @FocusState private var emojiEntryIsFocused: Bool + + @State private var emojis:[String] = [] + + @State private var placeholder: String = "" + @State private var maxWidth: CGFloat = ReactionSelectionView.MaxSelectionRowWidth + @State private var maxSelectionRowWidth: CGFloat = ReactionSelectionView.MaxSelectionRowWidth + @State private var maxHeight: CGFloat? = nil + @State private var opacity: CGFloat = 1.0 + @State private var xOffset: CGFloat = 0.0 + @State private var yOffset: CGFloat = 0.0 + @State private var viewState: ViewState = .initial + + @State private var bubbleDiameter: CGFloat = .zero + + var backgroundColor: Color + var selectedColor: Color + var animation: Animation + var animationDuration: Double + var currentReactions: [Reaction] + var customReactions: [ReactionType]? + var allowEmojiSearch: Bool + var alignment: MessageMenuAlignment + var leadingPadding: CGFloat + var trailingPadding: CGFloat + var reactionClosure: ((ReactionType?) -> Void) + + private let horizontalPadding: CGFloat = 16 + private let verticalPadding: CGFloat = 10 + private let bubbleDiameterMultiplier: CGFloat = 1.5 + + var body: some View { + let currentEmojiReactions = currentReactions.compactMap(\.emoji) + HStack(spacing: 0) { + // Apply the leading padding + leadingPaddingView() + + // The main reaction selection view + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: horizontalPadding) { + // Add the latest / most relevant emojis + ForEach(emojis, id: \.self) { emoji in + Button(action: { + transitionToViewState(.picked(emoji)) + }) { + emojiView(emoji: emoji, isSelected: currentEmojiReactions.contains( emoji )) + } + } + + if allowEmojiSearch, viewState.needsSearchButton { + // Finish the list with a `button` to open the keyboard in it's emoji state + additionalEmojiPickerView() + .onChange(of: selectedEmoji) { _ in + transitionToViewState(.picked(selectedEmoji)) + } + .onChange(of: emojiEntryIsFocused) { _ in + if emojiEntryIsFocused { + transitionToViewState(.search) + } + } + } + } + .padding(.vertical, verticalPadding) + .padding(.horizontal, (emojiEntryIsFocused || viewState.isPicked) ? bubbleDiameter / 6 : horizontalPadding) + + } + .padding(.horizontal, 2) + .modifier(InteriorRadialShadow(color: viewState.needsInteriorShadow ? backgroundColor : .clear)) + .frame(minWidth: maxHeight, maxWidth: maxWidth, minHeight: maxHeight, maxHeight: maxHeight) + .background( + Capsule(style: .continuous) + .foregroundStyle(backgroundColor) + ) + .clipShape( + Capsule(style: .continuous) + ) + .opacity(opacity) + + if emojiEntryIsFocused { + // Provide a close button that cancels the emoji search + // (dismisses the keyboard) and returns to the .row ViewState + closeButton(color: backgroundColor) + .padding(.trailing, alignment == .left ? -24 : 0) + .transition(.scaleAndFade) + } + + // Apply the trailing padding + trailingPaddingView() + } + .offset(x: xOffset, y: yOffset) + .onAppear { transitionToViewState(.row) } + .onChange(of: keyboardState.isShown) { _ in + if !keyboardState.isShown && viewState == .search { + // Someone closed the keyboard while we were searching, return to `.row` + transitionToViewState(.row) + } + } + } + + @ViewBuilder + func emojiView(emoji:String, isSelected:Bool) -> some View { + if isSelected { + Text(emoji) + .font(.title3) + .background( + Circle() + .fill(selectedColor) + .shadow(radius: 1) + .padding(-verticalPadding + 4) + ) + } else { + Text(emoji) + .font(.title3) + } + } + + @ViewBuilder + func additionalEmojiPickerView() -> some View { + // Finish the list with a `button` to open the keyboard in it's emoji state + EmojiTextField(placeholder: placeholder, text: $selectedEmoji) + .tint(.clear) + .font(.title3) + .focused($emojiEntryIsFocused) + .textSelection(.disabled) + .background( + ZStack { + Image(systemName: "face.smiling") + .imageScale(.large) + .foregroundStyle(selectedEmoji.isEmpty ? Color.secondary.opacity(0.35) : Color.clear) + } + ) + .frame(width: bubbleDiameter, height: bubbleDiameter) + } + + @ViewBuilder + func closeButton(color: Color) -> some View { + Text("🅧") + .font(.title) + .foregroundStyle(.secondary) + .background( + ZStack { + Circle() + .stroke(style: .init(lineWidth: 1)) + .fill(color) + } + ) + .onTapGesture { + // We call the reaction closure here so our message menu can react accordingly + reactionClosure(nil) + // Transistion back to .row state + transitionToViewState(.row) + } + .offset(x: -(bubbleDiameter / 3), y: -(bubbleDiameter / 1.5)) + } + + @ViewBuilder + func leadingPaddingView() -> some View { + if alignment == .left { + Color.clear.viewWidth(max(1, leadingPadding - 8)) + Spacer() + } else { + let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - trailingPadding) + Color.clear.viewWidth(additionalPadding + trailingPadding * 3) + } + } + + @ViewBuilder + func trailingPaddingView() -> some View { + if alignment == .right { + Spacer() + Color.clear.viewWidth(trailingPadding) + } else { + let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - leadingPadding) + Color.clear.viewWidth(additionalPadding + trailingPadding * 3) + } + } + + private func calcMaxSelectionRowWidth() -> CGFloat { + var emojiCount = emojis.count + 1 + if allowEmojiSearch { emojiCount += 1 } + let maxWidth = min( + CGFloat(emojiCount) * (bubbleDiameter + horizontalPadding) + horizontalPadding * 3, + ReactionSelectionView.MaxSelectionRowWidth + ) + return maxWidth + } + + private func transitionToViewState(_ state:ViewState) { + guard state != viewState else { return } + let previousState = viewState + viewState = state + switch viewState { + case .initial: + self.transitionToViewState(.row) + return + case .row: + bubbleDiameter = dynamicTypeSize.bubbleDiameter() + emojiEntryIsFocused = false + withAnimation(animation) { + emojis = getEmojis() + maxSelectionRowWidth = calcMaxSelectionRowWidth() + maxWidth = maxSelectionRowWidth + maxHeight = nil + xOffset = CGFloat.leastNonzeroMagnitude + yOffset = CGFloat.leastNonzeroMagnitude + } + case .search: + withAnimation(animation) { + emojis = [] + maxWidth = bubbleDiameter * bubbleDiameterMultiplier + maxHeight = bubbleDiameter * bubbleDiameterMultiplier + xOffset = getXOffset() + yOffset = getYOffset() + } + case .picked(let emoji): + withAnimation(animation) { + emojis = [emoji] + maxWidth = bubbleDiameter * bubbleDiameterMultiplier + maxHeight = bubbleDiameter * bubbleDiameterMultiplier + xOffset = getXOffset() + yOffset = getYOffset() + } + + switch previousState { + case .row: + Task { + try await Task.sleep(for: .milliseconds(animationDuration * 1333)) + reactionClosure(.emoji(emoji)) + } + case .search: + emojiEntryIsFocused = false + Task { + try await Task.sleep(for: .milliseconds(animationDuration * 666)) + reactionClosure(.emoji(selectedEmoji)) + } + case .initial, .picked: + break + } + } + } + + private func getEmojis() -> [String] { + if let customReactions, !customReactions.isEmpty { return customReactions.map { $0.toString } } + return defaultEmojis() + } + + /// Constructs the default reaction list, containing any reactions the current user has already applied to this message + /// - Returns: A list of emojis that the ReactionSelectionView should display + /// - Note: We include the current senders past reactions so it's easier for the sender to remove / undo a reaction if the developer supports this. + private func defaultEmojis() -> [String] { + var standard = ["👍", "👎"] + let current = currentReactions.compactMap(\.emoji).filter { + !standard.contains($0) + } + standard.insert(contentsOf: current, at: 2) + var extra = [ "❤️", "🤣", "‼️", "❓", "🥳", "💪", "🔥", "💔", "😭"] + while !extra.isEmpty, standard.count < max(10, current.count + 2) { + if let new = extra.firstIndex(where: { !standard.contains($0) }) { + standard.append( extra.remove(at: new) ) + } else { + break + } + } + return Array(standard) + } + + /// Calculates the X axis offset of the ReactionSelectionView for the current ViewState + /// - Returns: The X axis offset for the ReactionSelectionView + /// - Note: If the messageFrame's width is equal to, or larger than, the Screens width then we skip the offset animation + /// - Note: This also prevents the offset animation from occuring when the user uses a custom message builder + private func getXOffset() -> CGFloat { + guard viewModel.messageFrame.width < UIScreen.main.bounds.width else { return .leastNonzeroMagnitude } + switch viewState { + case .initial, .row: + return .leastNonzeroMagnitude + case .search, .picked: + if alignment == .left { + let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - leadingPadding) + return -((UIScreen.main.bounds.width - (additionalPadding + trailingPadding * 3) - (bubbleDiameter * 0.8)) - viewModel.messageFrame.maxX) + } else { + let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - trailingPadding) + return viewModel.messageFrame.minX - ((additionalPadding + trailingPadding * 3) + (bubbleDiameter * 0.8)) + } + } + } + + /// Calculates the Y axis offset of the ReactionSelectionView for the current ViewState + /// - Returns: The Y axis offset for the ReactionSelectionView + /// - Note: If the messageFrame's width is equal to, or larger than, the Screens width then we skip the offset animation + /// - Note: This also prevents the offset animation from occuring when the user uses a custom message builder + private func getYOffset() -> CGFloat { + guard viewModel.messageFrame.width < UIScreen.main.bounds.width else { return .leastNonzeroMagnitude } + switch viewState { + case .initial, .row: + return .leastNonzeroMagnitude + case .search, .picked: + return bubbleDiameter / 1.5 + } + } +} + +extension ReactionSelectionView { + /// ReactionSelectionView View State + private enum ViewState:Equatable { + case initial + /// A horizontal list of default reactions to select from + /// Placement: above the messageFrame + case row + /// A placeholder emoji view that launches the emoji keyboard that allows the sender to select a custom emoji + /// Placement: At the top corner of the messageFrame or directly above it when using a custom messageBuilder + case search + /// A temporary emoji view that animates into it's final position before the message menu is dismissed + /// Placement: At the top corner of the messageFrame or directly above it when using a custom messageBuilder + case picked(String) + + var needsInteriorShadow:Bool { + switch self { + case .row: + return true + case .search, .picked, .initial: + return false + } + } + + var needsSearchButton:Bool { + switch self { + case .row, .search, .initial: + return true + case .picked: + return false + } + } + + var isPicked:Bool { + switch self { + case .picked: + return true + default: + return false + } + } + } +} + +internal struct InteriorRadialShadow: ViewModifier { + var color:Color + + func body(content: Content) -> some View { + content.overlay( + ZStack { + GeometryReader { proxy in + Capsule(style: .continuous) + .fill( + RadialGradient(gradient: Gradient(colors: [.clear, color]), center: .center, startRadius: proxy.size.width / 2 - 18, endRadius: proxy.size.width / 2 - 5) + ) + .overlay( + RadialGradient(gradient: Gradient(colors: [.clear, color]), center: .center, startRadius: proxy.size.width / 2 - 18, endRadius: proxy.size.width / 2 - 5) + .clipShape(Capsule(style: .continuous)) + ) + } + Capsule(style: .continuous) + .stroke(color, lineWidth: 3) + } + .allowsHitTesting(false) + ) + } +} + +#Preview { + VStack { + ReactionSelectionView( + viewModel: ChatViewModel(), + backgroundColor: .gray, + selectedColor: .blue, + animation: .linear(duration: 0.2), + animationDuration: 0.2, + currentReactions: [ + Reaction( + user: .init( + id: "123", + name: "Tim", + avatarURL: nil, + isCurrentUser: true + ), + type: .emoji("❤️") + ), + Reaction( + user: .init( + id: "123", + name: "Tim", + avatarURL: nil, + isCurrentUser: true + ), + type: .emoji("👍") + ) + ], + allowEmojiSearch: true, + alignment: .left, + leadingPadding: 20, + trailingPadding: 20 + ) { selectedEmoji in + print(selectedEmoji) + } + } + .frame(width: 400, height: 100) +} + +extension AnyTransition { + static var scaleAndFade: AnyTransition { + .scale.combined(with: .opacity) + } +} From c3e6859c1f34515083a5ef3111b1a9ca34885f6a Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:08:30 -0800 Subject: [PATCH 07/50] Added a ReactionOverview view that associates message reactions with user avatars. --- .../MessageMenu+ReactionOverview.swift | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift new file mode 100644 index 00000000..1d285468 --- /dev/null +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift @@ -0,0 +1,130 @@ +// +// MessageMenu+ReactionOverview.swift +// Chat +// + +import SwiftUI + +struct ReactionOverview: View { + + @Environment(\.safeAreaInsets) private var safeAreaInsets + + @StateObject var viewModel: ChatViewModel + + let message: Message + let backgroundColor: Color + let padding: CGFloat = 16 + + struct SortedReaction:Identifiable { + var id: String { reaction.toString } + let reaction: ReactionType + let users: [User] + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: padding) { + Spacer() + ForEach( sortReactions() ) { reaction in + reactionUserView(reaction: reaction) + .padding(padding / 2) + } + Spacer() + } + .frame(minWidth: UIScreen.main.bounds.width - (padding * 2)) + } + .scrollIndicators(.hidden) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(backgroundColor) + ) + .clipShape( + RoundedRectangle(cornerRadius: 20, style: .continuous) + ) + .padding(padding) + } + + @ViewBuilder + func reactionUserView(reaction:SortedReaction) -> some View { + VStack { + Text(reaction.reaction.toString) + .font(.title3) + .background( + emojiBackgroundView() + .opacity(0.5) + .padding(-10) + ) + .padding(.top, 8) + .padding(.bottom) + + HStack(spacing: -14) { + ForEach(reaction.users) { user in + AvatarView(url: user.avatarURL, avatarSize: 32) + .contentShape(Circle()) + .overlay( + Circle() + .stroke(style: .init(lineWidth: 1)) + .foregroundStyle(backgroundColor) + ) + } + } + } + } + + @ViewBuilder + func emojiBackgroundView() -> some View { + GeometryReader { proxy in + ZStack(alignment: .center) { + Circle() + .fill(Color(UIColor.systemBackground)) + Circle() + .fill(Color(UIColor.systemBackground)) + .frame(width: proxy.size.width / 4, height: proxy.size.width / 4) + .offset(y: proxy.size.height / 2) + } + } + .compositingGroup() + } + + private func sortReactions() -> [SortedReaction] { + let mostRecent = message.reactions.sorted { $0.createdAt < $1.createdAt } + let orderedEmojis = mostRecent.map(\.emoji) + return Set(message.reactions.compactMap(\.emoji)).sorted(by: { + orderedEmojis.firstIndex(of: $0)! < orderedEmojis.firstIndex(of: $1)! + }).map { emoji in + let users = mostRecent.filter { $0.emoji == emoji } + return SortedReaction( + reaction: .emoji(emoji), + users: users.map(\.user) + ) + } + } +} + +#Preview { + let john = User(id: "john", name: "John", avatarURL: nil, isCurrentUser: true) + let stan = User(id: "stan", name: "Stan", avatarURL: nil, isCurrentUser: false) + let sally = User(id: "sally", name: "Sally", avatarURL: nil, isCurrentUser: false) + + ReactionOverview( + viewModel: ChatViewModel(), + message: .init( + id: UUID().uuidString, + user: stan, + status: .read, + text: "An example message of great importance", + reactions: [ + Reaction(user: john, createdAt: Date.now.addingTimeInterval(-80), type: .emoji("🔥")), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-70), type: .emoji("🥳")), + Reaction(user: john, createdAt: Date.now.addingTimeInterval(-60), type: .emoji("🔌")), + Reaction(user: john, createdAt: Date.now.addingTimeInterval(-50), type: .emoji("🧠")), + Reaction(user: john, createdAt: Date.now.addingTimeInterval(-40), type: .emoji("🥳")), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-30), type: .emoji("🔌")), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-20), type: .emoji("🧠")), + Reaction(user: sally, createdAt: Date.now.addingTimeInterval(-10), type: .emoji("🧠")) + ] + ), + backgroundColor: .black //Color(UIColor.secondarySystemBackground) + ) +} From 9ee94f77fcebbefd108f0b140b904679c8f35a86 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:09:49 -0800 Subject: [PATCH 08/50] Extended MessageView with a MessageReactionView for rendering reactions on message. --- .../MessageView/MessageReactionView.swift | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift new file mode 100644 index 00000000..b93e355b --- /dev/null +++ b/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift @@ -0,0 +1,159 @@ +// +// MessageView+Reaction.swift +// Chat +// + +import SwiftUI + +extension MessageView { + + @ViewBuilder + func reactionsView(_ message: Message, maxReactions: Int = 5) -> some View { + let preparedReactions = prepareReactions(message: message, maxReactions: maxReactions) + let overflowBubbleText = "+\(message.reactions.count - maxReactions + 1)" + + HStack(spacing: -bubbleSize.width / 5) { + if !message.user.isCurrentUser { + overflowBubbleView( + leadingSpacer: true, + needsOverflowBubble: preparedReactions.needsOverflowBubble, + text: overflowBubbleText, + containsReactionFromCurrentUser: preparedReactions.overflowContainsCurrentUser + ) + } + + ForEach(Array(preparedReactions.reactions.enumerated()), id: \.element) { index, reaction in + ReactionBubble(reaction: reaction, font: Font(font)) + .transition(.scaleAndFade) + .zIndex(message.user.isCurrentUser ? Double(preparedReactions.reactions.count - index) : Double(index + 1)) + .sizeGetter($bubbleSize) + } + + if message.user.isCurrentUser { + overflowBubbleView( + leadingSpacer: false, + needsOverflowBubble: preparedReactions.needsOverflowBubble, + text: overflowBubbleText, + containsReactionFromCurrentUser: preparedReactions.overflowContainsCurrentUser + ) + } + } + .padding(.horizontal, -(bubbleSize.width / 2)) + .frame(width: messageSize.width) + .offset(x: 0, y: -(bubbleSize.height / 1.5)) + } + + @ViewBuilder + func overflowBubbleView(leadingSpacer:Bool, needsOverflowBubble:Bool, text:String, containsReactionFromCurrentUser:Bool) -> some View { + if leadingSpacer { Spacer() } + if needsOverflowBubble { + ReactionBubble( + reaction: .init( + user: .init( + id: "null", + name: "", + avatarURL: nil, + isCurrentUser: containsReactionFromCurrentUser + ), + type: .emoji(text), + status: .sent + ), + font: .footnote.weight(.light) + ) + .padding(message.user.isCurrentUser ? .trailing : .leading, -3) + } + if !leadingSpacer { Spacer() } + } + + struct PreparedReactions { + /// Sorted Reactions by most recent -> oldest (trimmed to maxReactions) + let reactions:[Reaction] + /// Indicates whether we need to add an overflow bubble (due to the number of Reactions exceeding maxReactions) + let needsOverflowBubble:Bool + /// Indicates whether the clipped reactions (oldest reactions beyond maxReaction) contain a reaction from the current user + /// - Note: This value is used to color the background of the overflow bubble + let overflowContainsCurrentUser:Bool + } + + /// Orders the reactions by most recent to oldest, reverses their layout based on alignment and determines if an overflow bubble is necessary + private func prepareReactions(message:Message, maxReactions:Int) -> PreparedReactions { + guard maxReactions > 1, !message.reactions.isEmpty else { + return .init(reactions: [], needsOverflowBubble: false, overflowContainsCurrentUser: false) + } + // If we have more reactions than maxReactions, then we'll need an overflow bubble + let needsOverflowBubble = message.reactions.count > maxReactions + // Sort all reactions by most recent -> oldest + var reactions = Array(message.reactions.sorted(by: { $0.createdAt > $1.createdAt })) + // Check if our current user has a reaction in the overflow reactions (used for coloring the overflow bubble) + var overflowContainsCurrentUser: Bool = false + if needsOverflowBubble { + overflowContainsCurrentUser = reactions[min(reactions.count, maxReactions)...].contains(where: { $0.user.isCurrentUser }) + } + // Trim the reactions array if necessary + if needsOverflowBubble { reactions = Array(reactions.prefix(maxReactions - 1)) } + + return .init( + reactions: message.user.isCurrentUser ? reactions : reactions.reversed(), + needsOverflowBubble: needsOverflowBubble, + overflowContainsCurrentUser: overflowContainsCurrentUser + ) + } +} + +struct ReactionBubble: View { + + @Environment(\.chatTheme) var theme + + let reaction: Reaction + let font: Font + + @State private var phase = 0.0 + + var fillColor: Color { + switch reaction.status { + case .sending, .sent, .read: + return reaction.user.isCurrentUser ? theme.colors.messageMyBG : theme.colors.messageFriendBG + case .error: + return .red + } + } + + var opacity: Double { + switch reaction.status { + case .sent, .read: + return 1.0 + case .sending, .error: + return 0.7 + } + } + + var body: some View { + Text(reaction.emoji ?? "?") + .font(font) + .opacity(opacity) + .foregroundStyle(reaction.user.isCurrentUser ? theme.colors.messageMyText : theme.colors.messageFriendText) + .padding(6) + .background( + ZStack { + Circle() + .fill(fillColor) + // If the reaction is in flight, animate the stroke + if reaction.status == .sending { + Circle() + .stroke(style: .init(lineWidth: 2, lineCap: .round, dash: [100, 50], dashPhase: phase)) + .fill(theme.colors.messageFriendBG) + .onAppear { + withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: false)) { + phase -= 150 + } + } + // Otherwise just stroke the circle normally + } else { + Circle() + .stroke(style: .init(lineWidth: 1)) + .fill(theme.colors.mainBG) + } + } + ) + } +} From 20ea97a967e8ebb78f542eff843e404c0fd72e24 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:10:42 -0800 Subject: [PATCH 09/50] Extended MessageView to support reactions --- .../ChatView/MessageView/MessageView.swift | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageView.swift index fb2b049d..40b6e17a 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageView.swift @@ -9,7 +9,7 @@ import SwiftUI struct MessageView: View { - @Environment(\.chatTheme) private var theme + @Environment(\.chatTheme) var theme @ObservedObject var viewModel: ChatViewModel @@ -21,11 +21,17 @@ struct MessageView: View { let messageUseMarkdown: Bool let isDisplayingMessageMenu: Bool let showMessageTimeView: Bool + var font: UIFont @State var avatarViewSize: CGSize = .zero @State var statusSize: CGSize = .zero @State var timeSize: CGSize = .zero - + @State var messageSize: CGSize = .zero + + // The size of our reaction bubbles are based on the users font size, + // Therefore we need to capture it's rendered size in order to place it correctly + @State var bubbleSize: CGSize = .zero + static let widthWithMedia: CGFloat = 204 static let horizontalNoAvatarPadding: CGFloat = 8 static let horizontalAvatarPadding: CGFloat = 8 @@ -35,8 +41,6 @@ struct MessageView: View { static let horizontalStatusPadding: CGFloat = 8 static let horizontalBubblePadding: CGFloat = 70 - var font: UIFont - enum DateArrangement { case hstack, vstack, overlay } @@ -69,14 +73,17 @@ struct MessageView: View { } var showAvatar: Bool { - positionInUserGroup == .single + isDisplayingMessageMenu + || positionInUserGroup == .single || (chatType == .conversation && positionInUserGroup == .last) || (chatType == .comments && positionInUserGroup == .first) } var topPadding: CGFloat { if chatType == .comments { return 0 } - return positionInUserGroup == .single || positionInUserGroup == .first ? 8 : 4 + var amount:CGFloat = positionInUserGroup == .single || positionInUserGroup == .first ? 8 : 4 + if !message.reactions.isEmpty { amount += (bubbleSize.height / 1.5) } + return amount } var bottomPadding: CGFloat { @@ -101,7 +108,16 @@ struct MessageView: View { .frame(width: 2) } } - bubbleView(message) + if isDisplayingMessageMenu || message.reactions.isEmpty { + bubbleView(message) + } else { + ZStack(alignment: .top) { + bubbleView(message) + reactionsView(message) + } + // Sometimes the ZStack renders with a height larger than what's necessary, so we constrain it here. + .frame(maxHeight: messageSize.height) + } } if message.user.isCurrentUser, let status = message.status { @@ -142,6 +158,10 @@ struct MessageView: View { } } .bubbleBackground(message, theme: theme) + .sizeGetter($messageSize) + .applyIf(isDisplayingMessageMenu) { + $0.frameGetter($viewModel.messageFrame) + } } @ViewBuilder From 5988d685694685954e23238beb5a9c278b038611 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:11:30 -0800 Subject: [PATCH 10/50] Added mock reactions to the preview. --- Sources/ExyteChat/ChatView/MessageView/MessageView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageView.swift index 40b6e17a..53cd47b3 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageView.swift @@ -333,6 +333,15 @@ struct MessageView_Preview: PreviewProvider { Attachment.randomImage(), Attachment.randomImage(), Attachment.randomImage(), + ], + reactions: [ + Reaction(user: john, createdAt: Date.now.addingTimeInterval(-70), type: .emoji("🔥"), status: .sent), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-60), type: .emoji("🥳"), status: .sent), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-50), type: .emoji("🤠"), status: .sent), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-40), type: .emoji("🧠"), status: .sent), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-30), type: .emoji("🥳"), status: .sent), + Reaction(user: stan, createdAt: Date.now.addingTimeInterval(-20), type: .emoji("🤯"), status: .sent), + Reaction(user: john, createdAt: Date.now.addingTimeInterval(-10), type: .emoji("🥰"), status: .sending) ] ) From 06493a211d2bc76a4e3c72f063e6cd40be1af8fd Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:32:59 -0800 Subject: [PATCH 11/50] Modified the subscribeKeyboardNotifications subscription to log the keyboards frame as well as whether it's shown or not. --- .../ExyteChat/GlobalState/KeyboardState.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Sources/ExyteChat/GlobalState/KeyboardState.swift b/Sources/ExyteChat/GlobalState/KeyboardState.swift index 33b84878..53d368f3 100644 --- a/Sources/ExyteChat/GlobalState/KeyboardState.swift +++ b/Sources/ExyteChat/GlobalState/KeyboardState.swift @@ -8,27 +8,37 @@ import UIKit public final class KeyboardState: ObservableObject { @Published private(set) public var isShown: Bool = false - + @Published private(set) public var keyboardFrame: CGRect = .zero + private var subscriptions = Set() init() { subscribeKeyboardNotifications() } + + /// Requests the dismissal of the current / active keyboard + public func resignFirstResponder() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } private extension KeyboardState { func subscribeKeyboardNotifications() { - Publishers.Merge( + let pub = Publishers.Merge( NotificationCenter.default .publisher(for: UIResponder.keyboardWillShowNotification) - .map { _ in true }, + .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue } + .map { $0.cgRectValue }, NotificationCenter.default .publisher(for: UIResponder.keyboardWillHideNotification) - .map { _ in false } + .map { _ in .zero } ) .receive(on: RunLoop.main) - .assign(to: \.isShown, on: self) - .store(in: &subscriptions) + + // Assign the CGRect to keyboardFrame and store the sub + pub.assign(to: \.keyboardFrame, on: self).store(in: &subscriptions) + // Map the CGRect into a Bool, assign it to isShown and store the sub + pub.map { $0 != .zero }.assign(to: \.isShown, on: self).store(in: &subscriptions) } } From 88ec302241a523f92c9193c8dc453aa3f6613d2a Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:38:19 -0800 Subject: [PATCH 12/50] Added a `viewWidth` method that's similar to `viewSize` but sets the height to 1 px (when `viewSize` was used with large leadingPadding or trailingPadding values the height would effect the views layout, this was noticeable in the rendering of leading vs trailing aligned Message Menu Buttons). --- Sources/ExyteChat/Extensions/View+.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/ExyteChat/Extensions/View+.swift b/Sources/ExyteChat/Extensions/View+.swift index ae872a9d..46af9dac 100644 --- a/Sources/ExyteChat/Extensions/View+.swift +++ b/Sources/ExyteChat/Extensions/View+.swift @@ -8,9 +8,15 @@ import SwiftUI extension View { + /// Results in a square of the specified size func viewSize(_ size: CGFloat) -> some View { self.frame(width: size, height: size) } + + /// Results in a rectangle with the width specified and a height of 1 pixel + func viewWidth(_ width: CGFloat) -> some View { + self.frame(width: width, height: 1) + } func circleBackground(_ color: Color) -> some View { self.background { From a8f1822d254d37f562ff89d2e3ab41922f29aeca Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:41:18 -0800 Subject: [PATCH 13/50] Added an EmojiTextField. This view behaves similar to the standard TextFeild but when focused launches the keybaord in Emoji mode. --- .../Extensions/TextField+Emoji.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Sources/ExyteChat/Extensions/TextField+Emoji.swift diff --git a/Sources/ExyteChat/Extensions/TextField+Emoji.swift b/Sources/ExyteChat/Extensions/TextField+Emoji.swift new file mode 100644 index 00000000..c42e723c --- /dev/null +++ b/Sources/ExyteChat/Extensions/TextField+Emoji.swift @@ -0,0 +1,70 @@ +// +// EmojiTextField.swift +// Chat +// +// https://stackoverflow.com/questions/66397828/emoji-keyboard-swiftui +// + +import SwiftUI + +class UIEmojiTextField: UITextField { + override func awakeFromNib() { + super.awakeFromNib() + } + + func setEmoji() { + _ = self.textInputMode + } + + override var textInputContextIdentifier: String? { + return "" + } + + override var textInputMode: UITextInputMode? { + for mode in UITextInputMode.activeInputModes { + if mode.primaryLanguage == "emoji" { + self.keyboardType = .default // do not remove this + return mode + } + } + return nil + } +} + +/// A TextField that opens the keyboard (when focused) with the emoji selector active +struct EmojiTextField: UIViewRepresentable { + /// A placeholder string to display when no emoji is selected + var placeholder: String = "" + /// A binding string that the selected emoji will be assigned to + @Binding var text: String + + func makeUIView(context: Context) -> UIEmojiTextField { + let emojiTextField = UIEmojiTextField() + emojiTextField.placeholder = placeholder + emojiTextField.text = text + emojiTextField.delegate = context.coordinator + return emojiTextField + } + + func updateUIView(_ uiView: UIEmojiTextField, context: Context) { + uiView.text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, UITextFieldDelegate { + var parent: EmojiTextField + + init(parent: EmojiTextField) { + self.parent = parent + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + DispatchQueue.main.async { [weak self] in + self?.parent.text = textField.text ?? "" + } + } + } +} From 1721cffe8d083377ca7dc8c14bd159e8b232d8bf Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:43:48 -0800 Subject: [PATCH 14/50] Added a ReactionDelegate protocol and an internal DefaultReactionConfiguration. The ReactionDelegate provides an interface for both responding to message reactions and configuring the message menu for reactions on a per message basis. --- .../Extensions/ReactionDelegate.swift | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 Sources/ExyteChat/Extensions/ReactionDelegate.swift diff --git a/Sources/ExyteChat/Extensions/ReactionDelegate.swift b/Sources/ExyteChat/Extensions/ReactionDelegate.swift new file mode 100644 index 00000000..3e00f126 --- /dev/null +++ b/Sources/ExyteChat/Extensions/ReactionDelegate.swift @@ -0,0 +1,113 @@ +// +// ReactionDelegate.swift +// Chat +// + +/// A delegate for responding to Message Reactions and optionally configuring the Reaction Menu +/// +/// ```swift +/// func didReact(to message: Message, reaction: DraftReaction) +/// +/// // Optional configuration methods +/// func shouldShowOverview(for message: Message) -> Bool +/// func canReact(to message: Message) -> Bool +/// func reactions(for message: Message) -> [ReactionType]? +/// func allowEmojiSearch(for message: Message) -> Bool +/// ``` +public protocol ReactionDelegate { + + /// Called when the sender reacts to a message + /// - Parameters: + /// - message: The `Message` they reacted to + /// - reaction: The `DraftReaction` that should be sent / applied to the `Message` + func didReact(to message: Message, reaction: DraftReaction) -> Void + + /// Determines whether or not the Sender can react to a given `Message` + /// - Parameter message: The `Message` the Sender is interacting with + /// - Returns: A Bool indicating whether or not the Sender should be able to react to the given `Message` + /// + /// - Note: Optional, defaults to `true` + /// - Note: Called when Chat is preparing to show the Message Menu + func canReact(to message:Message) -> Bool + + /// Allows for the configuration of the Reactions available to the Sender for a given `Message` + /// - Parameter message: The `Message` the Sender is interacting with + /// - Returns: A list of `ReactionTypes` available for the Sender to use + /// + /// - Note: Optional, defaults to a standard set of emojis + /// - Note: Called when Chat is preparing to show the Message Menu + func reactions(for message:Message) -> [ReactionType]? + + /// Whether or not the Sender should be able to search for an emoji using the Keyboard for a given `Message` + /// - Parameter message: The `Message` the Sender is interacting with + /// - Returns: Whether or not the Sender can search for a custom emoji. + /// + /// - Note: Optional, defaults to `true` + /// - Note: Called when Chat is preparing to show the Message Menu + func allowEmojiSearch(for message:Message) -> Bool + + /// Whether or not the Message Menu should include a reaction overview at the top of the screen + /// - Parameter message: The `Message` the Sender is interacting with + /// - Returns: Whether the overview is shown or not + /// + /// - Note: Optional, defaults to `true` when the message has one or more reactions. + /// - Note: Called when Chat is preparing to show the Message Menu + func shouldShowOverview(for message:Message) -> Bool +} + +public extension ReactionDelegate { + func canReact(to message: Message) -> Bool { true } + func reactions(for message: Message) -> [ReactionType]? { nil } + func allowEmojiSearch(for message: Message) -> Bool { true } + func shouldShowOverview(for message:Message) -> Bool { !message.reactions.isEmpty } +} + +/// We use this implementation of ReactionDelegate for when the user wants to use the callback modifier instead of providing us with a dedicated delegate +struct DefaultReactionConfiguration: ReactionDelegate { + // Non optional didReact handler + var didReact: (Message, DraftReaction) -> Void + + // Optional handlers for further configuration + var canReact: ((Message) -> Bool)? = nil + var reactions: ((Message) -> [ReactionType]?)? = nil + var allowEmojiSearch: ((Message) -> Bool)? = nil + var shouldShowOverview: ((Message) -> Bool)? = nil + + init( + didReact: @escaping (Message, DraftReaction) -> Void, + canReact: ((Message) -> Bool)? = nil, + reactions: ((Message) -> [ReactionType]?)? = nil, + allowEmojiSearch: ((Message) -> Bool)? = nil, + shouldShowOverview: ((Message) -> Bool)? = nil + ) { + self.didReact = didReact + self.canReact = canReact + self.reactions = reactions + self.allowEmojiSearch = allowEmojiSearch + self.shouldShowOverview = shouldShowOverview + } + + func didReact(to message: Message, reaction: DraftReaction) { + didReact(message, reaction) + } + + func shouldShowOverview(for message: Message) -> Bool { + if let shouldShowOverview { return shouldShowOverview(message) } + else { return !message.reactions.isEmpty } + } + + func canReact(to message: Message) -> Bool { + if let canReact { return canReact(message) } + else { return true } + } + + func reactions(for message: Message) -> [ReactionType]? { + if let reactions { return reactions(message) } + else { return nil } + } + + func allowEmojiSearch(for message: Message) -> Bool { + if let allowEmojiSearch { return allowEmojiSearch(message) } + else { return true } + } +} From d82a4870acd394fb4d56526a088da16849ecb33d Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:47:43 -0800 Subject: [PATCH 15/50] Added a maxHeightGetter View extension that behaves similarly to the existing frame and size getter, but captures the maximum height of a view instead of just it's current height (useful for laying out the message menu with dynamic system font sizes). --- .../ExyteChat/Extensions/FrameGetter.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Sources/ExyteChat/Extensions/FrameGetter.swift b/Sources/ExyteChat/Extensions/FrameGetter.swift index 241b04be..24ff71c8 100644 --- a/Sources/ExyteChat/Extensions/FrameGetter.swift +++ b/Sources/ExyteChat/Extensions/FrameGetter.swift @@ -44,6 +44,24 @@ struct SizeGetter: ViewModifier { } } +struct MaxHeightGetter: ViewModifier { + @Binding var height: CGFloat + + func body(content: Content) -> some View { + content + .background( + GeometryReader { proxy -> Color in + if proxy.size.height > self.height { + DispatchQueue.main.async { + self.height = proxy.size.height + } + } + return Color.clear + } + ) + } +} + extension View { func frameGetter(_ frame: Binding) -> some View { @@ -53,6 +71,10 @@ extension View { func sizeGetter(_ size: Binding) -> some View { modifier(SizeGetter(size: size)) } + + func maxHeightGetter(_ height: Binding) -> some View { + modifier(MaxHeightGetter(height: height)) + } } struct MessageMenuPreferenceKey: PreferenceKey { From 395ff901fc99e4f39d4173203fc62f78b52db2b1 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:51:10 -0800 Subject: [PATCH 16/50] These values are a rough estimate of a single Emoji rendered at the Font.Title3 size across the full range of dynamic text sizes (including accessiblity fonts). We use these values when constructing the ReactionSelection view. --- .../DynamicTypeSize+BubbleDiameter.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Sources/ExyteChat/Extensions/DynamicTypeSize+BubbleDiameter.swift diff --git a/Sources/ExyteChat/Extensions/DynamicTypeSize+BubbleDiameter.swift b/Sources/ExyteChat/Extensions/DynamicTypeSize+BubbleDiameter.swift new file mode 100644 index 00000000..1c727e1e --- /dev/null +++ b/Sources/ExyteChat/Extensions/DynamicTypeSize+BubbleDiameter.swift @@ -0,0 +1,41 @@ +// +// DynamicTypeSize+BubbleDiameter.swift +// Chat +// + +import SwiftUI + +extension DynamicTypeSize { + /// These values were extracted from a single emoji rendered at `Font.title3` + /// We use these values to help with view layouts and frames + func bubbleDiameter() -> CGFloat { + switch self { + case .xSmall: + return 22 + case .small: + return 23 + case .medium: + return 24 + case .large: + return 25 + case .xLarge: + return 26 + case .xxLarge: + return 27 + case .xxxLarge: + return 30 + case .accessibility1: + return 35 + case .accessibility2: + return 42 + case .accessibility3: + return 48 + case .accessibility4: + return 53 + case .accessibility5: + return 59 + @unknown default: + return 25 + } + } +} From 172d45c37273760b8391d624bdaa514f1eff3703 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:51:51 -0800 Subject: [PATCH 17/50] Moved MessageMenu into sub folder --- .../ChatView/MessageView/MessageMenu.swift | 132 ------------------ 1 file changed, 132 deletions(-) delete mode 100644 Sources/ExyteChat/ChatView/MessageView/MessageMenu.swift diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu.swift deleted file mode 100644 index 0f2a9d7a..00000000 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// MessageMenu.swift -// -// -// Created by Alisa Mylnikova on 20.03.2023. -// - -import SwiftUI -import FloatingButton -import enum FloatingButton.Alignment - -public protocol MessageMenuAction: Equatable, CaseIterable { - func title() -> String - func icon() -> Image -} - -public enum DefaultMessageMenuAction: MessageMenuAction { - - case copy - case reply - case edit(saveClosure: (String)->Void) - - public func title() -> String { - switch self { - case .copy: - "Copy" - case .reply: - "Reply" - case .edit: - "Edit" - } - } - - public func icon() -> Image { - switch self { - case .copy: - Image(systemName: "doc.on.doc") - case .reply: - Image(systemName: "arrowshape.turn.up.left") - case .edit: - Image(systemName: "bubble.and.pencil") - } - } - - public static func == (lhs: DefaultMessageMenuAction, rhs: DefaultMessageMenuAction) -> Bool { - switch (lhs, rhs) { - case (.copy, .copy), - (.reply, .reply), - (.edit(_), .edit(_)): - return true - default: - return false - } - } - - public static var allCases: [DefaultMessageMenuAction] = [ - .copy, .reply, .edit(saveClosure: {_ in}) - ] -} - -struct MessageMenu: View { - - @Environment(\.chatTheme) private var theme - - @Binding var isShowingMenu: Bool - @Binding var menuButtonsSize: CGSize - var alignment: Alignment - var leadingPadding: CGFloat - var trailingPadding: CGFloat - var font: UIFont? = nil - var onAction: (ActionEnum) -> () - var mainButton: () -> MainButton - - var getFont: Font? { - if let font { - return Font(font) - } else { - return nil - } - } - - var body: some View { - FloatingButton( - mainButtonView: mainButton().allowsHitTesting(false), - buttons: ActionEnum.allCases.map { - menuButton(title: $0.title(), icon: $0.icon(), action: $0) - }, - isOpen: $isShowingMenu - ) - .straight() - //.mainZStackAlignment(.top) - .initialOpacity(0) - .direction(.bottom) - .alignment(alignment) - .spacing(2) - .animation(.linear(duration: 0.2)) - .menuButtonsSize($menuButtonsSize) - } - - func menuButton(title: String, icon: Image, action: ActionEnum) -> some View { - HStack(spacing: 0) { - if alignment == .left { - Color.clear.viewSize(leadingPadding) - } - - ZStack { - theme.colors.messageFriendBG - .cornerRadius(12) - HStack { - Text(title) - .foregroundColor(theme.colors.menuText) - Spacer() - icon - .renderingMode(.template) - .foregroundStyle(theme.colors.menuText) - } - .font(getFont) - .padding(.vertical, 11) - .padding(.horizontal, 12) - } - .frame(width: 208) - .fixedSize() - .onTapGesture { - onAction(action) - } - - if alignment == .right { - Color.clear.viewSize(trailingPadding) - } - } - } -} From dda70bafd3a13c98822a521192148914a37d2e74 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:56:22 -0800 Subject: [PATCH 18/50] Added a `messageFrame` var for logging a more precise frame for our message when invoking the message menu. This value gets populated by a `frameGetter` call in `MessgeView` when `isDisplayingMessageMenu` is true. --- Sources/ExyteChat/ChatView/ChatViewModel.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/ExyteChat/ChatView/ChatViewModel.swift b/Sources/ExyteChat/ChatView/ChatViewModel.swift index 36f93f86..89110dc8 100644 --- a/Sources/ExyteChat/ChatView/ChatViewModel.swift +++ b/Sources/ExyteChat/ChatView/ChatViewModel.swift @@ -12,7 +12,15 @@ final class ChatViewModel: ObservableObject { @Published var fullscreenAttachmentPresented = false @Published var messageMenuRow: MessageRow? - + + /// The messages frame that is currently being rendered in the Message Menu + /// - Note: Used to further refine a messages frame (instead of using the cell boundary), mainly used for positioning reactions + @Published var messageFrame: CGRect = .zero + + /// Provides a mechanism to issue haptic feedback to the user + /// - Note: Used when launching the MessageMenu + let impactGenerator = UIImpactFeedbackGenerator(style: .heavy) + let inputFieldId = UUID() var didSendMessage: (DraftMessage) -> Void = {_ in} From 36ae3af0a56cae654f1b19fca239aba220f39dae Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:58:05 -0800 Subject: [PATCH 19/50] The new MessageMenu results in not needing swiftui-introspection and FloatingButton packages. --- Package.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Package.swift b/Package.swift index 8c0dfb03..bf0c20a7 100644 --- a/Package.swift +++ b/Package.swift @@ -15,18 +15,10 @@ let package = Package( targets: ["ExyteChat"]), ], dependencies: [ - .package( - url: "https://github.com/siteline/swiftui-introspect", - from: "1.0.0" - ), .package( url: "https://github.com/exyte/MediaPicker.git", from: "2.0.0" ), - .package( - url: "https://github.com/exyte/FloatingButton", - from: "1.2.2" - ), .package( url: "https://github.com/exyte/ActivityIndicatorView", from: "1.0.0" @@ -36,9 +28,7 @@ let package = Package( .target( name: "ExyteChat", dependencies: [ - .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), .product(name: "ExyteMediaPicker", package: "MediaPicker"), - .product(name: "FloatingButton", package: "FloatingButton"), .product(name: "ActivityIndicatorView", package: "ActivityIndicatorView") ] ), From 2ade186d6f82bde6ff8decbae7bcfe111e721e6b Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:02:23 -0800 Subject: [PATCH 20/50] Using the new ImpactGenerator to provide haptic feedback to the user when launching the message menu. --- Sources/ExyteChat/ChatView/UIList.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/ExyteChat/ChatView/UIList.swift b/Sources/ExyteChat/ChatView/UIList.swift index fe3003bd..87ffa654 100644 --- a/Sources/ExyteChat/ChatView/UIList.swift +++ b/Sources/ExyteChat/ChatView/UIList.swift @@ -515,6 +515,9 @@ struct UIList: UIViewRepresentable { .onTapGesture { } .applyIf(showMessageMenuOnLongPress) { $0.onLongPressGesture { + // Trigger haptic feedback + self.viewModel.impactGenerator.impactOccurred() + // Launch the message menu self.viewModel.messageMenuRow = row } } From 5d39876b2b4243f4c2feeb699ba28eabd23c9fae Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:03:46 -0800 Subject: [PATCH 21/50] Added optional reactionDelegate param to the partial template initializers. --- .../PartialTemplateSpecifications.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/ExyteChat/ChatView/PartialTemplateSpecifications.swift b/Sources/ExyteChat/ChatView/PartialTemplateSpecifications.swift index 7ba91ab4..aece014f 100644 --- a/Sources/ExyteChat/ChatView/PartialTemplateSpecifications.swift +++ b/Sources/ExyteChat/ChatView/PartialTemplateSpecifications.swift @@ -1,6 +1,6 @@ // // SwiftUIView.swift -// +// // // Created by Alisa Mylnikova on 06.12.2023. // @@ -13,10 +13,12 @@ public extension ChatView where MessageContent == EmptyView { chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil, inputViewBuilder: @escaping InputViewBuilderClosure, messageMenuAction: MessageMenuActionClosure?) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.inputViewBuilder = inputViewBuilder @@ -30,10 +32,12 @@ public extension ChatView where InputViewContent == EmptyView { chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil, messageBuilder: @escaping MessageBuilderClosure, messageMenuAction: MessageMenuActionClosure?) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.messageBuilder = messageBuilder @@ -47,10 +51,12 @@ public extension ChatView where MenuAction == DefaultMessageMenuAction { chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil, messageBuilder: @escaping MessageBuilderClosure, inputViewBuilder: @escaping InputViewBuilderClosure) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.messageBuilder = messageBuilder @@ -64,9 +70,11 @@ public extension ChatView where MessageContent == EmptyView, InputViewContent == chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil, messageMenuAction: MessageMenuActionClosure?) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.messageMenuAction = messageMenuAction @@ -79,9 +87,11 @@ public extension ChatView where InputViewContent == EmptyView, MenuAction == Def chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil, messageBuilder: @escaping MessageBuilderClosure) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.messageBuilder = messageBuilder @@ -94,9 +104,11 @@ public extension ChatView where MessageContent == EmptyView, MenuAction == Defau chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil, inputViewBuilder: @escaping InputViewBuilderClosure) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.inputViewBuilder = inputViewBuilder @@ -108,9 +120,11 @@ public extension ChatView where MessageContent == EmptyView, InputViewContent == init(messages: [Message], chatType: ChatType = .conversation, replyMode: ReplyMode = .quote, - didSendMessage: @escaping (DraftMessage) -> Void) { + didSendMessage: @escaping (DraftMessage) -> Void, + reactionDelegate: ReactionDelegate? = nil) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } } From 760d37a48978d32632e3011c1c91c8d2ce00473e Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:05:06 -0800 Subject: [PATCH 22/50] Removed unused imports --- Sources/ExyteChat/ChatView/ChatView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index 3f2049c0..9bd7de33 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -6,8 +6,6 @@ // import SwiftUI -import FloatingButton -import SwiftUIIntrospect import ExyteMediaPicker public typealias MediaPickerParameters = SelectionParamsHolder From a02552c3b5d6322e9500636517b8d0c6c55ada7e Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:06:47 -0800 Subject: [PATCH 23/50] Removed unused @State vars associated with the old MessageMenu implementation. --- Sources/ExyteChat/ChatView/ChatView.swift | 90 ++--------------------- 1 file changed, 8 insertions(+), 82 deletions(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index 9bd7de33..f76ad9c7 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -130,16 +130,10 @@ public struct ChatView () = {} @State private var isShowingMenu = false - @State private var needsScrollView = false - @State private var readyToShowScrollView = false - @State private var menuButtonsSize: CGSize = .zero + @State private var tableContentHeight: CGFloat = 0 @State private var inputViewSize = CGSize.zero @State private var cellFrames = [String: CGRect]() - @State private var menuCellPosition: CGPoint = .zero - @State private var menuBgOpacity: CGFloat = 0 - @State private var menuCellOpacity: CGFloat = 0 - @State private var menuScrollView: UIScrollView? public init(messages: [Message], chatType: ChatType = .conversation, @@ -292,37 +286,8 @@ public struct ChatView UIScreen.main.bounds.height - safeAreaInsets.top - safeAreaInsets.bottom - - menuCellPosition = CGPoint(x: cellFrame.midX, y: cellFrame.minY + wholeMenuHeight/2 - safeAreaInsets.top) - menuCellOpacity = 1 - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - var finalCellPosition = menuCellPosition - if needsScrollTemp || - cellFrame.minY + wholeMenuHeight + safeAreaInsets.bottom > UIScreen.main.bounds.height { - - finalCellPosition = CGPoint(x: cellFrame.midX, y: UIScreen.main.bounds.height - wholeMenuHeight/2 - safeAreaInsets.top - safeAreaInsets.bottom - ) - } - - withAnimation(.linear(duration: 0.1)) { - menuBgOpacity = 0.9 - menuCellPosition = finalCellPosition - isShowingMenu = true - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - needsScrollView = needsScrollTemp - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - readyToShowScrollView = true - if let menuScrollView = menuScrollView { - menuScrollView.contentOffset = CGPoint(x: 0, y: menuScrollView.contentSize.height - menuScrollView.frame.height + safeAreaInsets.bottom) - } - } - } + func showMessageMenu() { + isShowingMenu = true } func hideMessageMenu() { - menuScrollView = nil - withAnimation(.linear(duration: 0.1)) { - menuCellOpacity = 0 - menuBgOpacity = 0 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - viewModel.messageMenuRow = nil - isShowingMenu = false - needsScrollView = false - readyToShowScrollView = false - } + viewModel.messageMenuRow = nil + viewModel.messageFrame = .zero + isShowingMenu = false } private static func createLocalization() -> ChatLocalization { From db9e07b73ae086dcaa16beb09a5a5ad54ae491db Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 09:08:44 -0800 Subject: [PATCH 24/50] Added reactionDelegate to the ChatView and it's initializer. Included the new MessageMenu. --- Sources/ExyteChat/ChatView/ChatView.swift | 46 +++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index f76ad9c7..651c27b4 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -77,6 +77,7 @@ public struct ChatView Void + var reactionDelegate: ReactionDelegate? // MARK: - View builders @@ -109,6 +110,7 @@ public struct ChatView () = {} + /// Used to prevent the MainView from responding to keyboard changes while the Menu is active @State private var isShowingMenu = false @State private var tableContentHeight: CGFloat = 0 @@ -139,16 +142,20 @@ public struct ChatView Void, + reactionDelegate: ReactionDelegate? = nil, messageBuilder: @escaping MessageBuilderClosure, inputViewBuilder: @escaping InputViewBuilderClosure, - messageMenuAction: MessageMenuActionClosure?) { + messageMenuAction: MessageMenuActionClosure?, + localization: ChatLocalization) { self.type = chatType self.didSendMessage = didSendMessage + self.reactionDelegate = reactionDelegate self.sections = ChatView.mapMessages(messages, chatType: chatType, replyMode: replyMode) self.ids = messages.map { $0.id } self.messageBuilder = messageBuilder self.inputViewBuilder = inputViewBuilder self.messageMenuAction = messageMenuAction + self.localization = localization } public var body: some View { @@ -207,6 +214,8 @@ public struct ChatView some View { - MessageMenu( + let cellFrame = cellFrames[row.id] ?? .zero + + return MessageMenu( + viewModel: viewModel, isShowingMenu: $isShowingMenu, - menuButtonsSize: $menuButtonsSize, + message: row.message, + cellFrame: cellFrame, alignment: row.message.user.isCurrentUser ? .right : .left, leadingPadding: avatarSize + MessageView.horizontalAvatarPadding * 2, trailingPadding: MessageView.statusViewSize + MessageView.horizontalStatusPadding, font: messageFont, - onAction: menuActionClosure(row.message)) { + animationDuration: messageMenuAnimationDuration, + onAction: menuActionClosure(row.message), + reactionHandler: MessageMenu.ReactionConfig( + delegate: reactionDelegate, + didReact: reactionClosure(row.message) + )) { ChatMessageView(viewModel: viewModel, messageBuilder: messageBuilder, row: row, chatType: type, avatarSize: avatarSize, tapAvatarClosure: nil, messageUseMarkdown: messageUseMarkdown, isDisplayingMessageMenu: true, showMessageTimeView: showMessageTimeView, messageFont: messageFont) .onTapGesture { hideMessageMenu() } } - .frame(height: menuButtonsSize.height + (cellFrames[row.id]?.height ?? 0), alignment: .top) - .opacity(menuCellOpacity) + } + + /// Constructs our default reactionCallback flow if the user supports Reactions by implementing the didReactToMessage closure + private func reactionClosure(_ message: Message) -> (ReactionType?) -> () { + return { reactionType in + Task { + // Run the callback on the main thread + await MainActor.run { + // Hide the menu + hideMessageMenu() + // Send the draft reaction + guard let reactionDelegate, let reactionType else { return } + reactionDelegate.didReact(to: message, reaction: DraftReaction(messageID: message.id, type: reactionType)) + } + } + } } func menuActionClosure(_ message: Message) -> (MenuAction) -> () { @@ -392,7 +425,6 @@ public struct ChatView Date: Sun, 16 Feb 2025 09:10:45 -0800 Subject: [PATCH 25/50] Added ChatView modifiers for setting a ReactionDelegate or configuring the default delegate on per closure basis. Also added a modifier for adjusting the MessageMenu's animation duration. --- Sources/ExyteChat/ChatView/ChatView.swift | 38 ++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index 651c27b4..3101d431 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -562,5 +562,41 @@ public extension ChatView { view.recorderSettings = settings return view } - + + /// Sets the general duration of various message menu animations + /// + /// This value is more akin to 'how snappy' the message menu feels + /// - Note: Good values are between 0.15 - 0.5 (defaults to 0.3) + /// - Important: This value is clamped between 0.1 and 1.0 + func messageMenuAnimationDuration(_ duration:Double) -> ChatView { + var view = self + view.messageMenuAnimationDuration = max(0.1, min(1.0, duration)) + return view + } + + /// Sets a ReactionDelegate on the ChatView for handling and configuring message reactions + func messageReactionDelegate(_ configuration: ReactionDelegate) -> ChatView { + var view = self + view.reactionDelegate = configuration + return view + } + + /// Constructs, and applies, a ReactionDelegate for you based on the provided closures + func onMessageReaction( + didReactTo: @escaping (Message, DraftReaction) -> Void, + canReactTo: ((Message) -> Bool)? = nil, + availableReactionsFor: ((Message) -> [ReactionType]?)? = nil, + allowEmojiSearchFor: ((Message) -> Bool)? = nil, + shouldShowOverviewFor: ((Message) -> Bool)? = nil + ) -> ChatView { + var view = self + view.reactionDelegate = DefaultReactionConfiguration( + didReact: didReactTo, + canReact: canReactTo, + reactions: availableReactionsFor, + allowEmojiSearch: allowEmojiSearchFor, + shouldShowOverview: shouldShowOverviewFor + ) + return view + } } From a4a01b02652bb377715098b11f8356e2c4a202c7 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:00:31 -0800 Subject: [PATCH 26/50] Added a remove(messageID:) method for deleting message from our data source and an add(draftReaction:, to:) method for simulating reacting to messages. --- .../ChatExample/ChatInteractor/ChatInteractorProtocol.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Example/ChatExample/ChatInteractor/ChatInteractorProtocol.swift b/Example/ChatExample/ChatInteractor/ChatInteractorProtocol.swift index 3e40eb60..52619e6c 100644 --- a/Example/ChatExample/ChatInteractor/ChatInteractorProtocol.swift +++ b/Example/ChatExample/ChatInteractor/ChatInteractorProtocol.swift @@ -12,6 +12,9 @@ protocol ChatInteractorProtocol { var otherSenders: [MockUser] { get } func send(draftMessage: ExyteChat.DraftMessage) + func remove(messageID: String) + + func add(draftReaction: DraftReaction, to messageID: String) func connect() func disconnect() From 0445b527ad3aa978d7b58bc22135b9d25f3ac9fc Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:02:24 -0800 Subject: [PATCH 27/50] Added the remove and add methods to conform to the updated ChatInteractorProtocol. Also added a method for updating a reactions status. --- .../ChatInteractor/MockChatInteractor.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/Example/ChatExample/ChatInteractor/MockChatInteractor.swift b/Example/ChatExample/ChatInteractor/MockChatInteractor.swift index e30b68e0..2e17d0dd 100644 --- a/Example/ChatExample/ChatInteractor/MockChatInteractor.swift +++ b/Example/ChatExample/ChatInteractor/MockChatInteractor.swift @@ -58,6 +58,82 @@ final class MockChatInteractor: ChatInteractorProtocol { } } } + + func remove(messageID: String) { + DispatchQueue.main.async { [weak self] in + self?.chatState.value.removeAll(where: { $0.uid == messageID }) + } + } + + /// Adds a reaction to an existing message + func add(draftReaction: ExyteChat.DraftReaction, to messageID: String) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let matchIndex = self.chatState.value.firstIndex(where: { $0.uid == messageID }) { + let originalMessage = self.chatState.value[matchIndex] + let reaction = Reaction(user: self.chatData.tim.toChatUser(), type: draftReaction.type) + let newMessage = MockMessage( + uid: originalMessage.uid, + sender: originalMessage.sender, + createdAt: originalMessage.createdAt, + status: originalMessage.status, + text: originalMessage.text, + images: originalMessage.images, + videos: originalMessage.videos, + reactions: originalMessage.reactions + [reaction], + recording: originalMessage.recording, + replyMessage: originalMessage.replyMessage + ) + print("Setting Reaction") + self.chatState.value[matchIndex] = newMessage + + // Update our message reaction status after a random delay... + delayUpdateReactionStatus(messageID: messageID, reactionID: reaction.id) + + } else { + print("No Match for Reaction") + } + } + } + + /// Updates a reaction's status after a random amount of time + func delayUpdateReactionStatus(messageID: String, reactionID: String) { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(.random(in: 500...2500))) { [weak self] in + guard let self else { return } + if let matchIndex = self.chatState.value.firstIndex(where: { $0.uid == messageID }) { + let originalMessage = self.chatState.value[matchIndex] + if let reactionIndex = originalMessage.reactions.firstIndex(where: { $0.id == reactionID }) { + let originalReaction = originalMessage.reactions[reactionIndex] + + var reactions = originalMessage.reactions + var status:Reaction.Status = .sent + if Int.random(min: 0, max: 2) == 0 { + status = .error(.init(id: originalReaction.id, messageID: originalMessage.uid, createdAt: originalReaction.createdAt, type: originalReaction.type)) + } + reactions[reactionIndex] = .init(id: originalReaction.id, user: originalReaction.user, createdAt: originalReaction.createdAt, type: originalReaction.type, status: status) + + let newMessage = MockMessage( + uid: originalMessage.uid, + sender: originalMessage.sender, + createdAt: originalMessage.createdAt, + status: originalMessage.status, + text: originalMessage.text, + images: originalMessage.images, + videos: originalMessage.videos, + reactions: reactions, + recording: originalMessage.recording, + replyMessage: originalMessage.replyMessage + ) + + self.chatState.value[matchIndex] = newMessage + } else { + print("No Match for Reaction") + } + } else { + print("No Match for Message") + } + } + } func connect() { Timer.publish(every: 2, on: .main, in: .default) From ec63d3639b90da56f7091b8b96cb0b79b5a86246 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:03:17 -0800 Subject: [PATCH 28/50] Modified the generateNewMessage and generateStartMessages to include / support message reactions. --- .../ChatInteractor/MockChatInteractor.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Example/ChatExample/ChatInteractor/MockChatInteractor.swift b/Example/ChatExample/ChatInteractor/MockChatInteractor.swift index 2e17d0dd..a6804682 100644 --- a/Example/ChatExample/ChatInteractor/MockChatInteractor.swift +++ b/Example/ChatExample/ChatInteractor/MockChatInteractor.swift @@ -176,7 +176,12 @@ private extension MockChatInteractor { } return (0...10) .map { index in - chatData.randomMessage(senders: senders, date: lastDate.randomTime()) + // Generate a random message + var msg = chatData.randomMessage(senders: senders, date: lastDate.randomTime()) + // 20% of the time, generate a random reaction to the message + if Int.random(in: 0...4) == 0 { msg = chatData.reactToMessage(msg, senders: senders) } + // Return the message + return msg } .sorted { lhs, rhs in lhs.createdAt < rhs.createdAt @@ -184,8 +189,17 @@ private extension MockChatInteractor { } func generateNewMessage() { - let message = chatData.randomMessage(senders: otherSenders) - chatState.value.append(message) + let idx = Int.random(min: 1, max: 10) + // 30% of the time, lets react to a previous and recent message + if idx <= 3, chatState.value.count >= idx { + let msgIndex = chatState.value.count - idx + let message = chatData.reactToMessage(chatState.value[msgIndex], senders: otherSenders) + chatState.value[msgIndex] = message + } else { + // 70% of the time just create a new random message + let message = chatData.randomMessage(senders: otherSenders) + chatState.value.append(message) + } } func updateSendingStatuses() { From 62f693bde0268073107aa03a4f48aed9f138f163 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:04:07 -0800 Subject: [PATCH 29/50] Added reactions to the MockMessage --- Example/ChatExample/Model/MockMessage.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Example/ChatExample/Model/MockMessage.swift b/Example/ChatExample/Model/MockMessage.swift index d6fb8d6b..d21eb15a 100644 --- a/Example/ChatExample/Model/MockMessage.swift +++ b/Example/ChatExample/Model/MockMessage.swift @@ -14,6 +14,7 @@ struct MockMessage { let text: String let images: [MockImage] let videos: [MockVideo] + let reactions: [Reaction] let recording: Recording? let replyMessage: ReplyMessage? } @@ -27,6 +28,7 @@ extension MockMessage { createdAt: createdAt, text: text, attachments: images.map { $0.toChatAttachment() } + videos.map { $0.toChatAttachment() }, + reactions: reactions, recording: recording, replyMessage: replyMessage ) From 025a200a35fe70d8b8d03005df0cbe5fec48fd84 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:06:18 -0800 Subject: [PATCH 30/50] Added reaction support to MockChatData, a function for generating a random reaction and a function for appending a reaction to an existing MockMessage. --- .../DataGeneration/MockChatData.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Example/ChatExample/DataGeneration/MockChatData.swift b/Example/ChatExample/DataGeneration/MockChatData.swift index 93054db7..cba84504 100644 --- a/Example/ChatExample/DataGeneration/MockChatData.swift +++ b/Example/ChatExample/DataGeneration/MockChatData.swift @@ -43,6 +43,7 @@ final class MockChatData { text: shouldGenerateText ? Lorem.sentence(nbWords: Int.random(in: 3...10), useMarkdown: true) : "", images: images, videos: [], + reactions: [], recording: nil, replyMessage: nil ) @@ -73,6 +74,31 @@ final class MockChatData { .map { _ in randomHexChar() } .joined() } + + func randomReaction(senders: [MockUser]) -> Reaction { + let sampleEmojis:[String] = ["👍", "👎", "❤️", "🤣", "‼️", "❓", "🥳", "💪", "🔥", "💔", "😭"] + return Reaction( + user: senders.random()!.toChatUser(), + createdAt: Date.now, + type: .emoji(sampleEmojis.random()!), + status: .sent + ) + } + + func reactToMessage(_ msg: MockMessage, senders: [MockUser]) -> MockMessage { + return MockMessage( + uid: msg.uid, + sender: msg.sender, + createdAt: msg.createdAt, + status: msg.status, + text: msg.text, + images: msg.images, + videos: msg.videos, + reactions: msg.reactions + [randomReaction(senders: senders)], + recording: msg.recording, + replyMessage: msg.replyMessage + ) + } } private extension MockChatData { @@ -142,6 +168,7 @@ extension DraftMessage { text: text, images: await makeMockImages(), videos: await makeMockVideos(), + reactions: [], recording: recording, replyMessage: replyMessage ) From d77a5d399ca0a723d193c7f78ea1870c2bd39384 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:09:02 -0800 Subject: [PATCH 31/50] Made our ChatExampleViewModel conform to the ReactionDelegate protocol for reaction support in our ChatExampleView. Also added a remove function so we can actually remove messages when a user uses the MessageMenu to delete a message. --- .../ChatExample/Screens/ChatExampleViewModel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Example/ChatExample/Screens/ChatExampleViewModel.swift b/Example/ChatExample/Screens/ChatExampleViewModel.swift index 69b89772..b765e649 100644 --- a/Example/ChatExample/Screens/ChatExampleViewModel.swift +++ b/Example/ChatExample/Screens/ChatExampleViewModel.swift @@ -6,7 +6,8 @@ import Foundation import Combine import ExyteChat -final class ChatExampleViewModel: ObservableObject { +final class ChatExampleViewModel: ObservableObject, ReactionDelegate { + @Published var messages: [Message] = [] var chatTitle: String { @@ -30,6 +31,14 @@ final class ChatExampleViewModel: ObservableObject { interactor.send(draftMessage: draft) } + func remove(messageID: String) { + interactor.remove(messageID: messageID) + } + + func didReact(to message: Message, reaction draftReaction: DraftReaction) { + interactor.add(draftReaction: draftReaction, to: draftReaction.messageID) + } + func onStart() { interactor.messages .compactMap { messages in From 3459793cdd2ac7dd736ddfba21babc98dd45be95 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:10:53 -0800 Subject: [PATCH 32/50] Added our viewModel as a message reaction delegate using the new API. --- Example/ChatExample/Screens/ChatExampleView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/ChatExample/Screens/ChatExampleView.swift b/Example/ChatExample/Screens/ChatExampleView.swift index a8122ce8..8d1d7f68 100644 --- a/Example/ChatExample/Screens/ChatExampleView.swift +++ b/Example/ChatExample/Screens/ChatExampleView.swift @@ -29,6 +29,7 @@ struct ChatExampleView: View { } .messageUseMarkdown(true) .setRecorderSettings(recorderSettings) + .messageReactionDelegate(viewModel) .navigationBarBackButtonHidden() .navigationBarTitleDisplayMode(.inline) .toolbar { From 8940b6f812939e8ee43cbd1d9678c6bf178bf8fa Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:11:50 -0800 Subject: [PATCH 33/50] Instead of just printing a delete message, actually delete the message now that our datasource supports it. --- Example/ChatExample/Screens/CommentsExampleView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Example/ChatExample/Screens/CommentsExampleView.swift b/Example/ChatExample/Screens/CommentsExampleView.swift index afa21ca5..d4445783 100644 --- a/Example/ChatExample/Screens/CommentsExampleView.swift +++ b/Example/ChatExample/Screens/CommentsExampleView.swift @@ -64,11 +64,14 @@ struct CommentsExampleView: View { defaultActionClosure(message, .reply) case .edit: defaultActionClosure(message, .edit { editedText in - // update this message's text on your BE + // update this message's text in your datasource print(editedText) }) case .delete: - print("deleted") + // delete this message in your datasource + viewModel.messages.removeAll { msg in + msg.id == message.id + } case .print: print(message.text) } From 875ea37380d07ae87b2d1661f9c5cb88ea194bdb Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:34:58 -0800 Subject: [PATCH 34/50] Fix merge conflicts --- Sources/ExyteChat/ChatView/ChatView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index f196a793..60b63730 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -7,8 +7,6 @@ import SwiftUI import GiphyUISDK -import FloatingButton -import SwiftUIIntrospect import ExyteMediaPicker public typealias MediaPickerParameters = SelectionParamsHolder @@ -142,6 +140,9 @@ public struct ChatView Date: Sun, 16 Feb 2025 13:35:32 -0800 Subject: [PATCH 35/50] Fix merge conflicts --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8581ae7b..2743ec88 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,6 @@ let package = Package( name: "ExyteChat", dependencies: [ .product(name: "ExyteMediaPicker", package: "MediaPicker"), - .product(name: "FloatingButton", package: "FloatingButton"), .product(name: "ActivityIndicatorView", package: "ActivityIndicatorView"), .product(name: "GiphyUISDK", package: "giphy-ios-sdk") ], From d8f0260035c55c648f42646a11eaff9ea9613219 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:40:36 -0800 Subject: [PATCH 36/50] Added comment regarding possible future Reaction types --- Sources/ExyteChat/Model/Reaction.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ExyteChat/Model/Reaction.swift b/Sources/ExyteChat/Model/Reaction.swift index 31a81b37..3613859e 100644 --- a/Sources/ExyteChat/Model/Reaction.swift +++ b/Sources/ExyteChat/Model/Reaction.swift @@ -7,7 +7,7 @@ import Foundation public enum ReactionType: Codable, Equatable, Hashable { case emoji(String) - //case sticker + //case sticker(Image / Giphy / Memoji) //case other... var toString:String { From 5766ce2b95a9001d53024acd27872ac0b90d28a0 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:31:17 -0800 Subject: [PATCH 37/50] Using PositionInUserGroup to get the exact top padding on a given message. --- Sources/ExyteChat/ChatView/ChatView.swift | 1 + .../ChatView/MessageView/MessageMenu/MessageMenu.swift | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index 9bee7c89..5d1f4413 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -406,6 +406,7 @@ public struct ChatView: View { var cellFrame: CGRect /// Leading / Trailing message alignment var alignment: MessageMenuAlignment + /// The position in user group of the message + var positionInUserGroup: PositionInUserGroup /// Leading padding (includes space for avatar) var leadingPadding: CGFloat /// Trailing padding @@ -224,7 +226,8 @@ struct MessageMenu: View { isShowingMenu = true /// Set our view state variables - reactionSelectionBottomPadding = message.reactions.isEmpty ? 2 : 6 + reactionSelectionBottomPadding = positionInUserGroup == .middle || positionInUserGroup == .last ? 4 : 0 + reactionOverviewWidth = chatViewFrame.width - safeAreaInsets.leading - safeAreaInsets.trailing reactionOverviewIsVisible = shouldShowReactionOverviewView reactionSelectionIsVisible = shouldShowReactionSelectionView menuIsVisible = true @@ -264,9 +267,8 @@ struct MessageMenu: View { height: viewModel.messageFrame.height ) - // - TODO: Reference the cells PositionInUserGroup for the exact padding amount messageTopPadding = cellFrame.height - messageFrame.height - if !message.reactions.isEmpty { messageTopPadding = 4 } + if !message.reactions.isEmpty { messageTopPadding = positionInUserGroup == .single || positionInUserGroup == .first ? 8 : 4 } /// Calculate our vertical safe area insets let safeArea = safeAreaInsets.top + safeAreaInsets.bottom From 58866d464421c90f93558ab67496e290ff8cd5dc Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:34:21 -0800 Subject: [PATCH 38/50] Updated ReactionOverview to handle leading safe area insets correctly and for a width to be specified by the parent view, instead of using UIScreen bounds. --- .../MessageMenu+ReactionOverview.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift index 1d285468..48e00266 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift @@ -12,8 +12,10 @@ struct ReactionOverview: View { @StateObject var viewModel: ChatViewModel let message: Message + let width: CGFloat let backgroundColor: Color let padding: CGFloat = 16 + let inScrollView: Bool struct SortedReaction:Identifiable { var id: String { reaction.toString } @@ -31,7 +33,7 @@ struct ReactionOverview: View { } Spacer() } - .frame(minWidth: UIScreen.main.bounds.width - (padding * 2)) + .frame(minWidth: width - (padding * 2)) } .scrollIndicators(.hidden) .frame(maxWidth: .infinity) @@ -43,6 +45,7 @@ struct ReactionOverview: View { RoundedRectangle(cornerRadius: 20, style: .continuous) ) .padding(padding) + .offset(x: horizontalOffset) } @ViewBuilder @@ -87,6 +90,15 @@ struct ReactionOverview: View { .compositingGroup() } + private var horizontalOffset: CGFloat { + guard inScrollView else { return 0 } + if message.user.isCurrentUser { + return safeAreaInsets.leading + } else { + return -safeAreaInsets.leading + } + } + private func sortReactions() -> [SortedReaction] { let mostRecent = message.reactions.sorted { $0.createdAt < $1.createdAt } let orderedEmojis = mostRecent.map(\.emoji) @@ -125,6 +137,8 @@ struct ReactionOverview: View { Reaction(user: sally, createdAt: Date.now.addingTimeInterval(-10), type: .emoji("🧠")) ] ), - backgroundColor: .black //Color(UIColor.secondarySystemBackground) + width: UIScreen.main.bounds.width, + backgroundColor: Color(UIColor.secondarySystemBackground), + inScrollView: false ) } From 96243c7225994d4ae9edd39085e2876a170992a1 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:35:43 -0800 Subject: [PATCH 39/50] Adjusted the emojiBackgroundView color to be more visible in more situations. --- .../MessageMenu/MessageMenu+ReactionOverview.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift index 48e00266..36b91cc7 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionOverview.swift @@ -55,7 +55,7 @@ struct ReactionOverview: View { .font(.title3) .background( emojiBackgroundView() - .opacity(0.5) + .opacity(0.1) .padding(-10) ) .padding(.top, 8) @@ -80,9 +80,9 @@ struct ReactionOverview: View { GeometryReader { proxy in ZStack(alignment: .center) { Circle() - .fill(Color(UIColor.systemBackground)) + .fill(.primary) Circle() - .fill(Color(UIColor.systemBackground)) + .fill(.primary) .frame(width: proxy.size.width / 4, height: proxy.size.width / 4) .offset(y: proxy.size.height / 2) } From b36a543f4901cfab9e724be93933edc956f5645d Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:36:52 -0800 Subject: [PATCH 40/50] Updated ReactionSelectionView to handle leading safeAreaInsets correctly --- .../MessageMenu/MessageMenu+ReactionSelectionView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift index 958fc72e..c679fc97 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift @@ -8,6 +8,7 @@ import SwiftUI struct ReactionSelectionView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.safeAreaInsets) private var safeAreaInsets static let MaxSelectionRowWidth:CGFloat = 400 @@ -293,10 +294,10 @@ struct ReactionSelectionView: View { return .leastNonzeroMagnitude case .search, .picked: if alignment == .left { - let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - leadingPadding) + let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - leadingPadding) - safeAreaInsets.leading return -((UIScreen.main.bounds.width - (additionalPadding + trailingPadding * 3) - (bubbleDiameter * 0.8)) - viewModel.messageFrame.maxX) } else { - let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - trailingPadding) + let additionalPadding = max(0, UIScreen.main.bounds.width - maxSelectionRowWidth - trailingPadding) - safeAreaInsets.leading return viewModel.messageFrame.minX - ((additionalPadding + trailingPadding * 3) + (bubbleDiameter * 0.8)) } } From b7aa088e46d18baca1487450f9e0c26da40f249e Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:37:45 -0800 Subject: [PATCH 41/50] Adjusted the inner shadow to be less intrusive. --- .../MessageMenu/MessageMenu+ReactionSelectionView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift index c679fc97..a1ac8abf 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift @@ -363,6 +363,8 @@ extension ReactionSelectionView { internal struct InteriorRadialShadow: ViewModifier { var color:Color + let innerRadius: CGFloat = 14 + let outerRadius: CGFloat = 5 func body(content: Content) -> some View { content.overlay( @@ -370,10 +372,10 @@ internal struct InteriorRadialShadow: ViewModifier { GeometryReader { proxy in Capsule(style: .continuous) .fill( - RadialGradient(gradient: Gradient(colors: [.clear, color]), center: .center, startRadius: proxy.size.width / 2 - 18, endRadius: proxy.size.width / 2 - 5) + RadialGradient(gradient: Gradient(colors: [.clear, color]), center: .center, startRadius: proxy.size.width / 2 - innerRadius, endRadius: proxy.size.width / 2 - outerRadius) ) .overlay( - RadialGradient(gradient: Gradient(colors: [.clear, color]), center: .center, startRadius: proxy.size.width / 2 - 18, endRadius: proxy.size.width / 2 - 5) + RadialGradient(gradient: Gradient(colors: [.clear, color]), center: .center, startRadius: proxy.size.width / 2 - innerRadius, endRadius: proxy.size.width / 2 - outerRadius) .clipShape(Capsule(style: .continuous)) ) } From e0793833ecf88dded228190c9f5fe24edbab211f Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:40:11 -0800 Subject: [PATCH 42/50] Added a reactionOverviewWidth param instead of calculating it independently 4 different times. --- .../MessageView/MessageMenu/MessageMenu.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift index 79535360..c82cfc45 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift @@ -92,6 +92,7 @@ struct MessageMenu: View { @State private var messageMenuFrame: CGRect = .zero @State private var reactionSelectionHeight: CGFloat = .zero @State private var reactionOverviewHeight: CGFloat = .zero + @State private var reactionOverviewWidth: CGFloat = .zero @State private var menuHeight: CGFloat = .zero /// Controls whether or not the reaction selection view is rendered @@ -156,8 +157,8 @@ struct MessageMenu: View { // Reaction Overview Rectangle if reactionOverviewIsVisible, case .vStack = messageMenuStyle { - ReactionOverview(viewModel: viewModel, message: message, backgroundColor: theme.colors.messageFriendBG) - .frame(maxWidth: chatViewFrame.width - safeAreaInsets.leading - safeAreaInsets.trailing) + ReactionOverview(viewModel: viewModel, message: message, width: reactionOverviewWidth, backgroundColor: theme.colors.messageFriendBG, inScrollView: false) + .frame(width: reactionOverviewWidth) .maxHeightGetter($reactionOverviewHeight) .offset(y: safeAreaInsets.top) .transition(defaultTransition) @@ -447,9 +448,10 @@ struct MessageMenu: View { func messageMenuView() -> some View { VStack(spacing: verticalSpacing) { if reactionOverviewIsVisible, case .scrollView = messageMenuStyle { - ReactionOverview(viewModel: viewModel, message: message, backgroundColor: theme.colors.messageFriendBG) - .frame(maxWidth: chatViewFrame.width - safeAreaInsets.leading - safeAreaInsets.trailing) - .offset(x: -safeAreaInsets.leading) + ReactionOverview(viewModel: viewModel, message: message, width: reactionOverviewWidth, backgroundColor: theme.colors.messageFriendBG, inScrollView: true) + .frame(width: reactionOverviewWidth) + .maxHeightGetter($reactionOverviewHeight) + //.offset(y: safeAreaInsets.top) .transition(defaultTransition) .opacity(messageMenuOpacity) } From d598c305b8db7bec6ee71f7bf83b3e1518dd3981 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:41:21 -0800 Subject: [PATCH 43/50] Include the height of the ReactionOverview in our content height check. --- .../ChatView/MessageView/MessageMenu/MessageMenu.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift index c82cfc45..6d1ee539 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift @@ -341,7 +341,8 @@ struct MessageMenu: View { if case .keyboard = previousState { if case .scrollView = messageMenuStyle { /// Ensure we still need our scroll view - let contentHeight = calculateMessageMenuHeight(including: [.message, .reactionSelection, .menu]) + let rOHeight:CGFloat = reactionOverviewIsVisible ? reactionOverviewHeight : 0 + let contentHeight = calculateMessageMenuHeight(including: [.message, .reactionSelection, .menu]) + rOHeight let safeArea = safeAreaInsets.top + safeAreaInsets.bottom if contentHeight > maxEntireHeight - safeArea { messageMenuStyle = .scrollView(height: maxEntireHeight - safeArea) From 77845301bd4c9539768600f95b83eeca46195e13 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:44:40 -0800 Subject: [PATCH 44/50] Updated the reactionsView layout to not require a sizeGetter and frame constraint (the previous layout resulted in very jittery scrolling). --- .../MessageView/MessageReactionView.swift | 11 ++-- .../ChatView/MessageView/MessageView.swift | 57 +++++++++---------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift index b93e355b..72a0b1d7 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageReactionView.swift @@ -38,14 +38,14 @@ extension MessageView { ) } } - .padding(.horizontal, -(bubbleSize.width / 2)) - .frame(width: messageSize.width) - .offset(x: 0, y: -(bubbleSize.height / 1.5)) + .offset( + x: message.user.isCurrentUser ? -(bubbleSize.height / 2) : (bubbleSize.height / 2), + y: -(bubbleSize.height / 1.5) + ) } @ViewBuilder func overflowBubbleView(leadingSpacer:Bool, needsOverflowBubble:Bool, text:String, containsReactionFromCurrentUser:Bool) -> some View { - if leadingSpacer { Spacer() } if needsOverflowBubble { ReactionBubble( reaction: .init( @@ -62,7 +62,6 @@ extension MessageView { ) .padding(message.user.isCurrentUser ? .trailing : .leading, -3) } - if !leadingSpacer { Spacer() } } struct PreparedReactions { @@ -150,7 +149,7 @@ struct ReactionBubble: View { // Otherwise just stroke the circle normally } else { Circle() - .stroke(style: .init(lineWidth: 1)) + .stroke(style: .init(lineWidth: 2)) .fill(theme.colors.mainBG) } } diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageView.swift index 53cd47b3..8c3ffda5 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageView.swift @@ -108,16 +108,8 @@ struct MessageView: View { .frame(width: 2) } } - if isDisplayingMessageMenu || message.reactions.isEmpty { - bubbleView(message) - } else { - ZStack(alignment: .top) { - bubbleView(message) - reactionsView(message) - } - // Sometimes the ZStack renders with a height larger than what's necessary, so we constrain it here. - .frame(maxHeight: messageSize.height) - } + + bubbleView(message) } if message.user.isCurrentUser, let status = message.status { @@ -138,30 +130,35 @@ struct MessageView: View { @ViewBuilder func bubbleView(_ message: Message) -> some View { - VStack(alignment: .leading, spacing: 0) { - if !message.attachments.isEmpty { - attachmentsView(message) + ZStack(alignment: message.user.isCurrentUser ? .topLeading : .topTrailing) { + VStack(alignment: .leading, spacing: 0) { + if !message.attachments.isEmpty { + attachmentsView(message) + } + + if !message.text.isEmpty { + textWithTimeView(message) + .font(Font(font)) + } + + if let recording = message.recording { + VStack(alignment: .trailing, spacing: 8) { + recordingView(recording) + messageTimeView() + .padding(.bottom, 8) + .padding(.trailing, 12) + } + } } - - if !message.text.isEmpty { - textWithTimeView(message) - .font(Font(font)) + .bubbleBackground(message, theme: theme) + .applyIf(isDisplayingMessageMenu) { + $0.frameGetter($viewModel.messageFrame) } - - if let recording = message.recording { - VStack(alignment: .trailing, spacing: 8) { - recordingView(recording) - messageTimeView() - .padding(.bottom, 8) - .padding(.trailing, 12) - } + + if !isDisplayingMessageMenu && !message.reactions.isEmpty { + reactionsView(message) } } - .bubbleBackground(message, theme: theme) - .sizeGetter($messageSize) - .applyIf(isDisplayingMessageMenu) { - $0.frameGetter($viewModel.messageFrame) - } } @ViewBuilder From 0ed009e6933d11bb9d85cf6387aeadbececfd236 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:51:59 -0800 Subject: [PATCH 45/50] Removed unused code --- .../ChatView/MessageView/MessageMenu/MessageMenu.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift index 6d1ee539..ab5dbe76 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift @@ -257,8 +257,6 @@ struct MessageMenu: View { horizontalOffset = safeAreaInsets.leading case .right: horizontalOffset = -safeAreaInsets.trailing - default: - horizontalOffset = 0 } } messageFrame = .init( @@ -660,9 +658,7 @@ struct ScrollContainerModifier: ViewModifier { func body(content: Content) -> some View { if (viewState != .initial || viewState != .prepare), case .scrollView(let height) = style { ScrollView { - Color.clear.frame(height: 16) content - Color.clear.frame(height: 16) } .clipped() .frame(maxHeight: height) From bef72042790584cdb721f8ef778222bbd5ffa25f Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:53:33 -0800 Subject: [PATCH 46/50] Formatting --- Sources/ExyteChat/Theme/ChatTheme+Auto.swift | 8 ++++---- Sources/ExyteChat/Theme/ChatTheme.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ExyteChat/Theme/ChatTheme+Auto.swift b/Sources/ExyteChat/Theme/ChatTheme+Auto.swift index 6e54f443..3902c502 100644 --- a/Sources/ExyteChat/Theme/ChatTheme+Auto.swift +++ b/Sources/ExyteChat/Theme/ChatTheme+Auto.swift @@ -67,7 +67,7 @@ public enum ThemedBackgroundStyle { /// The default system background tinted with the accent color (defaults to a value of 0.2) case mixedWithAccentColor(byAmount:Double = 0.2) - internal func getBackgroundColor(withAccent accentColor:Color, improveContrast:Bool) -> Color { + internal func getBackgroundColor(withAccent accentColor: Color, improveContrast: Bool) -> Color { switch self { case .systemDefault: return Color(UIColor.systemBackground) @@ -98,9 +98,9 @@ public enum ThemedBackgroundStyle { @available(iOS 18.0, *) internal struct ThemedChatView: ViewModifier { - var accentColor:Color - var background:ThemedBackgroundStyle - var improveContrast:Bool + var accentColor: Color + var background: ThemedBackgroundStyle + var improveContrast: Bool func body(content: Content) -> some View { let backgroundColor = background.getBackgroundColor(withAccent: accentColor, improveContrast: improveContrast) diff --git a/Sources/ExyteChat/Theme/ChatTheme.swift b/Sources/ExyteChat/Theme/ChatTheme.swift index a1319da8..d9cbee01 100644 --- a/Sources/ExyteChat/Theme/ChatTheme.swift +++ b/Sources/ExyteChat/Theme/ChatTheme.swift @@ -70,7 +70,7 @@ public struct ChatTheme { } @available(iOS 18.0, *) - internal init(accentColor: Color, background: ThemedBackgroundStyle = .mixedWithAccentColor(), improveContrast:Bool) { + internal init(accentColor: Color, background: ThemedBackgroundStyle = .mixedWithAccentColor(), improveContrast: Bool) { let backgroundColor:Color = background.getBackgroundColor(withAccent: accentColor, improveContrast: improveContrast) let friendMessageColor:Color = background.getFriendMessageColor(improveContrast: improveContrast) self.init( From d8e2ea06d498b3e31f5ea3201675c729595cbaa8 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:56:10 -0800 Subject: [PATCH 47/50] Instead of altering the opacity of the friendsMessageColor we mix it with the background. A transparent friendMessageColor presents an issue when overlaying reactions on message bubbles. --- Sources/ExyteChat/Theme/ChatTheme+Auto.swift | 6 +++--- Sources/ExyteChat/Theme/ChatTheme.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ExyteChat/Theme/ChatTheme+Auto.swift b/Sources/ExyteChat/Theme/ChatTheme+Auto.swift index 3902c502..a7d145e8 100644 --- a/Sources/ExyteChat/Theme/ChatTheme+Auto.swift +++ b/Sources/ExyteChat/Theme/ChatTheme+Auto.swift @@ -80,7 +80,7 @@ public enum ThemedBackgroundStyle { } } - internal func getFriendMessageColor(improveContrast:Bool) -> Color { + internal func getFriendMessageColor(improveContrast: Bool, background: Color) -> Color { switch self { case .systemDefault: return Color(UIColor.secondarySystemBackground) @@ -88,9 +88,9 @@ public enum ThemedBackgroundStyle { return Color(UIColor.secondarySystemBackground) case .mixedWithAccentColor: if improveContrast { - return Color(UIColor.systemBackground).opacity(0.8) + return Color(UIColor.systemBackground).mix(with: background, by: 0.2) } else { - return Color(UIColor.secondarySystemBackground).opacity(0.8) + return Color(UIColor.secondarySystemBackground).mix(with: background, by: 0.1) } } } diff --git a/Sources/ExyteChat/Theme/ChatTheme.swift b/Sources/ExyteChat/Theme/ChatTheme.swift index d9cbee01..90452e79 100644 --- a/Sources/ExyteChat/Theme/ChatTheme.swift +++ b/Sources/ExyteChat/Theme/ChatTheme.swift @@ -72,7 +72,7 @@ public struct ChatTheme { @available(iOS 18.0, *) internal init(accentColor: Color, background: ThemedBackgroundStyle = .mixedWithAccentColor(), improveContrast: Bool) { let backgroundColor:Color = background.getBackgroundColor(withAccent: accentColor, improveContrast: improveContrast) - let friendMessageColor:Color = background.getFriendMessageColor(improveContrast: improveContrast) + let friendMessageColor:Color = background.getFriendMessageColor(improveContrast: improveContrast, background: backgroundColor) self.init( colors: .init( mainBG: backgroundColor, From 95d585fbe20ef81942400724206e32749a4b4fd7 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 18:13:44 -0800 Subject: [PATCH 48/50] Actually use the alignment provided in our initializer instead of checking the message sender. --- Sources/ExyteChat/ChatView/ChatView.swift | 15 +++++++++++++-- .../MessageView/MessageMenu/MessageMenu.swift | 10 +++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/ExyteChat/ChatView/ChatView.swift b/Sources/ExyteChat/ChatView/ChatView.swift index 5d1f4413..04faf660 100644 --- a/Sources/ExyteChat/ChatView/ChatView.swift +++ b/Sources/ExyteChat/ChatView/ChatView.swift @@ -405,7 +405,7 @@ public struct ChatView MessageMenuAlignment { + switch chatType { + case .conversation: + return message.user.isCurrentUser ? .right : .left + case .comments: + return .left + } + } + + /// Our default reactionCallback flow if the user supports Reactions by implementing the didReactToMessage closure private func reactionClosure(_ message: Message) -> (ReactionType?) -> () { return { reactionType in Task { @@ -439,6 +449,7 @@ public struct ChatView (MenuAction) -> () { if let messageMenuAction { return { action in diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift index ab5dbe76..38cac0bb 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu.swift @@ -482,7 +482,7 @@ struct MessageMenu: View { .allowsHitTesting(false) if menuIsVisible { - menuView(isSender: message.user.isCurrentUser) + menuView() .transition(defaultTransition) } } @@ -507,10 +507,10 @@ struct MessageMenu: View { } @ViewBuilder - func menuView(isSender: Bool) -> some View { + func menuView() -> some View { let buttons = ActionEnum.allCases.enumerated().map { MenuButton(id: $0, action: $1) } HStack { - if isSender { Spacer() } + if alignment == .right { Spacer() } VStack { ForEach(buttons) { button in @@ -519,9 +519,9 @@ struct MessageMenu: View { } .menuContainer(menuStyle) - if !isSender { Spacer() } + if alignment == .left { Spacer() } } - .padding(isSender ? .trailing : .leading, isSender ? trailingPadding : leadingPadding) + .padding(alignment == .right ? .trailing : .leading, alignment == .right ? trailingPadding : leadingPadding) .padding(.top, 8) .maxHeightGetter($menuHeight) .frame(maxWidth: .infinity) From f93e696b63fa85dbebd2395a3d12bb3aa2d7002d Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 18:28:20 -0800 Subject: [PATCH 49/50] Moved the scaleAndFade definition above the preview code --- .../MessageMenu+ReactionSelectionView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift index a1ac8abf..8c499e22 100644 --- a/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift +++ b/Sources/ExyteChat/ChatView/MessageView/MessageMenu/MessageMenu+ReactionSelectionView.swift @@ -361,6 +361,12 @@ extension ReactionSelectionView { } } +extension AnyTransition { + static var scaleAndFade: AnyTransition { + .scale.combined(with: .opacity) + } +} + internal struct InteriorRadialShadow: ViewModifier { var color:Color let innerRadius: CGFloat = 14 @@ -425,9 +431,3 @@ internal struct InteriorRadialShadow: ViewModifier { } .frame(width: 400, height: 100) } - -extension AnyTransition { - static var scaleAndFade: AnyTransition { - .scale.combined(with: .opacity) - } -} From ad7547001d037dab91375d6b9d16d49d6624f8f3 Mon Sep 17 00:00:00 2001 From: Brandon <32753167+btoms20@users.noreply.github.com> Date: Mon, 17 Feb 2025 22:26:42 -0800 Subject: [PATCH 50/50] Altered mock reaction generation values, increased send time, decreased error rate. --- Example/ChatExample/ChatInteractor/MockChatInteractor.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Example/ChatExample/ChatInteractor/MockChatInteractor.swift b/Example/ChatExample/ChatInteractor/MockChatInteractor.swift index a6804682..3d1ddb4d 100644 --- a/Example/ChatExample/ChatInteractor/MockChatInteractor.swift +++ b/Example/ChatExample/ChatInteractor/MockChatInteractor.swift @@ -98,7 +98,7 @@ final class MockChatInteractor: ChatInteractorProtocol { /// Updates a reaction's status after a random amount of time func delayUpdateReactionStatus(messageID: String, reactionID: String) { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(.random(in: 500...2500))) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(.random(in: 1000...3500))) { [weak self] in guard let self else { return } if let matchIndex = self.chatState.value.firstIndex(where: { $0.uid == messageID }) { let originalMessage = self.chatState.value[matchIndex] @@ -107,7 +107,7 @@ final class MockChatInteractor: ChatInteractorProtocol { var reactions = originalMessage.reactions var status:Reaction.Status = .sent - if Int.random(min: 0, max: 2) == 0 { + if Int.random(min: 0, max: 20) == 0 { status = .error(.init(id: originalReaction.id, messageID: originalMessage.uid, createdAt: originalReaction.createdAt, type: originalReaction.type)) } reactions[reactionIndex] = .init(id: originalReaction.id, user: originalReaction.user, createdAt: originalReaction.createdAt, type: originalReaction.type, status: status)