Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions ChatExample/ChatExample/Screens/ChatExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ struct ChatExampleView: View {
.setMediaPickerLiveCameraStyle(.prominant)
.setRecorderSettings(recorderSettings)
.messageReactionDelegate(viewModel)
.setAvailableInputs([.text, .media, .giphy, .audio])
.swipeActions(edge: .leading, performsFirstActionWithFullSwipe: true, items: [replyAction])
.navigationBarBackButtonHidden()
.navigationBarTitleDisplayMode(.inline)
Expand Down
7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ let package = Package(
url: "https://github.com/onevcat/Kingfisher",
from: "8.5.0"
),
.package(
url: "https://github.com/exyte/AnchoredPopup.git",
from: "1.1.3"
),
],
targets: [
.target(
Expand All @@ -38,7 +42,8 @@ let package = Package(
.product(name: "ExyteMediaPicker", package: "MediaPicker"),
.product(name: "ActivityIndicatorView", package: "ActivityIndicatorView"),
.product(name: "GiphyUISDK", package: "giphy-ios-sdk"),
.product(name: "Kingfisher", package: "Kingfisher")
.product(name: "Kingfisher", package: "Kingfisher"),
.product(name: "AnchoredPopup", package: "AnchoredPopup")
],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ ChatView(messages: viewModel.messages) { draft in
- `.custom` (default) - ExyteMediaPicker fully customizable built-in media picker
- `.system` - Apple's native `PhotosPicker` for apps that don't need a customized picker UI. Selected items are shown as a removable thumbnail strip above the input field, and camera capture always uses the ExyteMediaPicker regardless of this setting.

The attach button on the left opens a popup menu to choose between Media and GIF when both are available via `setAvailableInputs`. If `photoPickerBackend` is `.system`, a separate Camera entry is also added to the menu, since Apple's native `PhotosPicker` can't capture photos/video itself; with the default `.custom` backend, camera capture is reachable from within the media picker itself, so no separate entry is needed. If there's only one attachment option in total, tapping the button triggers it directly with no popup. The right side of the input field shows a clear ("x") button to quickly clear typed text once the field is non-empty.

### Customize default colors and images
You can use `chatTheme` to customize colors and images of default UI. You can pass all/some colors and images:

Expand Down
14 changes: 14 additions & 0 deletions Sources/ExyteChat/Extensions/URL+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// URL+Extensions.swift
// Chat
//
// Created by Exyte on 23.07.2026.
//

import Foundation

extension URL {
var isGIF: Bool {
pathExtension.lowercased() == "gif"
}
}
13 changes: 11 additions & 2 deletions Sources/ExyteChat/Model/ChatLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@ public struct ChatLocalization: Hashable {
public var waitingForNetwork: String
public var recordingText: String
public var replyToText: String
public var attachMediaText: String
public var attachGifText: String
public var attachCameraText: String

public init(inputPlaceholder: String, signatureText: String, cancelButtonText: String, recentToggleText: String, waitingForNetwork: String, recordingText: String, replyToText: String) {
public init(inputPlaceholder: String, signatureText: String, cancelButtonText: String, recentToggleText: String, waitingForNetwork: String, recordingText: String, replyToText: String, attachMediaText: String = String(localized: "Media"), attachGifText: String = String(localized: "GIF"), attachCameraText: String = String(localized: "Camera")) {
self.inputPlaceholder = inputPlaceholder
self.signatureText = signatureText
self.cancelButtonText = cancelButtonText
self.recentToggleText = recentToggleText
self.waitingForNetwork = waitingForNetwork
self.recordingText = recordingText
self.replyToText = replyToText
self.attachMediaText = attachMediaText
self.attachGifText = attachGifText
self.attachCameraText = attachCameraText
}

public static var defaultLocalization: ChatLocalization {
Expand All @@ -34,7 +40,10 @@ public struct ChatLocalization: Hashable {
recentToggleText: String(localized: "Recents"),
waitingForNetwork: String(localized: "Waiting for network"),
recordingText: String(localized: "Recording..."),
replyToText: String(localized: "Reply to")
replyToText: String(localized: "Reply to"),
attachMediaText: String(localized: "Media"),
attachGifText: String(localized: "GIF"),
attachCameraText: String(localized: "Camera")
)
}
}
5 changes: 4 additions & 1 deletion Sources/ExyteChat/Theme/ChatTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ public struct ChatTheme: Sendable {
public var attach: Image
public var attachCamera: Image
public var microphone: Image
public var clearText: Image
}

public struct FullscreenMedia: Sendable {
Expand Down Expand Up @@ -359,6 +360,7 @@ public struct ChatTheme: Sendable {
attach: Image? = nil,
attachCamera: Image? = nil,
microphone: Image? = nil,
clearText: Image? = nil,
fullscreenPlay: Image? = nil,
fullscreenPause: Image? = nil,
fullscreenMute: Image? = nil,
Expand Down Expand Up @@ -420,7 +422,8 @@ public struct ChatTheme: Sendable {
sticker: sticker ?? Image("sticker", bundle: .current),
attach: attach ?? Image("attach", bundle: .current),
attachCamera: attachCamera ?? Image("attachCamera", bundle: .current),
microphone: microphone ?? Image("microphone", bundle: .current)
microphone: microphone ?? Image("microphone", bundle: .current),
clearText: clearText ?? Image(systemName: "xmark.circle.fill")
)

self.fullscreenMedia = FullscreenMedia(
Expand Down
31 changes: 31 additions & 0 deletions Sources/ExyteChat/Utils/CachedAnimatedImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// CachedAnimatedImage.swift
//

import SwiftUI
import Kingfisher

struct CachedAnimatedImage<Placeholder: View>: View {

let url: URL
let cacheKey: String?
let contentMode: SwiftUI.ContentMode
@ViewBuilder var placeholder: () -> Placeholder

var body: some View {
// `.network(...)` only works for http(s) URLs; local `file://` URLs (e.g. GIFs picked
// from the photo library) need `.provider(LocalFileImageDataProvider)`, which
// `convertToSource` picks automatically based on the URL scheme.
KFAnimatedImage(source: url.convertToSource(overrideCacheKey: cacheKey))
.configure { view in
#if canImport(UIKit)
view.contentMode = contentMode == .fill ? .scaleAspectFill : .scaleAspectFit
view.clipsToBounds = true
#elseif canImport(AppKit)
view.imageScaling = .scaleProportionallyUpOrDown
#endif
}
.cacheOriginalImage()
.placeholder(placeholder)
}
}
53 changes: 39 additions & 14 deletions Sources/ExyteChat/Views/Attachments/AttachmentCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,23 +184,48 @@ struct AsyncImageView: View {
let attachment: Attachment
let size: CGSize

private var animatedURL: (url: URL, cacheKey: String?)? {
if attachment.thumbnail.isGIF {
return (attachment.thumbnail, attachment.thumbnailCacheKey)
} else if attachment.full.isGIF {
return (attachment.full, attachment.fullCacheKey)
}
return nil
}

var body: some View {
CachedAsyncImage(
url: attachment.thumbnail,
cacheKey: attachment.thumbnailCacheKey
) { imageView in
imageView
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} placeholder: {
ZStack {
Rectangle()
.foregroundColor(theme.colors.inputBG)
if let animatedURL {
CachedAnimatedImage(
url: animatedURL.url,
cacheKey: animatedURL.cacheKey,
contentMode: .fill
) {
placeholder
}
.frame(width: size.width, height: size.height)
.clipped()
} else {
CachedAsyncImage(
url: attachment.thumbnail,
cacheKey: attachment.thumbnailCacheKey
) { imageView in
imageView
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
ActivityIndicator(size: 30, showBackground: false)
.clipped()
} placeholder: {
placeholder
}
}
}

private var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(theme.colors.inputBG)
.frame(width: size.width, height: size.height)
ActivityIndicator(size: 30, showBackground: false)
}
}
}
30 changes: 20 additions & 10 deletions Sources/ExyteChat/Views/Attachments/AttachmentsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,28 @@ struct AttachmentsPage: View {
var body: some View {
if attachment.type == .image {
ZoomableContainer {
CachedAsyncImage(
url: attachment.full,
cacheKey: attachment.fullCacheKey
) { phase in
switch phase {
case let .success(image):
image
.resizable()
.aspectRatio(contentMode: .fit)
default:
if attachment.full.isGIF {
CachedAnimatedImage(
url: attachment.full,
cacheKey: attachment.fullCacheKey,
contentMode: .fit
) {
ActivityIndicator()
}
} else {
CachedAsyncImage(
url: attachment.full,
cacheKey: attachment.fullCacheKey
) { phase in
switch phase {
case let .success(image):
image
.resizable()
.aspectRatio(contentMode: .fit)
default:
ActivityIndicator()
}
}
}
}
} else if attachment.type == .video {
Expand Down
Loading
Loading