From 3040959fc50092238f8cfc014dc93cee5e18d6ea Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Mon, 18 Dec 2023 20:05:45 -0600 Subject: [PATCH 01/20] New binding and animation for typing indicator --- Sources/SpeziChat/ChatView.swift | 7 ++++-- Sources/SpeziChat/MessageView.swift | 31 ++++++++++++++++-------- Sources/SpeziChat/MessagesView.swift | 35 +++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index bca58a9..cb06ee7 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -30,6 +30,7 @@ import SwiftUI public struct ChatView: View { @Binding var chat: Chat @Binding var disableInput: Bool + @Binding var displayTypingIndicator: Bool let messagePlaceholder: String? @State var messageInputHeight: CGFloat = 0 @@ -38,7 +39,7 @@ public struct ChatView: View { public var body: some View { ZStack { VStack { - MessagesView($chat, bottomPadding: $messageInputHeight) + MessagesView($chat, displayProgressIndicator: $displayTypingIndicator, bottomPadding: $messageInputHeight) .gesture( TapGesture().onEnded { UIApplication.shared.sendAction( @@ -69,10 +70,12 @@ public struct ChatView: View { public init( _ chat: Binding, disableInput: Binding = .constant(false), + displayProgressIndicator: Binding = .constant(false), messagePlaceholder: String? = nil ) { self._chat = chat self._disableInput = disableInput + self._displayTypingIndicator = displayProgressIndicator self.messagePlaceholder = messagePlaceholder } } @@ -87,5 +90,5 @@ public struct ChatView: View { ChatEntity(role: .assistant, content: "Assistant Message!"), ChatEntity(role: .function, content: "Function Message!") ] - )) + ), displayProgressIndicator: .constant(true)) } diff --git a/Sources/SpeziChat/MessageView.swift b/Sources/SpeziChat/MessageView.swift index b46193a..29491c0 100644 --- a/Sources/SpeziChat/MessageView.swift +++ b/Sources/SpeziChat/MessageView.swift @@ -26,17 +26,19 @@ import SwiftUI /// } /// } /// ``` -public struct MessageView: View { +public struct MessageView: View { /// Contains default values of configurable properties of the ``MessageView``. public enum Defaults { /// ``ChatEntity`` ``ChatEntity/Role``s that should be hidden by default - public static let hideMessagesWithRoles: Set = [.system, .function] + public static var hideMessagesWithRoles: Set { + [.system, .function] + } } private let chat: ChatEntity private let hideMessagesWithRoles: Set - + private let content: Content? private var foregroundColor: Color { chat.alignment == .leading ? .primary : .white @@ -64,7 +66,7 @@ public struct MessageView: View { if chat.alignment == .trailing { Spacer(minLength: 32) } - Text(chat.content) + content .multilineTextAlignment(multilineTextAllignment) .frame(idealWidth: .infinity) .padding(.horizontal, 10) @@ -92,9 +94,18 @@ public struct MessageView: View { /// - Parameters: /// - chat: The chat message that should be displayed. /// - hideMessagesWithRoles: If .system and/or .function messages should be hidden from the chat overview. - public init(_ chat: ChatEntity, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles) { + public init(_ chat: ChatEntity, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles) where Content == Text { self.chat = chat self.hideMessagesWithRoles = hideMessagesWithRoles + self.content = Text(chat.content) + } +} + +extension MessageView { + init(_ chat: ChatEntity, @ViewBuilder content: () -> Content) { + self.chat = chat + self.hideMessagesWithRoles = MessageView.Defaults.hideMessagesWithRoles + self.content = content() } } @@ -102,11 +113,11 @@ public struct MessageView: View { #Preview { ScrollView { VStack { - MessageView(ChatEntity(role: .system, content: "System Message!"), hideMessagesWithRoles: []) - MessageView(ChatEntity(role: .system, content: "System Message (hidden)!")) - MessageView(ChatEntity(role: .function, content: "Function Message!"), hideMessagesWithRoles: [.system]) - MessageView(ChatEntity(role: .user, content: "User Message!")) - MessageView(ChatEntity(role: .assistant, content: "Assistant Message!")) + MessageView(ChatEntity(role: .system, content: "System Message!"), hideMessagesWithRoles: []) + MessageView(ChatEntity(role: .system, content: "System Message (hidden)!")) + MessageView(ChatEntity(role: .function, content: "Function Message!"), hideMessagesWithRoles: [.system]) + MessageView(ChatEntity(role: .user, content: "User Message!")) + MessageView(ChatEntity(role: .assistant, content: "Assistant Message!")) } .padding() } diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index 6eb59d9..c86d4fb 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -34,6 +34,8 @@ public struct MessagesView: View { @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat + @Binding private var displayTypingIndicator: Bool + @State private var isAnimating: Bool = false private let hideMessagesWithRoles: Set @@ -61,6 +63,29 @@ public struct MessagesView: View { ForEach(Array(chat.enumerated()), id: \.offset) { _, message in MessageView(message, hideMessagesWithRoles: hideMessagesWithRoles) } + if displayTypingIndicator { + MessageView(ChatEntity(role: .assistant, content: "")) { + HStack(spacing: 3) { + ForEach(0..<3) { index in + Circle() + .opacity(self.isAnimating ? 1 : 0) + .foregroundStyle(.tertiary) + .animation( + Animation + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(0.2 * Double(index)), + value: self.isAnimating + ) + } + } + .frame(width: 42, height: 12) + .padding(.vertical, 4) + .onAppear { + self.isAnimating = true + } + } + } Spacer() .frame(height: bottomPadding) .id(MessagesView.bottomSpacerIdentifier) @@ -86,11 +111,13 @@ public struct MessagesView: View { /// - hideMessagesWithRoles: The .system and .function roles are hidden from message view public init( _ chat: Chat, - hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, + hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, + displayProgressIndicator: Bool = false, bottomPadding: CGFloat = 0 ) { self._chat = .constant(chat) self.hideMessagesWithRoles = hideMessagesWithRoles + self._displayTypingIndicator = .constant(displayProgressIndicator) self._bottomPadding = .constant(bottomPadding) } @@ -100,11 +127,13 @@ public struct MessagesView: View { /// - hideMessagesWithRoles: Defines which messages should be hidden based on the passed in message roles. public init( _ chat: Binding, - hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, + hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, + displayProgressIndicator: Binding = .constant(false), bottomPadding: Binding = .constant(0) ) { self._chat = chat self.hideMessagesWithRoles = hideMessagesWithRoles + self._displayTypingIndicator = displayProgressIndicator self._bottomPadding = bottomPadding } @@ -126,5 +155,5 @@ public struct MessagesView: View { ChatEntity(role: .user, content: "User Message!"), ChatEntity(role: .assistant, content: "Assistant Message!") ] - ) + , displayProgressIndicator: true) } From 210dbf4279c9400f2622e72a35e64a1885393be9 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Tue, 19 Dec 2023 16:18:51 -0600 Subject: [PATCH 02/20] Add documentation for `displayTypingIndicator` parameter --- Sources/SpeziChat/ChatView.swift | 9 +++++---- Sources/SpeziChat/MessagesView.swift | 12 +++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index cb06ee7..54fc9ab 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -39,7 +39,7 @@ public struct ChatView: View { public var body: some View { ZStack { VStack { - MessagesView($chat, displayProgressIndicator: $displayTypingIndicator, bottomPadding: $messageInputHeight) + MessagesView($chat, displayTypingIndicator: $displayTypingIndicator, bottomPadding: $messageInputHeight) .gesture( TapGesture().onEnded { UIApplication.shared.sendAction( @@ -66,16 +66,17 @@ public struct ChatView: View { /// - Parameters: /// - chat: The chat that should be displayed. /// - disableInput: Flag if the input view should be disabled. + /// - displayTypingIndicator: Flag if the "three dots" animation should be rendered to indicate pending assistant message. /// - messagePlaceholder: Placeholder text that should be added in the input field. public init( _ chat: Binding, disableInput: Binding = .constant(false), - displayProgressIndicator: Binding = .constant(false), + displayTypingIndicator: Binding = .constant(false), messagePlaceholder: String? = nil ) { self._chat = chat self._disableInput = disableInput - self._displayTypingIndicator = displayProgressIndicator + self._displayTypingIndicator = displayTypingIndicator self.messagePlaceholder = messagePlaceholder } } @@ -90,5 +91,5 @@ public struct ChatView: View { ChatEntity(role: .assistant, content: "Assistant Message!"), ChatEntity(role: .function, content: "Function Message!") ] - ), displayProgressIndicator: .constant(true)) + ), displayTypingIndicator: .constant(true)) } diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index c86d4fb..f268a38 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -108,32 +108,34 @@ public struct MessagesView: View { /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A fixed bottom padding for the messages view. + /// - displayTypingIndicator: Immutable option for rendering "three dots" animation. /// - hideMessagesWithRoles: The .system and .function roles are hidden from message view public init( _ chat: Chat, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - displayProgressIndicator: Bool = false, + displayTypingIndicator: Bool = false, bottomPadding: CGFloat = 0 ) { self._chat = .constant(chat) self.hideMessagesWithRoles = hideMessagesWithRoles - self._displayTypingIndicator = .constant(displayProgressIndicator) + self._displayTypingIndicator = .constant(displayTypingIndicator) self._bottomPadding = .constant(bottomPadding) } /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A bottom padding for the messages view. + /// - displayTypingIndicator: Option for rendering "three dots" animation, indicating that the LLM has started processing the message. /// - hideMessagesWithRoles: Defines which messages should be hidden based on the passed in message roles. public init( _ chat: Binding, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - displayProgressIndicator: Binding = .constant(false), + displayTypingIndicator: Binding = .constant(false), bottomPadding: Binding = .constant(0) ) { self._chat = chat self.hideMessagesWithRoles = hideMessagesWithRoles - self._displayTypingIndicator = displayProgressIndicator + self._displayTypingIndicator = displayTypingIndicator self._bottomPadding = bottomPadding } @@ -155,5 +157,5 @@ public struct MessagesView: View { ChatEntity(role: .user, content: "User Message!"), ChatEntity(role: .assistant, content: "Assistant Message!") ] - , displayProgressIndicator: true) + , displayTypingIndicator: true) } From df1d1b549a0c1e87e83cfd3aae99abf87e671c8f Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 21 Dec 2023 15:15:08 -0600 Subject: [PATCH 03/20] Encapsulated three dots in a separate view and addressed SwiftLint warnings --- Sources/SpeziChat/MessagesView.swift | 27 +++------------- Sources/SpeziChat/TypingIndicator.swift | 42 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 Sources/SpeziChat/TypingIndicator.swift diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index f268a38..0abc326 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -35,7 +35,7 @@ public struct MessagesView: View { @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat @Binding private var displayTypingIndicator: Bool - @State private var isAnimating: Bool = false + @State private var isAnimating = false private let hideMessagesWithRoles: Set @@ -65,25 +65,7 @@ public struct MessagesView: View { } if displayTypingIndicator { MessageView(ChatEntity(role: .assistant, content: "")) { - HStack(spacing: 3) { - ForEach(0..<3) { index in - Circle() - .opacity(self.isAnimating ? 1 : 0) - .foregroundStyle(.tertiary) - .animation( - Animation - .easeInOut(duration: 0.6) - .repeatForever(autoreverses: true) - .delay(0.2 * Double(index)), - value: self.isAnimating - ) - } - } - .frame(width: 42, height: 12) - .padding(.vertical, 4) - .onAppear { - self.isAnimating = true - } + TypingIndicator($isAnimating) } } Spacer() @@ -156,6 +138,7 @@ public struct MessagesView: View { ChatEntity(role: .function, content: "Function Message!"), ChatEntity(role: .user, content: "User Message!"), ChatEntity(role: .assistant, content: "Assistant Message!") - ] - , displayTypingIndicator: true) + ], + displayTypingIndicator: true + ) } diff --git a/Sources/SpeziChat/TypingIndicator.swift b/Sources/SpeziChat/TypingIndicator.swift new file mode 100644 index 0000000..be9cc04 --- /dev/null +++ b/Sources/SpeziChat/TypingIndicator.swift @@ -0,0 +1,42 @@ +// +// SwiftUIView.swift +// +// +// Created by Matthew Turk on 12/21/23. +// + +import SwiftUI + +public struct TypingIndicator: View { + @Binding var isAnimating: Bool + + public var body: some View { + HStack(spacing: 3) { + ForEach(0..<3) { index in + Circle() + .opacity(self.isAnimating ? 1 : 0) + .foregroundStyle(.tertiary) + .animation( + Animation + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(0.2 * Double(index)), + value: self.isAnimating + ) + } + } + .frame(width: 42, height: 12) + .padding(.vertical, 4) + .onAppear { + self.isAnimating = true + } + } + + init(_ isAnimating: Binding) { + self._isAnimating = isAnimating + } +} + +#Preview { + TypingIndicator(.constant(true)) +} From f5b95acaee5d5a45912de0ab831c6516b3a3282b Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 21 Dec 2023 15:16:43 -0600 Subject: [PATCH 04/20] Updated header comment for `TypingIndicator` --- Sources/SpeziChat/TypingIndicator.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziChat/TypingIndicator.swift b/Sources/SpeziChat/TypingIndicator.swift index be9cc04..07b1baa 100644 --- a/Sources/SpeziChat/TypingIndicator.swift +++ b/Sources/SpeziChat/TypingIndicator.swift @@ -1,8 +1,9 @@ // -// SwiftUIView.swift -// +// This source file is part of the Stanford Spezi open source project // -// Created by Matthew Turk on 12/21/23. +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT // import SwiftUI From a55bc73448fab2494eba33c781c9bab155bde535 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 21 Dec 2023 15:46:05 -0600 Subject: [PATCH 05/20] Added more extensive doc for `TypingIndicator` --- Sources/SpeziChat/MessagesView.swift | 10 ++-- Sources/SpeziChat/TypingIndicator.swift | 69 ++++++++++++++++++------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index 0abc326..505f783 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -35,7 +35,6 @@ public struct MessagesView: View { @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat @Binding private var displayTypingIndicator: Bool - @State private var isAnimating = false private let hideMessagesWithRoles: Set @@ -63,11 +62,10 @@ public struct MessagesView: View { ForEach(Array(chat.enumerated()), id: \.offset) { _, message in MessageView(message, hideMessagesWithRoles: hideMessagesWithRoles) } - if displayTypingIndicator { - MessageView(ChatEntity(role: .assistant, content: "")) { - TypingIndicator($isAnimating) - } + MessageView(ChatEntity(role: .assistant, content: "")) { + TypingIndicator($displayTypingIndicator) } + Spacer() .frame(height: bottomPadding) .id(MessagesView.bottomSpacerIdentifier) @@ -139,6 +137,6 @@ public struct MessagesView: View { ChatEntity(role: .user, content: "User Message!"), ChatEntity(role: .assistant, content: "Assistant Message!") ], - displayTypingIndicator: true + displayTypingIndicator: false ) } diff --git a/Sources/SpeziChat/TypingIndicator.swift b/Sources/SpeziChat/TypingIndicator.swift index 07b1baa..c76fec6 100644 --- a/Sources/SpeziChat/TypingIndicator.swift +++ b/Sources/SpeziChat/TypingIndicator.swift @@ -8,33 +8,62 @@ import SwiftUI +/// Creates a typing indicator animation for pending messages. +/// The animation consists of three dots that fade in and out in a sequential, wave-like pattern. +/// It loops continuously as long as `isAnimating` is `true`. +/// +/// This view can be bound to a `Bool` value that controls whether the animation is visible (and active) or not. +/// +/// Usage: +/// ```swift +/// struct ChatView: View { +/// @State private var isTyping = true +/// +/// var body: some View { +/// VStack { +/// MessageView(ChatEntity(role: .user, content: "User Message!")) +/// MessageView(ChatEntity(role: .assistant, content: "")) { +/// TypingIndicator($isTyping) +/// } +/// } +/// } +/// } +/// ``` +/// public struct TypingIndicator: View { - @Binding var isAnimating: Bool + @Binding var isVisible: Bool + @State var isAnimating = false public var body: some View { - HStack(spacing: 3) { - ForEach(0..<3) { index in - Circle() - .opacity(self.isAnimating ? 1 : 0) - .foregroundStyle(.tertiary) - .animation( - Animation - .easeInOut(duration: 0.6) - .repeatForever(autoreverses: true) - .delay(0.2 * Double(index)), - value: self.isAnimating - ) + if isVisible { + HStack(spacing: 3) { + ForEach(0..<3) { index in + Circle() + .opacity(self.isAnimating ? 1 : 0) + .foregroundStyle(.tertiary) + .animation( + Animation + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(0.2 * Double(index)), + value: self.isAnimating + ) + } } - } - .frame(width: 42, height: 12) - .padding(.vertical, 4) - .onAppear { - self.isAnimating = true + .frame(width: 42, height: 12) + .padding(.vertical, 4) + .onAppear { + self.isAnimating = true + } + } else { + EmptyView() } } - init(_ isAnimating: Binding) { - self._isAnimating = isAnimating + /// - Parameters + /// - isAnimating: A binding to a `Bool` that determines whether the animation is active. + init(_ isVisible: Binding) { + self._isVisible = isVisible } } From a1d425152e45794e5a8ffe37b73c477500c3cf95 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Mon, 8 Jan 2024 11:48:45 -0800 Subject: [PATCH 06/20] Remove generic from `MessageView` --- Sources/SpeziChat/ChatView.swift | 2 +- .../MessageInputViewHeightKey.swift | 0 .../{ => Helpers}/TypingIndicator.swift | 25 +++--------- Sources/SpeziChat/MessagesView.swift | 39 +++++++++++++------ 4 files changed, 35 insertions(+), 31 deletions(-) rename Sources/SpeziChat/{ => Helpers}/MessageInputViewHeightKey.swift (100%) rename Sources/SpeziChat/{ => Helpers}/TypingIndicator.swift (69%) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index 2cd4d56..d5d3b27 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -67,7 +67,7 @@ public struct ChatView: View { public var body: some View { ZStack { VStack { - MessagesView($chat, displayTypingIndicator: .constant(false), bottomPadding: $messageInputHeight) + MessagesView($chat, bottomPadding: $messageInputHeight) .gesture( TapGesture().onEnded { UIApplication.shared.sendAction( diff --git a/Sources/SpeziChat/MessageInputViewHeightKey.swift b/Sources/SpeziChat/Helpers/MessageInputViewHeightKey.swift similarity index 100% rename from Sources/SpeziChat/MessageInputViewHeightKey.swift rename to Sources/SpeziChat/Helpers/MessageInputViewHeightKey.swift diff --git a/Sources/SpeziChat/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift similarity index 69% rename from Sources/SpeziChat/TypingIndicator.swift rename to Sources/SpeziChat/Helpers/TypingIndicator.swift index c76fec6..25a04ef 100644 --- a/Sources/SpeziChat/TypingIndicator.swift +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -12,30 +12,23 @@ import SwiftUI /// The animation consists of three dots that fade in and out in a sequential, wave-like pattern. /// It loops continuously as long as `isAnimating` is `true`. /// -/// This view can be bound to a `Bool` value that controls whether the animation is visible (and active) or not. -/// /// Usage: /// ```swift /// struct ChatView: View { -/// @State private var isTyping = true -/// /// var body: some View { /// VStack { /// MessageView(ChatEntity(role: .user, content: "User Message!")) -/// MessageView(ChatEntity(role: .assistant, content: "")) { -/// TypingIndicator($isTyping) -/// } +/// TypingIndicator() /// } /// } /// } /// ``` /// public struct TypingIndicator: View { - @Binding var isVisible: Bool @State var isAnimating = false public var body: some View { - if isVisible { + HStack { HStack(spacing: 3) { ForEach(0..<3) { index in Circle() @@ -50,23 +43,17 @@ public struct TypingIndicator: View { ) } } - .frame(width: 42, height: 12) + .frame(width: 42, height: 12, alignment: .leading) .padding(.vertical, 4) .onAppear { self.isAnimating = true } - } else { - EmptyView() + .chatMessageStyle(alignment: .leading) + Spacer(minLength: 32) } } - - /// - Parameters - /// - isAnimating: A binding to a `Bool` that determines whether the animation is active. - init(_ isVisible: Binding) { - self._isVisible = isVisible - } } #Preview { - TypingIndicator(.constant(true)) + TypingIndicator() } diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index 93d524e..a34d6bd 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -30,12 +30,21 @@ import SwiftUI /// } /// ``` public struct MessagesView: View { + /// Offers configuration of when to show an animation indicating a pending message from assistant: + /// either (a) whenever the last message in the chat is from the user but not even a partial response from the assistant has arrived, + /// or (b) depending on `shouldDisplay`, some explicitly stated source of truth. + /// Useful as a visual cue to the user that the LLM has started processing the message. + public enum LoadingDisplayMode { + case automatic + case manual(shouldDisplay: Binding) + } + private static let bottomSpacerIdentifier = "Bottom Spacer" @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat - @Binding private var displayTypingIndicator: Bool private let hideMessagesWithRoles: Set + private let loadingDisplayMode: LoadingDisplayMode private var keyboardPublisher: AnyPublisher { @@ -54,6 +63,14 @@ public struct MessagesView: View { .eraseToAnyPublisher() } + private var shouldDisplayTypingIndicator: Bool { + switch self.loadingDisplayMode { + case .automatic: + self.chat.last?.role == .user + case .manual(let shouldDisplay): + shouldDisplay.wrappedValue + } + } public var body: some View { ScrollViewReader { scrollViewProxy in @@ -62,8 +79,9 @@ public struct MessagesView: View { ForEach(Array(chat.enumerated()), id: \.offset) { _, message in MessageView(message, hideMessagesWithRoles: hideMessagesWithRoles) } - MessageView(ChatEntity(role: .assistant, content: "")) - + if shouldDisplayTypingIndicator { + TypingIndicator() + } Spacer() .frame(height: bottomPadding) .id(MessagesView.bottomSpacerIdentifier) @@ -86,34 +104,34 @@ public struct MessagesView: View { /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A fixed bottom padding for the messages view. - /// - displayTypingIndicator: Immutable option for rendering "three dots" animation. + /// - loadingDisplayMode: Indicates whether a "three dots" animation should be automatically or manually shown. /// - hideMessagesWithRoles: The .system and .function roles are hidden from message view public init( _ chat: Chat, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - displayTypingIndicator: Bool = false, + loadingDisplayMode: LoadingDisplayMode = .automatic, bottomPadding: CGFloat = 0 ) { self._chat = .constant(chat) self.hideMessagesWithRoles = hideMessagesWithRoles - self._displayTypingIndicator = .constant(displayTypingIndicator) + self.loadingDisplayMode = loadingDisplayMode self._bottomPadding = .constant(bottomPadding) } /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A bottom padding for the messages view. - /// - displayTypingIndicator: Option for rendering "three dots" animation, indicating that the LLM has started processing the message. + /// - loadingDisplayMode: Indicates whether a "three dots" animation should be automatically or manually shown. /// - hideMessagesWithRoles: Defines which messages should be hidden based on the passed in message roles. public init( _ chat: Binding, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - displayTypingIndicator: Binding = .constant(false), + loadingDisplayMode: LoadingDisplayMode = .automatic, bottomPadding: Binding = .constant(0) ) { self._chat = chat self.hideMessagesWithRoles = hideMessagesWithRoles - self._displayTypingIndicator = displayTypingIndicator + self.loadingDisplayMode = loadingDisplayMode self._bottomPadding = bottomPadding } @@ -134,7 +152,6 @@ public struct MessagesView: View { ChatEntity(role: .function(name: "test_function"), content: "Function Message!"), ChatEntity(role: .user, content: "User Message!"), ChatEntity(role: .assistant, content: "Assistant Message!") - ], - displayTypingIndicator: false + ] ) } From f31375ed13e6ce0ea73d3d32c4eab8964e7a4713 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Mon, 8 Jan 2024 11:58:11 -0800 Subject: [PATCH 07/20] Leftover comment --- Sources/SpeziChat/ChatView.swift | 3 --- Sources/SpeziChat/MessageView.swift | 2 ++ 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index d5d3b27..cd7b062 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -55,7 +55,6 @@ import SwiftUI /// ``` public struct ChatView: View { @Binding var chat: Chat - // @Binding var displayTypingIndicator: Bool var disableInput: Bool let exportFormat: ChatExportFormat? let messagePlaceholder: String? @@ -134,8 +133,6 @@ public struct ChatView: View { self.disableInput = disableInput self.exportFormat = exportFormat self.messagePlaceholder = messagePlaceholder - self.showShareSheet = false - self.messageInputHeight = 0.0 } } diff --git a/Sources/SpeziChat/MessageView.swift b/Sources/SpeziChat/MessageView.swift index d90e793..cc17c16 100644 --- a/Sources/SpeziChat/MessageView.swift +++ b/Sources/SpeziChat/MessageView.swift @@ -40,6 +40,7 @@ public struct MessageView: View { private let chat: ChatEntity private let hideMessagesWithRoles: Set + public var body: some View { // Compare raw value of `ChatEntity/Role`s as associated values present if !hideMessagesWithRoles.contains(where: { $0.rawValue == chat.role.rawValue }) { @@ -66,6 +67,7 @@ public struct MessageView: View { } } + #Preview { ScrollView { VStack { From c380368467dcda5440ee1fe5ce52c8f44042a0f8 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 18 Jan 2024 14:37:27 -0800 Subject: [PATCH 08/20] Extend `testChat` --- Tests/UITests/TestApp/ChatTestView.swift | 2 +- .../TestApp/TypingIndicatorTestView.swift | 24 ------------------- .../TestAppUITests/TestAppUITests.swift | 11 +++++++-- .../UITests/UITests.xcodeproj/project.pbxproj | 4 ---- 4 files changed, 10 insertions(+), 31 deletions(-) delete mode 100644 Tests/UITests/TestApp/TypingIndicatorTestView.swift diff --git a/Tests/UITests/TestApp/ChatTestView.swift b/Tests/UITests/TestApp/ChatTestView.swift index 1052e4c..5c355a2 100644 --- a/Tests/UITests/TestApp/ChatTestView.swift +++ b/Tests/UITests/TestApp/ChatTestView.swift @@ -24,7 +24,7 @@ struct ChatTestView: View { /// Append a new assistant message to the chat after sleeping for 1 second. if newValue.last?.role == .user { Task { - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .seconds(5)) await MainActor.run { chat.append(.init(role: .assistant, content: "Assistant Message Response!")) diff --git a/Tests/UITests/TestApp/TypingIndicatorTestView.swift b/Tests/UITests/TestApp/TypingIndicatorTestView.swift deleted file mode 100644 index 9c68570..0000000 --- a/Tests/UITests/TestApp/TypingIndicatorTestView.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI -import SpeziChat - -struct TypingIndicatorTestView: View { - @State private var chat = Chat(arrayLiteral: ChatEntity(role: .user, content: "User Message!")) - @State private var shouldDisplay: Bool = false - - var body: some View { - MessagesView($chat, loadingDisplayMode: .manual(shouldDisplay: $shouldDisplay)) - .toolbar { - ToolbarItem { - Button { - shouldDisplay.toggle() - } label: { - Image(systemName: "hammer") - } - } - } - } -} - -#Preview { - TypingIndicatorTestView() -} diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index dad81c1..207f25c 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -33,6 +33,13 @@ class TestAppUITests: XCTestCase { sleep(1) + // Run test to check if typing indicator is in the view and tag Paul + XCTAssert((0..<3).reduce(false, { partialResult, index in + return partialResult || app.otherElements["Pending Message Animation \(index)"].waitForExistence(timeout: 1) + })) + + sleep(4) + XCTAssert(app.staticTexts["Assistant Message Response!"].waitForExistence(timeout: 5)) } @@ -99,7 +106,7 @@ class TestAppUITests: XCTestCase { filesApp.buttons["Done"].tap() } - func testTypingIndicatorVisibility() throws { + /*func testTypingIndicatorVisibility() throws { let app = XCUIApplication() app.launch() @@ -109,5 +116,5 @@ class TestAppUITests: XCTestCase { let typingIndicator = app.otherElements["Pending Message Animation 0"] XCTAssert(typingIndicator.waitForExistence(timeout: 2), "Typing Indicator should be present") - } + }*/ } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index dcf6423..eeccb94 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 971D0E1D2B005DED003AD89E /* ChatTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 971D0E1C2B005DED003AD89E /* ChatTestView.swift */; }; 971D0E1F2B005E9C003AD89E /* SpeziChat in Frameworks */ = {isa = PBXBuildFile; productRef = 971D0E1E2B005E9C003AD89E /* SpeziChat */; }; 97911AFB2B01A27D003AEEF5 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 97911AFA2B01A27D003AEEF5 /* XCTestExtensions */; }; - B24250272B53774B0032FA1D /* TypingIndicatorTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24250262B53774B0032FA1D /* TypingIndicatorTestView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -49,7 +48,6 @@ 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; 971D0E1C2B005DED003AD89E /* ChatTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTestView.swift; sourceTree = ""; }; 9794D9552B01BC2200DC02FB /* Speech.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Speech.framework; path = System/Library/Frameworks/Speech.framework; sourceTree = SDKROOT; }; - B24250262B53774B0032FA1D /* TypingIndicatorTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorTestView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -98,7 +96,6 @@ children = ( 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 971D0E1C2B005DED003AD89E /* ChatTestView.swift */, - B24250262B53774B0032FA1D /* TypingIndicatorTestView.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, ); path = TestApp; @@ -229,7 +226,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B24250272B53774B0032FA1D /* TypingIndicatorTestView.swift in Sources */, 971D0E1D2B005DED003AD89E /* ChatTestView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, ); From 7371b937c3c53bb490d368cc22e25f84dc7eeeed Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 18 Jan 2024 14:51:45 -0800 Subject: [PATCH 09/20] Neater placement of accessibility identifier --- .../UITests/TestAppUITests/TestAppUITests.swift | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index 207f25c..0a3be57 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -33,10 +33,7 @@ class TestAppUITests: XCTestCase { sleep(1) - // Run test to check if typing indicator is in the view and tag Paul - XCTAssert((0..<3).reduce(false, { partialResult, index in - return partialResult || app.otherElements["Pending Message Animation \(index)"].waitForExistence(timeout: 1) - })) + XCTAssert(app.otherElements["Pending Message Animation"].waitForExistence(timeout: 2)) sleep(4) @@ -105,16 +102,4 @@ class TestAppUITests: XCTestCase { XCTAssert(filesApp.buttons["Done"].waitForExistence(timeout: 2)) filesApp.buttons["Done"].tap() } - - /*func testTypingIndicatorVisibility() throws { - let app = XCUIApplication() - app.launch() - - try app.textViews["Message Input Textfield"].enter(value: "User Message!", dismissKeyboard: false) - XCTAssert(app.buttons["Send Message"].waitForExistence(timeout: 5)) - app.buttons["Send Message"].tap() - - let typingIndicator = app.otherElements["Pending Message Animation 0"] - XCTAssert(typingIndicator.waitForExistence(timeout: 2), "Typing Indicator should be present") - }*/ } From 95bf9041c2fa5522563ee6c3e73f8408f2816487 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 18 Jan 2024 14:58:35 -0800 Subject: [PATCH 10/20] Undo accidental changes to `project.pbxproj` --- Sources/SpeziChat/Helpers/TypingIndicator.swift | 2 +- Tests/UITests/UITests.xcodeproj/project.pbxproj | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/SpeziChat/Helpers/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift index 781599c..007e78f 100644 --- a/Sources/SpeziChat/Helpers/TypingIndicator.swift +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -41,8 +41,8 @@ public struct TypingIndicator: View { .delay(0.2 * Double(index)), value: self.isAnimating ) - .accessibilityIdentifier("\(String(localized: "PENDING_MESSAGE_ANIMATION", bundle: .module)) \(index)") } + .accessibilityIdentifier(String(localized: "PENDING_MESSAGE_ANIMATION", bundle: .module)) } .frame(width: 42, height: 12, alignment: .leading) .padding(.vertical, 4) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index eeccb94..4d3922a 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -392,14 +392,14 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezichat.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; @@ -431,14 +431,14 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezichat.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; @@ -580,14 +580,14 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezichat.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Test; }; From cabbd77e5794851bd3228efb105f779cc493a785 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 18 Jan 2024 15:01:18 -0800 Subject: [PATCH 11/20] Modify comment for consistency --- Sources/SpeziChat/Helpers/TypingIndicator.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SpeziChat/Helpers/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift index 007e78f..3082084 100644 --- a/Sources/SpeziChat/Helpers/TypingIndicator.swift +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -23,7 +23,6 @@ import SwiftUI /// } /// } /// ``` -/// public struct TypingIndicator: View { @State var isAnimating = false From 8e4a7ace84892aac983b444fd48231db326cf509 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Sat, 20 Jan 2024 18:43:15 -0800 Subject: [PATCH 12/20] Re-build documentation --- Sources/SpeziChat/MessagesView.swift | 22 +++++++++++-------- .../SpeziChat/Resources/Localizable.xcstrings | 1 - 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index a34d6bd..59e22e1 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -30,13 +30,15 @@ import SwiftUI /// } /// ``` public struct MessagesView: View { - /// Offers configuration of when to show an animation indicating a pending message from assistant: - /// either (a) whenever the last message in the chat is from the user but not even a partial response from the assistant has arrived, - /// or (b) depending on `shouldDisplay`, some explicitly stated source of truth. - /// Useful as a visual cue to the user that the LLM has started processing the message. + /// Represents a configuration used in the initializer of `MessagesView` to specify when to display an animation indicating a pending message from a chat participant. + /// + /// `LoadingDisplayMode` has two possible cases: + /// - `.automatic`: The animation is shown whenever the last message in the chat is from the user, + /// and the assistant has not yet begun to respond. + /// - `.manual(shouldDisplay: Bool)`: The animation will be displayed Boolean flag. public enum LoadingDisplayMode { case automatic - case manual(shouldDisplay: Binding) + case manual(shouldDisplay: Bool) } private static let bottomSpacerIdentifier = "Bottom Spacer" @@ -44,7 +46,7 @@ public struct MessagesView: View { @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat private let hideMessagesWithRoles: Set - private let loadingDisplayMode: LoadingDisplayMode + private let loadingDisplayMode: LoadingDisplayMode? private var keyboardPublisher: AnyPublisher { @@ -68,7 +70,9 @@ public struct MessagesView: View { case .automatic: self.chat.last?.role == .user case .manual(let shouldDisplay): - shouldDisplay.wrappedValue + shouldDisplay + case .none: + false } } @@ -109,7 +113,7 @@ public struct MessagesView: View { public init( _ chat: Chat, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - loadingDisplayMode: LoadingDisplayMode = .automatic, + loadingDisplayMode: LoadingDisplayMode? = nil, bottomPadding: CGFloat = 0 ) { self._chat = .constant(chat) @@ -126,7 +130,7 @@ public struct MessagesView: View { public init( _ chat: Binding, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - loadingDisplayMode: LoadingDisplayMode = .automatic, + loadingDisplayMode: LoadingDisplayMode? = nil, bottomPadding: Binding = .constant(0) ) { self._chat = chat diff --git a/Sources/SpeziChat/Resources/Localizable.xcstrings b/Sources/SpeziChat/Resources/Localizable.xcstrings index bca6be1..7aa441f 100644 --- a/Sources/SpeziChat/Resources/Localizable.xcstrings +++ b/Sources/SpeziChat/Resources/Localizable.xcstrings @@ -66,7 +66,6 @@ } }, "PENDING_MESSAGE_ANIMATION" : { - "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { From 67dcd21c31126e469a7526c40bcc110026ccadf5 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Sat, 20 Jan 2024 18:54:42 -0800 Subject: [PATCH 13/20] Improvements to `TypingIndicator` preview --- Sources/SpeziChat/Helpers/TypingIndicator.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziChat/Helpers/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift index 3082084..575f47d 100644 --- a/Sources/SpeziChat/Helpers/TypingIndicator.swift +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -55,5 +55,14 @@ public struct TypingIndicator: View { } #Preview { - TypingIndicator() + ScrollView { + VStack { + MessageView(ChatEntity(role: .system, content: "System Message!"), hideMessagesWithRoles: []) + MessageView(ChatEntity(role: .system, content: "System Message (hidden)!")) + MessageView(ChatEntity(role: .function(name: "test_function"), content: "Function Message!"), hideMessagesWithRoles: [.system]) + MessageView(ChatEntity(role: .user, content: "User Message!")) + TypingIndicator() + } + .padding() + } } From 7a2af4952eee536cb7df7b1783c51804006b3229 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Sat, 20 Jan 2024 19:07:46 -0800 Subject: [PATCH 14/20] Adjust circle size --- Sources/SpeziChat/Helpers/TypingIndicator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziChat/Helpers/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift index 575f47d..461b0f8 100644 --- a/Sources/SpeziChat/Helpers/TypingIndicator.swift +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -40,10 +40,11 @@ public struct TypingIndicator: View { .delay(0.2 * Double(index)), value: self.isAnimating ) + .frame(width: 10) } .accessibilityIdentifier(String(localized: "PENDING_MESSAGE_ANIMATION", bundle: .module)) } - .frame(width: 42, height: 12, alignment: .leading) + .frame(width: 42, height: 12, alignment: .center) .padding(.vertical, 4) .onAppear { self.isAnimating = true From 85ade8a6b60fe4e9f1f077025243d2a07b9ad2e1 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Sat, 20 Jan 2024 20:16:32 -0800 Subject: [PATCH 15/20] Modify `ChatView` for automatic `TypingIndicator` --- Sources/SpeziChat/ChatView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index d9a1363..cfe5992 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -66,7 +66,7 @@ public struct ChatView: View { public var body: some View { ZStack { VStack { - MessagesView($chat, bottomPadding: $messageInputHeight) + MessagesView($chat, loadingDisplayMode: .automatic, bottomPadding: $messageInputHeight) .gesture( TapGesture().onEnded { UIApplication.shared.sendAction( From ea2ee1d9705b26150b3c6a39e96f0d49e332dae2 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 25 Jan 2024 12:30:23 -0800 Subject: [PATCH 16/20] Renaming for typing indicator --- Sources/SpeziChat/ChatView.swift | 3 ++- .../SpeziChat/Helpers/TypingIndicator.swift | 2 +- Sources/SpeziChat/MessagesView.swift | 20 +++++++++---------- .../SpeziChat/Resources/Localizable.xcstrings | 20 +++++++++---------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index cfe5992..efec19b 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -61,12 +61,13 @@ public struct ChatView: View { @State var messageInputHeight: CGFloat = 0 @State private var showShareSheet = false + @State private var messagePendingAnimation: MessagesView.TypingIndicatorDisplayMode? public var body: some View { ZStack { VStack { - MessagesView($chat, loadingDisplayMode: .automatic, bottomPadding: $messageInputHeight) + MessagesView($chat, typingIndicator: messagePendingAnimation, bottomPadding: $messageInputHeight) .gesture( TapGesture().onEnded { UIApplication.shared.sendAction( diff --git a/Sources/SpeziChat/Helpers/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift index 461b0f8..b6d5e1f 100644 --- a/Sources/SpeziChat/Helpers/TypingIndicator.swift +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -42,7 +42,7 @@ public struct TypingIndicator: View { ) .frame(width: 10) } - .accessibilityIdentifier(String(localized: "PENDING_MESSAGE_ANIMATION", bundle: .module)) + .accessibilityIdentifier(String(localized: "TYPING_INDICATOR", bundle: .module)) } .frame(width: 42, height: 12, alignment: .center) .padding(.vertical, 4) diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index 59e22e1..b2fd3da 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -32,11 +32,11 @@ import SwiftUI public struct MessagesView: View { /// Represents a configuration used in the initializer of `MessagesView` to specify when to display an animation indicating a pending message from a chat participant. /// - /// `LoadingDisplayMode` has two possible cases: + /// `TypingIndicatorDisplayMode` has two possible cases: /// - `.automatic`: The animation is shown whenever the last message in the chat is from the user, /// and the assistant has not yet begun to respond. /// - `.manual(shouldDisplay: Bool)`: The animation will be displayed Boolean flag. - public enum LoadingDisplayMode { + public enum TypingIndicatorDisplayMode { case automatic case manual(shouldDisplay: Bool) } @@ -46,7 +46,7 @@ public struct MessagesView: View { @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat private let hideMessagesWithRoles: Set - private let loadingDisplayMode: LoadingDisplayMode? + private let typingIndicator: TypingIndicatorDisplayMode? private var keyboardPublisher: AnyPublisher { @@ -66,7 +66,7 @@ public struct MessagesView: View { } private var shouldDisplayTypingIndicator: Bool { - switch self.loadingDisplayMode { + switch self.typingIndicator { case .automatic: self.chat.last?.role == .user case .manual(let shouldDisplay): @@ -108,34 +108,34 @@ public struct MessagesView: View { /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A fixed bottom padding for the messages view. - /// - loadingDisplayMode: Indicates whether a "three dots" animation should be automatically or manually shown. + /// - typingIndicator: Indicates whether a "three dots" animation should be automatically or manually shown; default value of `nil` will result in no indicator being shown under any condition. /// - hideMessagesWithRoles: The .system and .function roles are hidden from message view public init( _ chat: Chat, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - loadingDisplayMode: LoadingDisplayMode? = nil, + typingIndicator: TypingIndicatorDisplayMode? = nil, bottomPadding: CGFloat = 0 ) { self._chat = .constant(chat) self.hideMessagesWithRoles = hideMessagesWithRoles - self.loadingDisplayMode = loadingDisplayMode + self.typingIndicator = typingIndicator self._bottomPadding = .constant(bottomPadding) } /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A bottom padding for the messages view. - /// - loadingDisplayMode: Indicates whether a "three dots" animation should be automatically or manually shown. + /// - typingIndicator: Indicates whether a "three dots" animation should be automatically or manually shown; default value of `nil` will result in no indicator being shown under any condition. /// - hideMessagesWithRoles: Defines which messages should be hidden based on the passed in message roles. public init( _ chat: Binding, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, - loadingDisplayMode: LoadingDisplayMode? = nil, + typingIndicator: TypingIndicatorDisplayMode? = nil, bottomPadding: Binding = .constant(0) ) { self._chat = chat self.hideMessagesWithRoles = hideMessagesWithRoles - self.loadingDisplayMode = loadingDisplayMode + self.typingIndicator = typingIndicator self._bottomPadding = bottomPadding } diff --git a/Sources/SpeziChat/Resources/Localizable.xcstrings b/Sources/SpeziChat/Resources/Localizable.xcstrings index 7aa441f..ea18908 100644 --- a/Sources/SpeziChat/Resources/Localizable.xcstrings +++ b/Sources/SpeziChat/Resources/Localizable.xcstrings @@ -65,16 +65,6 @@ } } }, - "PENDING_MESSAGE_ANIMATION" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pending Message Animation" - } - } - } - }, "SEND_MESSAGE" : { "localizations" : { "de" : { @@ -96,6 +86,16 @@ } } } + }, + "TYPING_INDICATOR" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typing Indicator" + } + } + } } }, "version" : "1.0" From 66f11c4633fd64025cc112f00f7ab1a03a7ca9d3 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 25 Jan 2024 12:54:27 -0800 Subject: [PATCH 17/20] Revisions to `README` for updated views --- README.md | 3 ++- Sources/SpeziChat/MessagesView.swift | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ec2a60a..8fee7bb 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ These entries are mandatory for apps that utilize microphone and speech recognit ### Chat View -The [`ChatView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/chatview) provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. +The [`ChatView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/chatview) provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. It accepts an additional `messagePendingAnimation` parameter to control whether a chat bubble animation is shown for a message that is currently being composed. By default, `messagePendingAnimation` has a value of `nil` and does not show. In addition, the [`ChatView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/chatview) provides functionality to export the visualized [`Chat`](https://swiftpackageindex.com/stanfordspezi/spezichat/0.1.1/documentation/spezichat/chat) as a PDF document, JSON representation, or textual UTF-8 file (see `ChatView/ChatExportFormat`) via a Share Sheet (or Activity View). ```swift @@ -77,6 +77,7 @@ struct ChatTestView: View { The [`MessagesView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/messagesview) displays a `Chat` containing multiple `ChatEntity`s with different `ChatEntity/Role`s in a typical chat-like fashion. The `View` automatically scrolls down to the newest message that is added to the passed `Chat` SwiftUI `Binding`. +The `typingIndicator` parameter controls when a typing indicator is shown onscreen for incoming messages to `Chat`. ```swift struct MessagesViewTestView: View { diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index b2fd3da..a618436 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -30,12 +30,12 @@ import SwiftUI /// } /// ``` public struct MessagesView: View { - /// Represents a configuration used in the initializer of `MessagesView` to specify when to display an animation indicating a pending message from a chat participant. + /// Represents a configuration used in the initializer of ``MessagesView`` to specify when to display an animation indicating a pending message from a chat participant. /// - /// `TypingIndicatorDisplayMode` has two possible cases: - /// - `.automatic`: The animation is shown whenever the last message in the chat is from the user, + /// ``TypingIndicatorDisplayMode`` has two possible cases: + /// - ``TypingIndicatorDisplayMode/automatic``: The animation is shown whenever the last message in the chat is from the user, /// and the assistant has not yet begun to respond. - /// - `.manual(shouldDisplay: Bool)`: The animation will be displayed Boolean flag. + /// - ``TypingIndicatorDisplayMode/manual(shouldDisplay:)``: The animation will be displayed based on the provided Boolean flag. public enum TypingIndicatorDisplayMode { case automatic case manual(shouldDisplay: Bool) From 6581794b88050a81f37cc93a3060f74e08e6d840 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 25 Jan 2024 13:14:30 -0800 Subject: [PATCH 18/20] Add new documentation for `ChatView` --- Sources/SpeziChat/ChatView.swift | 5 ++++- Tests/UITests/TestAppUITests/TestAppUITests.swift | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index efec19b..1cf1afc 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -124,16 +124,19 @@ public struct ChatView: View { /// - disableInput: Flag if the input view should be disabled. /// - exportFormat: If specified, enables the export of the ``Chat`` displayed in the ``ChatView`` via a share sheet in various formats defined in ``ChatView/ChatExportFormat``. /// - messagePlaceholder: Placeholder text that should be added in the input field. + /// - messagePendingAnimation: Parameter to control whether a chat bubble animation is shown. public init( _ chat: Binding, disableInput: Bool = false, exportFormat: ChatExportFormat? = nil, - messagePlaceholder: String? = nil + messagePlaceholder: String? = nil, + messagePendingAnimation: MessagesView.TypingIndicatorDisplayMode? = nil ) { self._chat = chat self.disableInput = disableInput self.exportFormat = exportFormat self.messagePlaceholder = messagePlaceholder + self.messagePendingAnimation = messagePendingAnimation } } diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index 109ddf5..5681413 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -33,7 +33,7 @@ class TestAppUITests: XCTestCase { sleep(1) - XCTAssert(app.otherElements["Pending Message Animation"].waitForExistence(timeout: 2)) + XCTAssert(app.otherElements["Typing Indicator"].waitForExistence(timeout: 2)) sleep(4) From c1d333e77cf30c8a547523c947e5789447b079d2 Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Thu, 25 Jan 2024 13:24:07 -0800 Subject: [PATCH 19/20] Rename identifier to Typing Indicator --- Sources/SpeziChat/ChatView.swift | 2 +- Tests/UITests/TestApp/ChatTestView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index 1cf1afc..090516b 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -58,10 +58,10 @@ public struct ChatView: View { var disableInput: Bool let exportFormat: ChatExportFormat? let messagePlaceholder: String? + let messagePendingAnimation: MessagesView.TypingIndicatorDisplayMode? @State var messageInputHeight: CGFloat = 0 @State private var showShareSheet = false - @State private var messagePendingAnimation: MessagesView.TypingIndicatorDisplayMode? public var body: some View { diff --git a/Tests/UITests/TestApp/ChatTestView.swift b/Tests/UITests/TestApp/ChatTestView.swift index 5c355a2..1a54c33 100644 --- a/Tests/UITests/TestApp/ChatTestView.swift +++ b/Tests/UITests/TestApp/ChatTestView.swift @@ -17,7 +17,7 @@ struct ChatTestView: View { var body: some View { - ChatView($chat, exportFormat: .pdf) + ChatView($chat, exportFormat: .pdf, messagePendingAnimation: .automatic) .navigationTitle("SpeziChat") .padding(.top, 16) .onChange(of: chat) { _, newValue in From ebfe6479b70ea3d046a7ac42ad974b88adf5247f Mon Sep 17 00:00:00 2001 From: Matthew Turk Date: Fri, 26 Jan 2024 10:56:07 -0800 Subject: [PATCH 20/20] Adjusting the DocC article --- Sources/SpeziChat/SpeziChat.docc/SpeziChat.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md b/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md index db95e06..c143343 100644 --- a/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md +++ b/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md @@ -59,7 +59,7 @@ These entries are mandatory for apps that utilize microphone and speech recognit ### Chat View -The ``ChatView`` provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. +The ``ChatView`` provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. It accepts an additional `messagePendingAnimation` parameter to control whether a chat bubble animation is shown for a message that is currently being composed. By default, `messagePendingAnimation` has a value of `nil` and does not show. In addition, the ``ChatView`` provides functionality to export the visualized ``Chat`` as a PDF document, JSON representation, or textual UTF-8 file (see ``ChatView/ChatExportFormat``) via a Share Sheet (or Activity View). ```swift @@ -80,6 +80,7 @@ struct ChatTestView: View { The ``MessagesView`` displays a ``Chat`` containing multiple ``ChatEntity``s with different ``ChatEntity/Role``s in a typical chat-like fashion. The `View` automatically scrolls down to the newest message that is added to the passed ``Chat`` SwiftUI `Binding`. +The `typingIndicator` parameter controls when a typing indicator is shown onscreen for incoming messages to `Chat`. ```swift struct MessagesViewTestView: View {