Skip to content

Commit d638677

Browse files
Async voice messages (#415)
1 parent e215f10 commit d638677

File tree

61 files changed

+2335
-39
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+2335
-39
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6+
### ✅ Added
7+
- Recording of async voice messages
8+
- Rendering and playing async voice messages
9+
610
### 🔄 Changed
711

812
# [4.45.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.45.0)

DemoAppSwiftUI/AppDelegate.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
6363
#endif
6464

6565
let utils = Utils(
66-
messageListConfig: MessageListConfig(dateIndicatorPlacement: .messageList)
66+
messageListConfig: MessageListConfig(dateIndicatorPlacement: .messageList),
67+
composerConfig: ComposerConfig(isVoiceRecordingEnabled: true)
6768
)
6869
streamChat = StreamChat(chatClient: chatClient, utils: utils)
6970

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,10 +686,18 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
686686
scrolledId = messages.first?.messageId
687687
}
688688

689+
private func cleanupAudioPlayer() {
690+
utils.audioPlayer.seek(to: 0)
691+
utils.audioPlayer.updateRate(.normal)
692+
utils.audioPlayer.stop()
693+
utils._audioPlayer = nil
694+
}
695+
689696
deinit {
690697
messageCachingUtils.clearCache()
691698
if messageController == nil {
692699
utils.channelControllerFactory.clearCurrentController()
700+
cleanupAudioPlayer()
693701
ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss)
694702
if !channelDataSource.hasLoadedAllNextMessages {
695703
channelDataSource.loadFirstPage { _ in }

Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerConfig.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import SwiftUI
88
/// Config for customizing the composer.
99
public struct ComposerConfig {
1010

11+
public var isVoiceRecordingEnabled: Bool
1112
public var inputViewMinHeight: CGFloat
1213
public var inputViewMaxHeight: CGFloat
1314
public var inputViewCornerRadius: CGFloat
@@ -19,6 +20,7 @@ public struct ComposerConfig {
1920
public var attachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload]
2021

2122
public init(
23+
isVoiceRecordingEnabled: Bool = false,
2224
inputViewMinHeight: CGFloat = 38,
2325
inputViewMaxHeight: CGFloat = 76,
2426
inputViewCornerRadius: CGFloat = 20,
@@ -39,6 +41,7 @@ public struct ComposerConfig {
3941
self.attachmentPayloadConverter = attachmentPayloadConverter
4042
self.gallerySupportedTypes = gallerySupportedTypes
4143
self.inputPaddingsConfig = inputPaddingsConfig
44+
self.isVoiceRecordingEnabled = isVoiceRecordingEnabled
4245
}
4346

4447
public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload] = { message in

Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,17 @@ public struct CustomAttachment: Identifiable, Equatable {
7171
self.content = content
7272
}
7373
}
74+
75+
/// Represents an added voice recording.
76+
public struct AddedVoiceRecording: Identifiable, Equatable {
77+
public var id: String {
78+
url.absoluteString
79+
}
80+
81+
/// The URL of the recording.
82+
public let url: URL
83+
/// The duration of the recording.
84+
public let duration: TimeInterval
85+
/// The waveform of the recording.
86+
public let waveform: [Float]
87+
}

Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
2020
private var channelConfig: ChannelConfig?
2121
@Binding var quotedMessage: ChatMessage?
2222
@Binding var editedMessage: ChatMessage?
23+
24+
private let recordingViewHeight: CGFloat = 80
2325

2426
public init(
2527
viewFactory: Factory,
@@ -81,6 +83,7 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
8183
shouldScroll: viewModel.inputComposerShouldScroll,
8284
removeAttachmentWithId: viewModel.removeAttachment(with:)
8385
)
86+
.environmentObject(viewModel)
8487
.alert(isPresented: $viewModel.attachmentSizeExceeded) {
8588
Alert(
8689
title: Text(L10n.Attachment.MaxSize.title),
@@ -102,11 +105,33 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
102105
onMessageSent()
103106
}
104107
}
108+
.environmentObject(viewModel)
105109
.alert(isPresented: $viewModel.errorShown) {
106110
Alert.defaultErrorAlert
107111
}
108112
}
109113
.padding(.all, 8)
114+
.opacity(viewModel.recordingState.showsComposer ? 1 : 0)
115+
.overlay(
116+
ZStack {
117+
if case let .recording(location) = viewModel.recordingState {
118+
factory.makeComposerRecordingView(
119+
viewModel: viewModel,
120+
gestureLocation: location
121+
)
122+
.frame(height: 60)
123+
} else if viewModel.recordingState == .locked || viewModel.recordingState == .stopped {
124+
factory.makeComposerRecordingLockedView(viewModel: viewModel)
125+
.frame(height: recordingViewHeight)
126+
} else if viewModel.recordingState == .showingTip {
127+
factory.makeComposerRecordingTipView()
128+
.offset(y: -composerHeight + 12)
129+
} else {
130+
EmptyView()
131+
}
132+
}
133+
)
134+
.frame(height: viewModel.recordingState.showsComposer ? nil : recordingViewHeight)
110135

111136
if viewModel.sendInChannelShown {
112137
factory.makeSendInChannelView(
@@ -194,6 +219,8 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
194219
/// View for the composer's input (text and media).
195220
public struct ComposerInputView<Factory: ViewFactory>: View {
196221

222+
@EnvironmentObject var viewModel: MessageComposerViewModel
223+
197224
@Injected(\.colors) private var colors
198225
@Injected(\.fonts) private var fonts
199226
@Injected(\.images) private var images
@@ -292,6 +319,15 @@ public struct ComposerInputView<Factory: ViewFactory>: View {
292319
)
293320
.padding(.trailing, 8)
294321
}
322+
323+
if !viewModel.addedVoiceRecordings.isEmpty {
324+
AddedVoiceRecordingsView(
325+
addedVoiceRecordings: viewModel.addedVoiceRecordings,
326+
onDiscardAttachment: removeAttachmentWithId
327+
)
328+
.padding(.trailing, 8)
329+
.padding(.top, 8)
330+
}
295331

296332
if !addedCustomAttachments.isEmpty {
297333
factory.makeCustomAttachmentPreviewView(
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// Copyright © 2023 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
public struct AudioRecordingInfo: Equatable {
9+
/// The waveform of the recording.
10+
public var waveform: [Float]
11+
/// The duration of the recording.
12+
public var duration: TimeInterval
13+
14+
mutating func update(with entry: Float, duration: TimeInterval) {
15+
waveform.append(entry)
16+
self.duration = duration
17+
}
18+
}
19+
20+
extension AudioRecordingInfo {
21+
static let initial = AudioRecordingInfo(waveform: [], duration: 0)
22+
}
23+
24+
extension MessageComposerViewModel: AudioRecordingDelegate {
25+
public func audioRecorder(
26+
_ audioRecorder: AudioRecording,
27+
didUpdateContext: AudioRecordingContext
28+
) {
29+
audioRecordingInfo.update(
30+
with: didUpdateContext.averagePower,
31+
duration: didUpdateContext.duration
32+
)
33+
}
34+
35+
public func audioRecorder(
36+
_ audioRecorder: AudioRecording,
37+
didFinishRecordingAtURL location: URL
38+
) {
39+
if audioRecordingInfo == .initial { return }
40+
audioAnalysisFactory?.waveformVisualisation(
41+
fromAudioURL: location,
42+
for: waveformTargetSamples,
43+
completionHandler: { [weak self] result in
44+
guard let self else { return }
45+
switch result {
46+
case let .success(waveform):
47+
DispatchQueue.main.async {
48+
let recording = AddedVoiceRecording(
49+
url: location,
50+
duration: self.audioRecordingInfo.duration,
51+
waveform: waveform
52+
)
53+
if self.recordingState == .stopped {
54+
self.pendingAudioRecording = recording
55+
self.audioRecordingInfo.waveform = waveform
56+
} else {
57+
self.addedVoiceRecordings.append(recording)
58+
self.recordingState = .initial
59+
self.audioRecordingInfo = .initial
60+
}
61+
}
62+
case let .failure(error):
63+
log.error(error)
64+
self.recordingState = .initial
65+
}
66+
}
67+
)
68+
}
69+
70+
public func audioRecorder(
71+
_ audioRecorder: AudioRecording,
72+
didFailWithError error: Error
73+
) {
74+
log.error(error)
75+
recordingState = .initial
76+
audioRecordingInfo = .initial
77+
}
78+
}
79+
80+
extension MessageComposerViewModel {
81+
public func startRecording() {
82+
utils.audioSessionFeedbackGenerator.feedbackForBeginRecording()
83+
audioRecorder.beginRecording {
84+
log.debug("started recording")
85+
}
86+
}
87+
88+
public func stopRecording() {
89+
utils.audioSessionFeedbackGenerator.feedbackForStopRecording()
90+
audioRecorder.stopRecording()
91+
}
92+
93+
public func resumeRecording() {
94+
utils.audioSessionFeedbackGenerator.feedbackForBeginRecording()
95+
audioRecorder.resumeRecording()
96+
}
97+
98+
public func pauseRecording() {
99+
utils.audioSessionFeedbackGenerator.feedbackForPause()
100+
audioRecorder.pauseRecording()
101+
}
102+
}
103+
104+
extension MessageComposerViewModel {
105+
public func discardRecording() {
106+
recordingState = .initial
107+
audioRecordingInfo = .initial
108+
stopRecording()
109+
}
110+
111+
public func confirmRecording() {
112+
if recordingState == .stopped {
113+
if let pending = pendingAudioRecording {
114+
addedVoiceRecordings.append(pending)
115+
pendingAudioRecording = nil
116+
audioRecordingInfo = .initial
117+
recordingState = .initial
118+
}
119+
} else {
120+
stopRecording()
121+
}
122+
}
123+
124+
public func previewRecording() {
125+
recordingState = .stopped
126+
stopRecording()
127+
}
128+
}

0 commit comments

Comments
 (0)