Skip to content
Open
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
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,10 @@ ChatView(messages: viewModel.messages) { draft in
`photoPickerBackend` - choose which photo/video picker is presented when the user taps to attach media:
- `.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.
`inputViewRightButtonType` - choose which action occupies the input field's dedicated right-side button slot when the composer is empty:
- `.camera` (default) - camera button on the right, giphy button stays on the left next to attach (shown only if that action is actually available via `setAvailableInputs`)
- `.giphy` - giphy button on the right, left side shows only attach
- `.none` - no button on the right side

### 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
37 changes: 37 additions & 0 deletions Sources/ExyteChat/Utils/CachedAnimatedImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// 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)
}
}

extension URL {
Comment thread
Shonchik marked this conversation as resolved.
Outdated
var isGIF: Bool {
pathExtension.lowercased() == "gif"
}
}
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
1 change: 1 addition & 0 deletions Sources/ExyteChat/Views/ChatCustomizationParameters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ struct InputViewCustomizationParameters {
var audioRecordingMode: AudioRecordingMode = .holdToRecord
var mediaPickerParameters = MediaPickerParameters()
var photoPickerBackend: PhotoPickerBackend = .custom
var inputViewRightButtonType: InputViewRightButtonType = .camera
}

public typealias MediaPickerParameters = ExyteMediaPicker.MediaPickerCutomizationParameters
Expand Down
1 change: 1 addition & 0 deletions Sources/ExyteChat/Views/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
globalFocusState.focus = nil
}
}
.onChange(of: chatCustomizationParameters.scrollToParams) { scrollToParams in

Check warning on line 145 in Sources/ExyteChat/Views/ChatView.swift

View workflow job for this annotation

GitHub Actions / Build and Test

'onChange(of:perform:)' was deprecated in iOS 17.0: Use `onChange` with a two or zero parameter action closure instead.
self.pendingScrollTo = scrollToParams
}
.sheet(isPresented: $inputViewModel.showGiphyPicker) {
Expand Down Expand Up @@ -394,6 +394,7 @@
recorderSettings: inputViewCustomizationParameters.recorderSettings,
audioRecordingMode: inputViewCustomizationParameters.audioRecordingMode,
photoPickerBackend: inputViewCustomizationParameters.photoPickerBackend,
inputViewRightButtonType: inputViewCustomizationParameters.inputViewRightButtonType,
localization: chatCustomizationParameters.localization
)
} else {
Expand Down
53 changes: 40 additions & 13 deletions Sources/ExyteChat/Views/InputView/InputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ public enum AvailableInputType: Sendable {
case giphy
}

public enum InputViewRightButtonType: Sendable {
case camera
case giphy
case none
}

public struct InputViewAttachments {
var medias: [Media] = []
var recording: Recording?
Expand All @@ -91,6 +97,7 @@ struct InputView: View {
var recorderSettings: RecorderSettings = RecorderSettings()
var audioRecordingMode: AudioRecordingMode = .holdToRecord
var photoPickerBackend: PhotoPickerBackend = .custom
var inputViewRightButtonType: InputViewRightButtonType = .camera
var localization: ChatLocalization

@StateObject var recordingPlayer = RecordingPlayer()
Expand All @@ -102,8 +109,9 @@ struct InputView: View {
private var state: InputViewState {
viewModel.state
}

@State private var overlaySize: CGSize = .zero

@State private var stopRecordButtonSize: CGSize = .zero
@State private var lockRecordButtonSize: CGSize = .zero

@State private var recordButtonFrame: CGRect = .zero
@State private var lockRecordFrame: CGRect = .zero
Expand Down Expand Up @@ -157,7 +165,7 @@ struct InputView: View {
if isMediaAvailable() {
attachButton
}
if isGiphyAvailable() {
if isGiphyAvailable(), effectiveRightButtonType() != .giphy {
giphyButton
}
case .signature:
Expand Down Expand Up @@ -198,8 +206,15 @@ struct InputView: View {
Group {
switch state {
case .empty, .waitingForRecordingPermission:
if case .message = style, isMediaAvailable() {
cameraButton
if case .message = style {
switch effectiveRightButtonType() {
Comment thread
Shonchik marked this conversation as resolved.
Outdated
case .camera:
cameraButton
case .giphy:
giphyButton
case .none:
EmptyView()
}
}
case .isRecordingHold, .isRecordingTap:
recordDurationInProcess
Expand Down Expand Up @@ -269,15 +284,15 @@ struct InputView: View {
}
.compositingGroup()
.overlay(alignment: .top) {
Group {
if state == .isRecordingTap {
stopRecordButton
} else if state == .isRecordingHold {
lockRecordButton
}
if state == .isRecordingTap {
stopRecordButton
.sizeGetter($stopRecordButtonSize)
.offset(y: -stopRecordButtonSize.height - stopRecordButtonOffset)
} else if state == .isRecordingHold {
lockRecordButton
.sizeGetter($lockRecordButtonSize)
.offset(y: -lockRecordButtonSize.height - stopRecordButtonOffset)
}
.sizeGetter($overlaySize)
.offset(y: -overlaySize.height - stopRecordButtonOffset)
}
}
.viewSize(48)
Expand Down Expand Up @@ -646,6 +661,18 @@ struct InputView: View {
private func isMediaAvailable() -> Bool {
return availableInputs.contains(AvailableInputType.media)
}

private func effectiveRightButtonType() -> InputViewRightButtonType {

Comment thread
Shonchik marked this conversation as resolved.
switch inputViewRightButtonType {
case .giphy:
return isGiphyAvailable() ? .giphy : .none
case .camera:
return isMediaAvailable() ? .camera : .none
case .none:
return .none
}
}
}

private struct MediaAttachmentThumbnail: View {
Expand Down
7 changes: 7 additions & 0 deletions Sources/ExyteChat/Views/PublicAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,11 @@ public extension ChatView {
view.inputViewCustomizationParameters.photoPickerBackend = backend
return view
}

/// Which action occupies InputView's dedicated right-side button slot: camera, giphy attachment, or none.
func inputViewRightButtonType(_ type: InputViewRightButtonType) -> ChatView {
Comment thread
Shonchik marked this conversation as resolved.
Outdated
var view = self
view.inputViewCustomizationParameters.inputViewRightButtonType = type
return view
}
}
Loading