@sendbird/uikit-react-native 3.11.0 → 3.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +7 -5
  2. package/lib/commonjs/domain/groupChannel/component/GroupChannelMessageList.js +0 -3
  3. package/lib/commonjs/domain/groupChannel/component/GroupChannelMessageList.js.map +1 -1
  4. package/lib/commonjs/platform/createMediaService.expo.js +83 -12
  5. package/lib/commonjs/platform/createMediaService.expo.js.map +1 -1
  6. package/lib/commonjs/platform/createNotificationService.native.js +31 -6
  7. package/lib/commonjs/platform/createNotificationService.native.js.map +1 -1
  8. package/lib/commonjs/platform/createPlayerService.expo.js +214 -113
  9. package/lib/commonjs/platform/createPlayerService.expo.js.map +1 -1
  10. package/lib/commonjs/platform/createRecorderService.expo.js +248 -130
  11. package/lib/commonjs/platform/createRecorderService.expo.js.map +1 -1
  12. package/lib/commonjs/utils/expoBackwardUtils.js +23 -0
  13. package/lib/commonjs/utils/expoBackwardUtils.js.map +1 -1
  14. package/lib/commonjs/utils/expoPermissionGranted.js.map +1 -1
  15. package/lib/commonjs/version.js +1 -1
  16. package/lib/commonjs/version.js.map +1 -1
  17. package/lib/module/domain/groupChannel/component/GroupChannelMessageList.js +0 -3
  18. package/lib/module/domain/groupChannel/component/GroupChannelMessageList.js.map +1 -1
  19. package/lib/module/platform/createMediaService.expo.js +82 -13
  20. package/lib/module/platform/createMediaService.expo.js.map +1 -1
  21. package/lib/module/platform/createNotificationService.native.js +32 -6
  22. package/lib/module/platform/createNotificationService.native.js.map +1 -1
  23. package/lib/module/platform/createPlayerService.expo.js +214 -113
  24. package/lib/module/platform/createPlayerService.expo.js.map +1 -1
  25. package/lib/module/platform/createRecorderService.expo.js +249 -131
  26. package/lib/module/platform/createRecorderService.expo.js.map +1 -1
  27. package/lib/module/utils/expoBackwardUtils.js +23 -0
  28. package/lib/module/utils/expoBackwardUtils.js.map +1 -1
  29. package/lib/module/utils/expoPermissionGranted.js.map +1 -1
  30. package/lib/module/version.js +1 -1
  31. package/lib/module/version.js.map +1 -1
  32. package/lib/typescript/src/containers/SendbirdUIKitContainer.d.ts +1 -1
  33. package/lib/typescript/src/platform/createMediaService.expo.d.ts +2 -2
  34. package/lib/typescript/src/platform/createNotificationService.native.d.ts +13 -1
  35. package/lib/typescript/src/platform/createPlayerService.expo.d.ts +2 -2
  36. package/lib/typescript/src/platform/createRecorderService.expo.d.ts +2 -2
  37. package/lib/typescript/src/utils/expoBackwardUtils.d.ts +10 -0
  38. package/lib/typescript/src/utils/expoPermissionGranted.d.ts +1 -1
  39. package/lib/typescript/src/version.d.ts +1 -1
  40. package/package.json +16 -5
  41. package/src/domain/groupChannel/component/GroupChannelMessageList.tsx +0 -3
  42. package/src/platform/createMediaService.expo.tsx +87 -9
  43. package/src/platform/createNotificationService.native.ts +53 -7
  44. package/src/platform/createPlayerService.expo.tsx +242 -109
  45. package/src/platform/createRecorderService.expo.tsx +267 -110
  46. package/src/utils/expoBackwardUtils.ts +29 -0
  47. package/src/utils/expoPermissionGranted.ts +3 -1
  48. package/src/version.ts +1 -1
@@ -1,47 +1,102 @@
1
- import * as ExpoAV from 'expo-av';
2
- import type { RecordingOptions } from 'expo-av/build/Audio/Recording.types';
1
+ import type * as ExpoAudio from 'expo-audio';
2
+ import type * as ExpoAV from 'expo-av';
3
3
  import { Platform } from 'react-native';
4
4
 
5
- import { matchesOneOf, sleep } from '@sendbird/uikit-utils';
5
+ import { Logger, matchesOneOf, sleep } from '@sendbird/uikit-utils';
6
6
 
7
7
  import VoiceMessageConfig from '../libs/VoiceMessageConfig';
8
+ import expoBackwardUtils from '../utils/expoBackwardUtils';
9
+ import type { ExpoAudioModule } from '../utils/expoBackwardUtils';
8
10
  import expoPermissionGranted from '../utils/expoPermissionGranted';
9
11
  import type { RecorderServiceInterface, Unsubscribe } from './types';
10
12
 
11
13
  type RecordingListener = Parameters<RecorderServiceInterface['addRecordingListener']>[number];
12
14
  type StateListener = Parameters<RecorderServiceInterface['addStateListener']>[number];
13
15
  type Modules = {
14
- avModule: typeof ExpoAV;
16
+ avModule: ExpoAudioModule;
15
17
  };
16
- const createExpoRecorderService = ({ avModule }: Modules): RecorderServiceInterface => {
17
- class VoiceRecorder implements RecorderServiceInterface {
18
- public uri: RecorderServiceInterface['uri'] = undefined;
19
- public state: RecorderServiceInterface['state'] = 'idle';
20
- public options: RecorderServiceInterface['options'] = {
21
- minDuration: VoiceMessageConfig.DEFAULT.RECORDER.MIN_DURATION,
22
- maxDuration: VoiceMessageConfig.DEFAULT.RECORDER.MAX_DURATION,
23
- extension: VoiceMessageConfig.DEFAULT.RECORDER.EXTENSION,
24
- };
25
18
 
26
- // NOTE: In Android, even when startRecorder() is awaited, if stop() is executed immediately afterward, an error occurs
27
- private _recordStartedAt = 0;
28
- private _getRecorderStopSafeBuffer = () => {
29
- const minWaitingTime = 500;
30
- const elapsedTime = Date.now() - this._recordStartedAt;
31
- if (elapsedTime > minWaitingTime) return 0;
32
- else return minWaitingTime - elapsedTime;
19
+ interface AudioRecorderAdapter {
20
+ requestPermission(): Promise<boolean>;
21
+ record(): Promise<void>;
22
+ stop(): Promise<void>;
23
+ reset(): Promise<void>;
24
+ addRecordingListener(callback: RecordingListener): Unsubscribe;
25
+ addStateListener(callback: StateListener): Unsubscribe;
26
+ convertRecordPath(uri: string): string;
27
+ readonly state: RecorderServiceInterface['state'];
28
+ readonly options: RecorderServiceInterface['options'];
29
+ uri?: string;
30
+ }
31
+
32
+ abstract class BaseAudioRecorderAdapter implements AudioRecorderAdapter {
33
+ public uri: RecorderServiceInterface['uri'] = undefined;
34
+ public state: RecorderServiceInterface['state'] = 'idle';
35
+ public options: RecorderServiceInterface['options'] = {
36
+ minDuration: VoiceMessageConfig.DEFAULT.RECORDER.MIN_DURATION,
37
+ maxDuration: VoiceMessageConfig.DEFAULT.RECORDER.MAX_DURATION,
38
+ extension: VoiceMessageConfig.DEFAULT.RECORDER.EXTENSION,
39
+ };
40
+
41
+ protected readonly _audioSettings = {
42
+ sampleRate: VoiceMessageConfig.DEFAULT.RECORDER.SAMPLE_RATE,
43
+ bitRate: VoiceMessageConfig.DEFAULT.RECORDER.BIT_RATE,
44
+ numberOfChannels: VoiceMessageConfig.DEFAULT.RECORDER.CHANNELS,
45
+ // encoding: mpeg4_aac
46
+ };
47
+ protected readonly _recordingSubscribers = new Set<RecordingListener>();
48
+ protected readonly _stateSubscribers = new Set<StateListener>();
49
+
50
+ // NOTE: In Android, even when startRecorder() is awaited, if stop() is executed immediately afterward, an error occurs
51
+ protected _recordStartedAt = 0;
52
+ protected _getRecorderStopSafeBuffer = () => {
53
+ const minWaitingTime = 500;
54
+ const elapsedTime = Date.now() - this._recordStartedAt;
55
+ if (elapsedTime > minWaitingTime) return 0;
56
+ else return minWaitingTime - elapsedTime;
57
+ };
58
+ protected setState = (state: RecorderServiceInterface['state']) => {
59
+ this.state = state;
60
+ this._stateSubscribers.forEach((callback) => {
61
+ callback(state);
62
+ });
63
+ };
64
+
65
+ public addRecordingListener = (callback: RecordingListener): Unsubscribe => {
66
+ this._recordingSubscribers.add(callback);
67
+ return () => {
68
+ this._recordingSubscribers.delete(callback);
33
69
  };
70
+ };
34
71
 
35
- private _recorder = new avModule.Audio.Recording();
36
- private readonly _recordingSubscribers = new Set<RecordingListener>();
37
- private readonly _stateSubscribers = new Set<StateListener>();
38
- private readonly _audioSettings = {
39
- sampleRate: VoiceMessageConfig.DEFAULT.RECORDER.SAMPLE_RATE,
40
- bitRate: VoiceMessageConfig.DEFAULT.RECORDER.BIT_RATE,
41
- numberOfChannels: VoiceMessageConfig.DEFAULT.RECORDER.CHANNELS,
42
- // encoding: mpeg4_aac
72
+ public addStateListener = (callback: StateListener): Unsubscribe => {
73
+ this._stateSubscribers.add(callback);
74
+ return () => {
75
+ this._stateSubscribers.delete(callback);
43
76
  };
44
- private readonly _audioOptions: RecordingOptions = {
77
+ };
78
+
79
+ public convertRecordPath = (uri: string): string => {
80
+ return uri;
81
+ };
82
+
83
+ abstract requestPermission(): Promise<boolean>;
84
+ abstract record(): Promise<void>;
85
+ abstract stop(): Promise<void>;
86
+ abstract reset(): Promise<void>;
87
+ }
88
+
89
+ class LegacyExpoAVRecorderAdapter extends BaseAudioRecorderAdapter {
90
+ private readonly avModule: typeof ExpoAV;
91
+
92
+ private _recorder: ExpoAV.Audio.Recording;
93
+ private readonly _audioOptions: ExpoAV.Audio.RecordingOptions;
94
+
95
+ constructor(avModule: typeof ExpoAV) {
96
+ super();
97
+ this.avModule = avModule;
98
+ this._recorder = new avModule.Audio.Recording();
99
+ this._audioOptions = {
45
100
  android: {
46
101
  ...this._audioSettings,
47
102
  extension: `.${this.options.extension}`,
@@ -56,109 +111,211 @@ const createExpoRecorderService = ({ avModule }: Modules): RecorderServiceInterf
56
111
  },
57
112
  web: {},
58
113
  };
114
+ }
59
115
 
60
- private prepare = async () => {
61
- this.setState('preparing');
62
- if (Platform.OS === 'ios') {
63
- await avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
64
- }
116
+ private prepare = async () => {
117
+ this.setState('preparing');
118
+ if (Platform.OS === 'ios') {
119
+ await this.avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
120
+ }
65
121
 
66
- if (this._recorder._isDoneRecording) {
67
- this._recorder = new avModule.Audio.Recording();
122
+ if (this._recorder._isDoneRecording) {
123
+ this._recorder = new this.avModule.Audio.Recording();
124
+ }
125
+ this._recorder.setProgressUpdateInterval(100);
126
+ this._recorder.setOnRecordingStatusUpdate((status) => {
127
+ const completed = status.durationMillis >= this.options.maxDuration;
128
+ if (completed) this.stop();
129
+ if (status.isRecording) {
130
+ this._recordingSubscribers.forEach((callback) => {
131
+ callback({ currentTime: status.durationMillis, completed: completed });
132
+ });
68
133
  }
69
- this._recorder.setProgressUpdateInterval(100);
70
- this._recorder.setOnRecordingStatusUpdate((status) => {
71
- const completed = status.durationMillis >= this.options.maxDuration;
72
- if (completed) this.stop();
73
- if (status.isRecording) {
74
- this._recordingSubscribers.forEach((callback) => {
75
- callback({ currentTime: status.durationMillis, completed: completed });
76
- });
134
+ });
135
+ await this._recorder.prepareToRecordAsync(this._audioOptions);
136
+ };
137
+
138
+ public requestPermission = async (): Promise<boolean> => {
139
+ const status = await this.avModule.Audio.getPermissionsAsync();
140
+ if (expoPermissionGranted([status])) {
141
+ return true;
142
+ } else {
143
+ const status = await this.avModule.Audio.requestPermissionsAsync();
144
+ return expoPermissionGranted([status]);
145
+ }
146
+ };
147
+
148
+ public record = async (): Promise<void> => {
149
+ if (matchesOneOf(this.state, ['idle', 'completed'])) {
150
+ try {
151
+ await this.prepare();
152
+ await this._recorder.startAsync();
153
+
154
+ if (Platform.OS === 'android') {
155
+ this._recordStartedAt = Date.now();
77
156
  }
78
- });
79
- await this._recorder.prepareToRecordAsync(this._audioOptions);
80
- };
81
157
 
82
- private setState = (state: RecorderServiceInterface['state']) => {
83
- this.state = state;
84
- this._stateSubscribers.forEach((callback) => {
85
- callback(state);
86
- });
87
- };
158
+ const uri = this._recorder.getURI();
159
+ if (uri) this.uri = uri;
160
+ this.setState('recording');
161
+ } catch (e) {
162
+ this.setState('idle');
163
+ throw e;
164
+ }
165
+ }
166
+ };
88
167
 
89
- public requestPermission = async (): Promise<boolean> => {
90
- const status = await avModule.Audio.getPermissionsAsync();
91
- if (expoPermissionGranted([status])) {
92
- return true;
93
- } else {
94
- const status = await avModule.Audio.requestPermissionsAsync();
95
- return expoPermissionGranted([status]);
168
+ public stop = async (): Promise<void> => {
169
+ if (matchesOneOf(this.state, ['recording'])) {
170
+ if (Platform.OS === 'android') {
171
+ const buffer = this._getRecorderStopSafeBuffer();
172
+ if (buffer > 0) await sleep(buffer);
96
173
  }
97
- };
98
174
 
99
- public addRecordingListener = (callback: RecordingListener): Unsubscribe => {
100
- this._recordingSubscribers.add(callback);
101
- return () => {
102
- this._recordingSubscribers.delete(callback);
103
- };
104
- };
175
+ await this._recorder.stopAndUnloadAsync();
176
+ if (Platform.OS === 'ios') {
177
+ await this.avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: false });
178
+ }
179
+ this.setState('completed');
180
+ }
181
+ };
105
182
 
106
- public addStateListener = (callback: StateListener): Unsubscribe => {
107
- this._stateSubscribers.add(callback);
108
- return () => {
109
- this._stateSubscribers.delete(callback);
110
- };
111
- };
183
+ public reset = async (): Promise<void> => {
184
+ await this.stop();
185
+ this.uri = undefined;
186
+ this._recordingSubscribers.clear();
187
+ this._recorder = new this.avModule.Audio.Recording();
188
+ this.setState('idle');
189
+ };
190
+ }
191
+
192
+ class ExpoAudioRecorderAdapter extends BaseAudioRecorderAdapter {
193
+ private readonly audioModule: typeof ExpoAudio;
194
+ private recorder: ExpoAudio.AudioRecorder | null = null;
195
+ private recordingUpdateInterval: NodeJS.Timeout | null = null;
196
+
197
+ constructor(audioModule: typeof ExpoAudio) {
198
+ super();
199
+ this.audioModule = audioModule;
200
+ }
201
+
202
+ private setListener = () => {
203
+ if (!this.recorder) return;
204
+
205
+ this.recordingUpdateInterval = setInterval(() => {
206
+ if (this.recorder && this.recorder.isRecording) {
207
+ const currentTime = this.recorder.currentTime * 1000;
208
+ const completed = currentTime >= this.options.maxDuration;
112
209
 
113
- public record = async (): Promise<void> => {
114
- if (matchesOneOf(this.state, ['idle', 'completed'])) {
115
- try {
116
- await this.prepare();
117
- await this._recorder.startAsync();
118
-
119
- if (Platform.OS === 'android') {
120
- this._recordStartedAt = Date.now();
121
- }
122
-
123
- const uri = this._recorder.getURI();
124
- if (uri) this.uri = uri;
125
- this.setState('recording');
126
- } catch (e) {
127
- this.setState('idle');
128
- throw e;
210
+ if (completed) {
211
+ this.stop().catch((error) => {
212
+ Logger.warn('[RecorderService.Expo] Failed to stop in update interval', error);
213
+ });
129
214
  }
215
+
216
+ this._recordingSubscribers.forEach((callback) => {
217
+ callback({ currentTime, completed });
218
+ });
130
219
  }
220
+ }, 100);
221
+ };
222
+
223
+ private removeListener = () => {
224
+ if (this.recordingUpdateInterval) {
225
+ clearInterval(this.recordingUpdateInterval);
226
+ this.recordingUpdateInterval = null;
227
+ }
228
+ };
229
+
230
+ private prepare = async () => {
231
+ this.setState('preparing');
232
+ if (Platform.OS === 'ios') {
233
+ await this.audioModule.setAudioModeAsync({
234
+ allowsRecording: true,
235
+ playsInSilentMode: true,
236
+ });
237
+ }
238
+
239
+ const recordingOptions = {
240
+ ...this._audioSettings,
241
+ extension: `.${this.options.extension}`,
131
242
  };
132
243
 
133
- public stop = async (): Promise<void> => {
134
- if (matchesOneOf(this.state, ['recording'])) {
244
+ this.recorder = new this.audioModule.AudioModule.AudioRecorder(recordingOptions);
245
+ await this.recorder.prepareToRecordAsync();
246
+ };
247
+
248
+ public requestPermission = async (): Promise<boolean> => {
249
+ const status = await this.audioModule.getRecordingPermissionsAsync();
250
+ if (expoPermissionGranted([status])) {
251
+ return true;
252
+ } else {
253
+ const status = await this.audioModule.requestRecordingPermissionsAsync();
254
+ return expoPermissionGranted([status]);
255
+ }
256
+ };
257
+
258
+ public record = async (): Promise<void> => {
259
+ if (matchesOneOf(this.state, ['idle', 'completed'])) {
260
+ try {
261
+ await this.prepare();
262
+ this.setListener();
263
+ this.recorder?.record();
264
+
135
265
  if (Platform.OS === 'android') {
136
- const buffer = this._getRecorderStopSafeBuffer();
137
- if (buffer > 0) await sleep(buffer);
266
+ this._recordStartedAt = Date.now();
138
267
  }
139
268
 
140
- await this._recorder.stopAndUnloadAsync();
141
- if (Platform.OS === 'ios') {
142
- await avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: false });
143
- }
144
- this.setState('completed');
269
+ const uri = this.recorder?.uri;
270
+ if (uri) this.uri = uri;
271
+ this.setState('recording');
272
+ } catch (e) {
273
+ this.setState('idle');
274
+ this.removeListener();
275
+ throw e;
145
276
  }
146
- };
277
+ }
278
+ };
147
279
 
148
- public reset = async (): Promise<void> => {
149
- await this.stop();
150
- this.uri = undefined;
151
- this._recordingSubscribers.clear();
152
- this._recorder = new avModule.Audio.Recording();
153
- this.setState('idle');
154
- };
280
+ public stop = async (): Promise<void> => {
281
+ if (matchesOneOf(this.state, ['recording'])) {
282
+ if (Platform.OS === 'android') {
283
+ const buffer = this._getRecorderStopSafeBuffer();
284
+ if (buffer > 0) await sleep(buffer);
285
+ }
155
286
 
156
- public convertRecordPath = (uri: string): string => {
157
- return uri;
158
- };
287
+ await this.recorder?.stop();
288
+ this.removeListener();
289
+ if (Platform.OS === 'ios') {
290
+ await this.audioModule.setAudioModeAsync({
291
+ allowsRecording: false,
292
+ playsInSilentMode: false,
293
+ });
294
+ }
295
+ this.setState('completed');
296
+ }
297
+ };
298
+
299
+ public reset = async (): Promise<void> => {
300
+ await this.stop();
301
+ this.recorder = null;
302
+ this.uri = undefined;
303
+ this._recordingSubscribers.clear();
304
+ this._stateSubscribers.clear();
305
+ this.setState('idle');
306
+ };
307
+ }
308
+
309
+ const createExpoRecorderService = ({ avModule }: Modules): RecorderServiceInterface => {
310
+ if (expoBackwardUtils.expoAV.isLegacyAVModule(avModule)) {
311
+ Logger.warn(
312
+ '[RecorderService.Expo] expo-av is deprecated and will be removed in Expo 54. Please migrate to expo-audio.',
313
+ );
159
314
  }
160
315
 
161
- return new VoiceRecorder();
316
+ return expoBackwardUtils.expoAV.isAudioModule(avModule)
317
+ ? new ExpoAudioRecorderAdapter(avModule)
318
+ : new LegacyExpoAVRecorderAdapter(avModule);
162
319
  };
163
320
 
164
321
  export default createExpoRecorderService;
@@ -1,6 +1,9 @@
1
+ import type * as ExpoAudio from 'expo-audio';
2
+ import type * as ExpoAV from 'expo-av';
1
3
  import type * as ExpoDocumentPicker from 'expo-document-picker';
2
4
  import type * as ExpoFs from 'expo-file-system';
3
5
  import type * as ExpoImagePicker from 'expo-image-picker';
6
+ import type * as ExpoVideo from 'expo-video';
4
7
 
5
8
  import type { FilePickerResponse } from '../platform/types';
6
9
  import normalizeFile from './normalizeFile';
@@ -55,6 +58,29 @@ const expoBackwardUtils = {
55
58
  }
56
59
  },
57
60
  },
61
+ expoAV: {
62
+ isLegacyAVModule(module: ExpoAudioModule | ExpoVideoModule): module is typeof ExpoAV {
63
+ try {
64
+ return 'Video' in module && 'Audio' in module && typeof module.Video === 'function';
65
+ } catch {
66
+ return false;
67
+ }
68
+ },
69
+ isAudioModule(module: ExpoAudioModule): module is typeof ExpoAudio {
70
+ try {
71
+ return 'useAudioRecorder' in module && typeof module.useAudioRecorder === 'function';
72
+ } catch {
73
+ return false;
74
+ }
75
+ },
76
+ isVideoModule(module: ExpoVideoModule): module is typeof ExpoVideo {
77
+ try {
78
+ return 'VideoView' in module && 'useVideoPlayer' in module && typeof module.useVideoPlayer === 'function';
79
+ } catch {
80
+ return false;
81
+ }
82
+ },
83
+ },
58
84
  toFileSize(info: ExpoFs.FileInfo) {
59
85
  if ('size' in info) {
60
86
  return info.size;
@@ -64,4 +90,7 @@ const expoBackwardUtils = {
64
90
  },
65
91
  };
66
92
 
93
+ export type ExpoAudioModule = typeof ExpoAV | typeof ExpoAudio;
94
+ export type ExpoVideoModule = typeof ExpoAV | typeof ExpoVideo;
95
+
67
96
  export default expoBackwardUtils;
@@ -9,7 +9,9 @@ export interface ExpoPermissionResponse {
9
9
  export interface ExpoMediaLibraryPermissionResponse extends ExpoPermissionResponse {
10
10
  accessPrivileges?: 'all' | 'limited' | 'none';
11
11
  }
12
- export interface ExpoPushPermissionResponse extends ExpoPermissionResponse, NotificationPermissionsStatus {}
12
+ export interface ExpoPushPermissionResponse
13
+ extends Omit<ExpoPermissionResponse, 'status'>,
14
+ NotificationPermissionsStatus {}
13
15
 
14
16
  const expoPermissionGranted = (
15
17
  stats: Array<ExpoMediaLibraryPermissionResponse | ExpoPushPermissionResponse | ExpoPermissionResponse>,
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
- const VERSION = '3.11.0';
1
+ const VERSION = '3.11.2';
2
2
  export default VERSION;