Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,10 @@ The basic unit of conversation is the `Message` protocol. You can use your own t
```swift
public protocol Message: Identifiable, Hashable {
var content: String? { get set }
var imageURL: String? { get }
var participant: Participant { get }
var error: Error? { get }

init(content: String?, imageURL: String?, participant: Participant)
init(content: String?, participant: Participant)
}

public enum Participant {
Expand Down
16 changes: 6 additions & 10 deletions Sources/ConversationKit/Model/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,29 @@ public enum Participant {

public protocol Message: Identifiable, Hashable {
var content: String? { get set }
var imageURL: String? { get }
var participant: Participant { get }
var error: Error? { get }

init(content: String?, imageURL: String?, participant: Participant)
init(content: String?, participant: Participant)
}

public struct DefaultMessage: Message {
public let id: UUID = .init()
public var content: String?
public let imageURL: String?
public let participant: Participant
public let error: (any Error)?
public var imageURL: String? = nil

public init(content: String? = nil, imageURL: String? = nil, participant: Participant, error: (any Error)? = nil) {
public init(content: String? = nil, participant: Participant, error: (any Error)? = nil, imageURL: String? = nil) {
self.content = content
self.imageURL = imageURL
self.participant = participant
self.error = error
}

// Protocol-required initializer
public init(content: String?, imageURL: String?, participant: Participant) {
public init(content: String?, participant: Participant) {
self.content = content
self.imageURL = imageURL
self.participant = participant
self.error = nil
}
Expand All @@ -60,16 +58,14 @@ extension DefaultMessage {
public static func == (lhs: DefaultMessage, rhs: DefaultMessage) -> Bool {
lhs.id == rhs.id &&
lhs.content == rhs.content &&
lhs.imageURL == rhs.imageURL &&
lhs.participant == rhs.participant
// intentionally ignore `error`
// intentionally ignore `error` and `imageURL`
}

public func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(content)
hasher.combine(imageURL)
hasher.combine(participant)
// intentionally ignore `error`
// intentionally ignore `error` and `imageURL`
}
}
43 changes: 22 additions & 21 deletions Sources/ConversationKit/Views/ConversationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,11 @@ public struct ConversationView<Content, MessageType: Message>: View where Conten
self._messages = messages
self.message = userPrompt ?? ""
self.content = { message in
MessageView(message: message.content,
imageURL: message.imageURL,
participant: message.participant,
error: message.error)
let imageURL = (message as? DefaultMessage)?.imageURL
return MessageView(message: message.content,
imageURL: imageURL,
participant: message.participant,
error: message.error)
}
}

Expand Down Expand Up @@ -219,7 +220,7 @@ public struct ConversationView<Content, MessageType: Message>: View where Conten

@MainActor
func submit() {
let userMessage = MessageType(content: message, imageURL: nil, participant: .user)
let userMessage = MessageType(content: message, participant: .user)
message = ""
focusedField = .message

Expand All @@ -233,19 +234,19 @@ public struct ConversationView<Content, MessageType: Message>: View where Conten
#Preview("Built-in chat bubbles") {
@Previewable @State var messages: [DefaultMessage] = [
.init(content: "Hello, how are you?",
imageURL: "https://picsum.photos/1080/1920",
participant: .other),
participant: .other,
imageURL: "https://picsum.photos/1080/1920"),
.init(content: "Well, I am fine, how are you?",
imageURL: "https://picsum.photos/100/100",
participant: .user),
participant: .user,
imageURL: "https://picsum.photos/100/100"),
.init(content: "Not too bad. Not too bad after all.",
participant: .other),
.init(imageURL: "https://picsum.photos/100/100",
participant: .user),
.init(participant: .user,
imageURL: "https://picsum.photos/100/100"),
.init(content: "Laborum ea ad anim magna.", participant: .other),
.init(content: "Esse aliquip laboris irure est voluptate aliquip non duis aute eu. Occaecat irure incididunt aute aute do sunt labore nisi esse nostrud amet labore enim mollit occaecat. Occaecat incididunt consectetur sint dolor deserunt exercitation mollit id culpa deserunt fugiat pariatur pariatur ullamco. Ex aliqua sit commodo enim qui commodo aliqua sint dolor laboris magna consequat adipisicing sunt.",
imageURL: "https://picsum.photos/100/100",
participant: .user)
participant: .user,
imageURL: "https://picsum.photos/100/100")
]
NavigationStack {
ConversationView(messages: $messages)
Expand Down Expand Up @@ -274,19 +275,19 @@ public struct ConversationView<Content, MessageType: Message>: View where Conten
#Preview("Custom chat bubbles") {
@Previewable @State var messages: [DefaultMessage] = [
.init(content: "Hello, how are you?",
imageURL: "https://picsum.photos/1080/1920",
participant: .other),
participant: .other,
imageURL: "https://picsum.photos/1080/1920"),
.init(content: "Well, I am fine, how are you?",
imageURL: "https://picsum.photos/100/100",
participant: .user),
participant: .user,
imageURL: "https://picsum.photos/100/100"),
.init(content: "Not too bad. Not too bad after all.",
participant: .other),
.init(imageURL: "https://picsum.photos/100/100",
participant: .user),
.init(participant: .user,
imageURL: "https://picsum.photos/100/100"),
.init(content: "Laborum ea ad anim magna.", participant: .other),
.init(content: "Esse aliquip laboris irure est voluptate aliquip non duis aute eu. Occaecat irure incididunt aute aute do sunt labore nisi esse nostrud amet labore enim mollit occaecat. Occaecat incididunt consectetur sint dolor deserunt exercitation mollit id culpa deserunt fugiat pariatur pariatur ullamco. Ex aliqua sit commodo enim qui commodo aliqua sint dolor laboris magna consequat adipisicing sunt.",
imageURL: "https://picsum.photos/100/100",
participant: .user)
participant: .user,
imageURL: "https://picsum.photos/100/100")
]
NavigationStack {
ConversationView(messages: $messages) { message in
Expand Down
62 changes: 38 additions & 24 deletions Sources/ConversationKit/Views/MessageComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extension EnvironmentValues {
@Entry var onSubmitAction: () -> Void = {}
@Entry var disableAttachments: Bool = false
@Entry var attachmentActions: AnyView = AnyView(EmptyView())
@Entry var attachmentPreview: AnyView = AnyView(EmptyView())
}

extension View {
Expand All @@ -36,13 +37,18 @@ extension View {
public func attachmentActions<Content: View>(@ViewBuilder content: () -> Content) -> some View {
environment(\.attachmentActions, AnyView(content()))
}

public func attachmentPreview<Content: View>(@ViewBuilder content: () -> Content) -> some View {
environment(\.attachmentPreview, AnyView(content()))
}
}

public struct MessageComposerView: View {
@Environment(\.onSubmitAction) private var onSubmitAction
@Environment(\.disableAttachments) private var disableAttachments
@Environment(\.attachmentActions) private var attachmentActions

@Environment(\.attachmentPreview) private var attachmentPreview

@Binding var message: String

public init(message: Binding<String>) {
Expand All @@ -63,18 +69,22 @@ public struct MessageComposerView: View {
.controlSize(.large)
.buttonBorderShape(.circle)
}

VStack {
attachmentPreview

HStack(alignment: .bottom) {
TextField("Enter a message", text: $message, axis: .vertical)
.frame(minHeight: 32)
.padding(EdgeInsets(top: 7, leading: 16, bottom: 7, trailing: 0))
.onSubmit(of: .text) { onSubmitAction() }

Button(action: { onSubmitAction() }) {
Image(systemName: "arrow.up")
HStack(alignment: .bottom) {
TextField("Enter a message", text: $message, axis: .vertical)
.frame(minHeight: 32)
.padding(EdgeInsets(top: 7, leading: 16, bottom: 7, trailing: 0))
.onSubmit(of: .text) { onSubmitAction() }

Button(action: { onSubmitAction() }) {
Image(systemName: "arrow.up")
}
.buttonStyle(.borderedProminent)
.padding(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 7))
}
.buttonStyle(.borderedProminent)
.padding(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 7))
}
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20.0))
.offset(x: -5.0, y: 0.0)
Expand All @@ -101,20 +111,24 @@ public struct MessageComposerView: View {
)
.padding(.trailing, 8)
}

HStack(alignment: .bottom) {
TextField("Enter a message", text: $message, axis: .vertical)
.frame(minHeight: 32)
.padding(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 0))
.onSubmit(of: .text) { onSubmitAction() }

Button(action: { onSubmitAction() }) {
Image(systemName: "arrow.up")

VStack {
attachmentPreview

HStack(alignment: .bottom) {
TextField("Enter a message", text: $message, axis: .vertical)
.frame(minHeight: 32)
.padding(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 0))
.onSubmit(of: .text) { onSubmitAction() }

Button(action: { onSubmitAction() }) {
Image(systemName: "arrow.up")
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.circle)
.controlSize(.regular)
.padding(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 7))
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.circle)
.controlSize(.regular)
.padding(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 7))
}
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 22))
Comment on lines +115 to 134

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant code duplication between this block (for older iOS versions) and the one inside the #if #available(iOS 26.0, *) check (lines 73-90). The layout logic for the VStack containing attachmentPreview and the HStack for the text input is nearly identical.

To improve maintainability, consider refactoring the common view structure into a private @ViewBuilder method or property. This would centralize the layout logic and make future changes easier to apply across both platform-specific branches.

Expand Down