@ion299/sdk-react-native 0.1.0-beta.4 → 0.1.0-beta.6

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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/android/src/main/java/com/chatplatform/sdk/ChatSdkAudioPlayerModule.kt +201 -0
  3. package/android/src/main/java/com/chatplatform/sdk/ChatSdkPackage.kt +1 -0
  4. package/ios/ChatSdkAudioPlayer.m +25 -0
  5. package/ios/ChatSdkAudioPlayer.swift +193 -0
  6. package/lib/commonjs/audio/audioController.js +145 -0
  7. package/lib/commonjs/audio/audioController.js.map +1 -0
  8. package/lib/commonjs/components/AudioMessage.js +198 -0
  9. package/lib/commonjs/components/AudioMessage.js.map +1 -0
  10. package/lib/commonjs/components/MessageBubble.js +9 -0
  11. package/lib/commonjs/components/MessageBubble.js.map +1 -1
  12. package/lib/commonjs/components/MessageInput.js +9 -13
  13. package/lib/commonjs/components/MessageInput.js.map +1 -1
  14. package/lib/commonjs/native/NativeChatSdkAudioPlayer.js +28 -0
  15. package/lib/commonjs/native/NativeChatSdkAudioPlayer.js.map +1 -0
  16. package/lib/module/audio/audioController.js +140 -0
  17. package/lib/module/audio/audioController.js.map +1 -0
  18. package/lib/module/components/AudioMessage.js +193 -0
  19. package/lib/module/components/AudioMessage.js.map +1 -0
  20. package/lib/module/components/MessageBubble.js +9 -0
  21. package/lib/module/components/MessageBubble.js.map +1 -1
  22. package/lib/module/components/MessageInput.js +9 -13
  23. package/lib/module/components/MessageInput.js.map +1 -1
  24. package/lib/module/native/NativeChatSdkAudioPlayer.js +23 -0
  25. package/lib/module/native/NativeChatSdkAudioPlayer.js.map +1 -0
  26. package/lib/typescript/commonjs/audio/audioController.d.ts +25 -0
  27. package/lib/typescript/commonjs/audio/audioController.d.ts.map +1 -0
  28. package/lib/typescript/commonjs/components/AudioMessage.d.ts +11 -0
  29. package/lib/typescript/commonjs/components/AudioMessage.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/components/MessageBubble.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/components/MessageInput.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/native/NativeChatSdkAudioPlayer.d.ts +20 -0
  33. package/lib/typescript/commonjs/native/NativeChatSdkAudioPlayer.d.ts.map +1 -0
  34. package/lib/typescript/module/audio/audioController.d.ts +25 -0
  35. package/lib/typescript/module/audio/audioController.d.ts.map +1 -0
  36. package/lib/typescript/module/components/AudioMessage.d.ts +11 -0
  37. package/lib/typescript/module/components/AudioMessage.d.ts.map +1 -0
  38. package/lib/typescript/module/components/MessageBubble.d.ts.map +1 -1
  39. package/lib/typescript/module/components/MessageInput.d.ts.map +1 -1
  40. package/lib/typescript/module/native/NativeChatSdkAudioPlayer.d.ts +20 -0
  41. package/lib/typescript/module/native/NativeChatSdkAudioPlayer.d.ts.map +1 -0
  42. package/package.json +2 -4
  43. package/src/audio/audioController.ts +144 -0
  44. package/src/components/AudioMessage.tsx +198 -0
  45. package/src/components/MessageBubble.tsx +6 -0
  46. package/src/components/MessageInput.tsx +5 -13
  47. package/src/native/NativeChatSdkAudioPlayer.ts +54 -0
@@ -0,0 +1,144 @@
1
+ import AudioModule, {
2
+ onAudioState,
3
+ type AudioStateEvent,
4
+ } from '../native/NativeChatSdkAudioPlayer'
5
+
6
+ export interface AudioPlaybackState {
7
+ state: 'idle' | 'loading' | 'playing' | 'paused' | 'error'
8
+ positionMillis: number
9
+ durationMillis: number
10
+ }
11
+
12
+ type Listener = (state: AudioPlaybackState) => void
13
+
14
+ const IDLE: AudioPlaybackState = { state: 'idle', positionMillis: 0, durationMillis: 0 }
15
+
16
+ class AudioController {
17
+ private listeners = new Map<string, Set<Listener>>()
18
+ private states = new Map<string, AudioPlaybackState>()
19
+ private activeKey: string | null = null
20
+ private subscribed = false
21
+
22
+ get isAvailable(): boolean {
23
+ return !!AudioModule
24
+ }
25
+
26
+ private ensureSubscribed() {
27
+ if (this.subscribed || !AudioModule) return
28
+ this.subscribed = true
29
+ onAudioState((event) => this.handleEvent(event))
30
+ }
31
+
32
+ private handleEvent(event: AudioStateEvent) {
33
+ const prev = this.getState(event.key)
34
+ let next: AudioPlaybackState
35
+
36
+ switch (event.state) {
37
+ case 'loading':
38
+ next = { state: 'loading', positionMillis: 0, durationMillis: event.durationMillis }
39
+ break
40
+ case 'playing':
41
+ next = {
42
+ state: 'playing',
43
+ positionMillis: event.positionMillis,
44
+ durationMillis: event.durationMillis || prev.durationMillis,
45
+ }
46
+ break
47
+ case 'paused':
48
+ next = {
49
+ state: 'paused',
50
+ positionMillis: event.positionMillis,
51
+ durationMillis: event.durationMillis || prev.durationMillis,
52
+ }
53
+ break
54
+ case 'ended':
55
+ case 'stopped':
56
+ next = { state: 'idle', positionMillis: 0, durationMillis: event.durationMillis || prev.durationMillis }
57
+ if (this.activeKey === event.key) this.activeKey = null
58
+ break
59
+ case 'error':
60
+ default:
61
+ next = { state: 'error', positionMillis: 0, durationMillis: 0 }
62
+ if (this.activeKey === event.key) this.activeKey = null
63
+ break
64
+ }
65
+
66
+ this.setState(event.key, next)
67
+ }
68
+
69
+ private setState(key: string, state: AudioPlaybackState) {
70
+ this.states.set(key, state)
71
+ this.listeners.get(key)?.forEach((listener) => listener(state))
72
+ }
73
+
74
+ getState(key: string): AudioPlaybackState {
75
+ return this.states.get(key) ?? IDLE
76
+ }
77
+
78
+ subscribe(key: string, listener: Listener): () => void {
79
+ this.ensureSubscribed()
80
+ let set = this.listeners.get(key)
81
+ if (!set) {
82
+ set = new Set()
83
+ this.listeners.set(key, set)
84
+ }
85
+ set.add(listener)
86
+ listener(this.getState(key))
87
+ return () => {
88
+ set!.delete(listener)
89
+ if (set!.size === 0) this.listeners.delete(key)
90
+ }
91
+ }
92
+
93
+ async play(key: string, url: string, headers: Record<string, string> = {}): Promise<void> {
94
+ if (!AudioModule) return
95
+ this.ensureSubscribed()
96
+
97
+ if (this.activeKey && this.activeKey !== key) {
98
+ const prevKey = this.activeKey
99
+ const prev = this.getState(prevKey)
100
+ this.setState(prevKey, { state: 'idle', positionMillis: 0, durationMillis: prev.durationMillis })
101
+ }
102
+ this.activeKey = key
103
+
104
+ const current = this.getState(key)
105
+ if (current.state === 'idle' || current.state === 'error') {
106
+ this.setState(key, { ...current, state: 'loading', positionMillis: 0 })
107
+ }
108
+
109
+ try {
110
+ await AudioModule.play(key, url, headers)
111
+ } catch {
112
+ this.setState(key, { state: 'error', positionMillis: 0, durationMillis: 0 })
113
+ }
114
+ }
115
+
116
+ async pause(key: string): Promise<void> {
117
+ if (!AudioModule) return
118
+ try {
119
+ await AudioModule.pause(key)
120
+ } catch {
121
+
122
+ }
123
+ }
124
+
125
+ async seek(key: string, positionMillis: number): Promise<void> {
126
+ if (!AudioModule) return
127
+ try {
128
+ await AudioModule.seek(key, positionMillis)
129
+ } catch {
130
+
131
+ }
132
+ }
133
+
134
+ async stop(key: string): Promise<void> {
135
+ if (!AudioModule) return
136
+ try {
137
+ await AudioModule.stop(key)
138
+ } catch {
139
+
140
+ }
141
+ }
142
+ }
143
+
144
+ export const audioController = new AudioController()
@@ -0,0 +1,198 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ GestureResponderEvent,
5
+ LayoutChangeEvent,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ } from 'react-native'
11
+ import { audioController, type AudioPlaybackState } from '../audio/audioController'
12
+ import type { ChatAttachment } from '../types'
13
+ import type { ChatTheme } from '../theme'
14
+
15
+ interface Props {
16
+ attachment: ChatAttachment
17
+ isOutbound: boolean
18
+ theme: ChatTheme
19
+ }
20
+
21
+ function attachmentKey(attachment: ChatAttachment): string {
22
+ return attachment.id > 0 ? `a${attachment.id}` : `t${attachment.url}`
23
+ }
24
+
25
+ function formatTime(millis: number): string {
26
+ if (!Number.isFinite(millis) || millis <= 0) return '0:00'
27
+ const totalSeconds = Math.floor(millis / 1000)
28
+ const minutes = Math.floor(totalSeconds / 60)
29
+ const seconds = totalSeconds % 60
30
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
31
+ }
32
+
33
+ export function AudioMessage({ attachment, isOutbound, theme }: Props) {
34
+ const key = attachmentKey(attachment)
35
+ const [pb, setPb] = useState<AudioPlaybackState>(() => audioController.getState(key))
36
+ const [trackWidth, setTrackWidth] = useState(0)
37
+
38
+ useEffect(() => audioController.subscribe(key, setPb), [key])
39
+
40
+ const isPlaying = pb.state === 'playing'
41
+ const isLoading = pb.state === 'loading'
42
+ const isError = pb.state === 'error'
43
+ const duration = pb.durationMillis
44
+ const position = pb.positionMillis
45
+ const progress = duration > 0 ? Math.min(1, position / duration) : 0
46
+
47
+ const accent = isOutbound ? '#ffffff' : theme.primaryColor
48
+ const trackBg = isOutbound ? 'rgba(255,255,255,0.30)' : 'rgba(0,0,0,0.12)'
49
+ const timeColor = isOutbound ? 'rgba(255,255,255,0.75)' : theme.systemText
50
+ const iconColor = isOutbound ? theme.primaryColor : '#ffffff'
51
+
52
+ const onToggle = () => {
53
+ if (isPlaying) {
54
+ void audioController.pause(key)
55
+ } else {
56
+ void audioController.play(key, attachment.url)
57
+ }
58
+ }
59
+
60
+ const onSeek = (e: GestureResponderEvent) => {
61
+ if (duration <= 0 || trackWidth <= 0) return
62
+ const ratio = Math.max(0, Math.min(1, e.nativeEvent.locationX / trackWidth))
63
+ void audioController.seek(key, ratio * duration)
64
+ }
65
+
66
+ const onTrackLayout = (e: LayoutChangeEvent) => {
67
+ setTrackWidth(e.nativeEvent.layout.width)
68
+ }
69
+
70
+ const timeLabel = duration > 0
71
+ ? `${formatTime(position)} / ${formatTime(duration)}`
72
+ : isError
73
+ ? 'Не удалось воспроизвести'
74
+ : formatTime(position)
75
+
76
+ return (
77
+ <View style={styles.root}>
78
+ <TouchableOpacity
79
+ style={[styles.button, { backgroundColor: accent }]}
80
+ onPress={onToggle}
81
+ activeOpacity={0.8}
82
+ disabled={isLoading}
83
+ >
84
+ {isLoading ? (
85
+ <ActivityIndicator size="small" color={iconColor} />
86
+ ) : isPlaying ? (
87
+ <PauseIcon color={iconColor} />
88
+ ) : (
89
+ <PlayIcon color={iconColor} />
90
+ )}
91
+ </TouchableOpacity>
92
+
93
+ <View style={styles.body}>
94
+ <TouchableOpacity
95
+ activeOpacity={1}
96
+ onPress={onSeek}
97
+ onLayout={onTrackLayout}
98
+ style={styles.trackWrap}
99
+ >
100
+ <View style={[styles.track, { backgroundColor: trackBg }]}>
101
+ <View
102
+ style={[styles.trackFill, { backgroundColor: accent, width: `${progress * 100}%` }]}
103
+ />
104
+ <View
105
+ style={[
106
+ styles.thumb,
107
+ { backgroundColor: accent, left: `${progress * 100}%` },
108
+ ]}
109
+ />
110
+ </View>
111
+ </TouchableOpacity>
112
+ <Text style={[styles.time, { color: timeColor }]}>{timeLabel}</Text>
113
+ </View>
114
+ </View>
115
+ )
116
+ }
117
+
118
+ function PlayIcon({ color }: { color: string }) {
119
+ return <View style={[styles.playTriangle, { borderLeftColor: color }]} />
120
+ }
121
+
122
+ function PauseIcon({ color }: { color: string }) {
123
+ return (
124
+ <View style={styles.pauseWrap}>
125
+ <View style={[styles.pauseBar, { backgroundColor: color }]} />
126
+ <View style={[styles.pauseBar, { backgroundColor: color }]} />
127
+ </View>
128
+ )
129
+ }
130
+
131
+ const BUTTON_SIZE = 40
132
+
133
+ const styles = StyleSheet.create({
134
+ root: {
135
+ flexDirection: 'row',
136
+ alignItems: 'center',
137
+ gap: 10,
138
+ minWidth: 200,
139
+ paddingVertical: 2,
140
+ },
141
+ button: {
142
+ width: BUTTON_SIZE,
143
+ height: BUTTON_SIZE,
144
+ borderRadius: BUTTON_SIZE / 2,
145
+ alignItems: 'center',
146
+ justifyContent: 'center',
147
+ flexShrink: 0,
148
+ },
149
+ body: {
150
+ flex: 1,
151
+ gap: 4,
152
+ },
153
+ trackWrap: {
154
+ paddingVertical: 8,
155
+ justifyContent: 'center',
156
+ },
157
+ track: {
158
+ height: 4,
159
+ borderRadius: 2,
160
+ },
161
+ trackFill: {
162
+ height: 4,
163
+ borderRadius: 2,
164
+ },
165
+ thumb: {
166
+ position: 'absolute',
167
+ top: -3,
168
+ width: 10,
169
+ height: 10,
170
+ borderRadius: 5,
171
+ marginLeft: -5,
172
+ },
173
+ time: {
174
+ fontSize: 11,
175
+ lineHeight: 14,
176
+ },
177
+ playTriangle: {
178
+ width: 0,
179
+ height: 0,
180
+ backgroundColor: 'transparent',
181
+ borderStyle: 'solid',
182
+ borderTopWidth: 7,
183
+ borderBottomWidth: 7,
184
+ borderLeftWidth: 12,
185
+ borderTopColor: 'transparent',
186
+ borderBottomColor: 'transparent',
187
+ marginLeft: 3,
188
+ },
189
+ pauseWrap: {
190
+ flexDirection: 'row',
191
+ gap: 4,
192
+ },
193
+ pauseBar: {
194
+ width: 4,
195
+ height: 14,
196
+ borderRadius: 1,
197
+ },
198
+ })
@@ -1,6 +1,8 @@
1
1
  import React from 'react'
2
2
  import { Dimensions, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
3
3
  import { attachmentDisplayName } from '../attachmentUtils'
4
+ import { audioController } from '../audio/audioController'
5
+ import { AudioMessage } from './AudioMessage'
4
6
  import type { ChatAttachment, ChatButton, ChatMessage } from '../types'
5
7
  import type { ChatTheme } from '../theme'
6
8
 
@@ -123,6 +125,10 @@ function AttachmentView({
123
125
  )
124
126
  }
125
127
 
128
+ if (attachment.type === 'audio' && audioController.isAvailable) {
129
+ return <AudioMessage attachment={attachment} isOutbound={isOutbound} theme={theme} />
130
+ }
131
+
126
132
  const nameColor = isOutbound ? theme.outboundText : theme.inboundText
127
133
  const sizeColor = isOutbound ? 'rgba(255,255,255,0.65)' : theme.systemText
128
134
  const displayName = attachmentDisplayName(attachment)
@@ -11,7 +11,6 @@ import {
11
11
  TouchableOpacity,
12
12
  View,
13
13
  } from 'react-native'
14
- import Svg, { Path } from 'react-native-svg'
15
14
  import { INPUT_BOTTOM_PADDING } from '../safeArea'
16
15
  import { pickFiles as defaultPickFiles } from '../filePicker'
17
16
  import type { ChatTheme } from '../theme'
@@ -122,18 +121,7 @@ export function MessageInput({ theme, isSending, onSend, onPickFiles, strings }:
122
121
  {picking ? (
123
122
  <ActivityIndicator color={theme.systemText} size="small" />
124
123
  ) : (
125
- <Svg
126
- width={20}
127
- height={20}
128
- viewBox="0 0 32 32"
129
- fill="none"
130
- stroke={theme.primaryColor}
131
- strokeWidth={2}
132
- strokeLinecap="round"
133
- strokeLinejoin="round"
134
- >
135
- <Path d="M10 9 L10 24 C10 28 13 30 16 30 19 30 22 28 22 24 L22 6 C22 3 20 2 18 2 16 2 14 3 14 6 L14 23 C14 24 15 25 16 25 17 25 18 24 18 23 L18 9" />
136
- </Svg>
124
+ <Text style={[styles.attachIcon, { color: theme.primaryColor }]}>+</Text>
137
125
  )}
138
126
  </TouchableOpacity>
139
127
 
@@ -257,6 +245,10 @@ const styles = StyleSheet.create({
257
245
  justifyContent: 'center',
258
246
  flexShrink: 0,
259
247
  },
248
+ attachIcon: {
249
+ fontSize: 26,
250
+ lineHeight: 28,
251
+ },
260
252
  inputWrap: {
261
253
  flex: 1,
262
254
  borderWidth: 1,
@@ -0,0 +1,54 @@
1
+ import { NativeEventEmitter, NativeModules, TurboModuleRegistry } from 'react-native'
2
+ import type { EventSubscription, TurboModule } from 'react-native'
3
+
4
+ export type NativeAudioState =
5
+ | 'loading'
6
+ | 'playing'
7
+ | 'paused'
8
+ | 'ended'
9
+ | 'stopped'
10
+ | 'error'
11
+
12
+ export interface AudioStateEvent {
13
+ key: string
14
+ positionMillis: number
15
+ durationMillis: number
16
+ state: NativeAudioState
17
+ }
18
+
19
+ export interface Spec extends TurboModule {
20
+ play(key: string, url: string, headers: Record<string, string>): Promise<void>
21
+ pause(key: string): Promise<void>
22
+ seek(key: string, positionMillis: number): Promise<void>
23
+ stop(key: string): Promise<void>
24
+ addListener(eventName: string): void
25
+ removeListeners(count: number): void
26
+ }
27
+
28
+ const MODULE_NAME = 'ChatSdkAudioPlayer'
29
+
30
+ function loadModule(): Spec | null {
31
+ try {
32
+ return TurboModuleRegistry.get<Spec>(MODULE_NAME)
33
+ } catch {
34
+ return (NativeModules[MODULE_NAME] as Spec | undefined) ?? null
35
+ }
36
+ }
37
+
38
+ const moduleRef = loadModule()
39
+
40
+ export default moduleRef
41
+
42
+ let emitter: NativeEventEmitter | null = null
43
+
44
+ function getEmitter(): NativeEventEmitter | null {
45
+ if (!moduleRef) return null
46
+ if (!emitter) emitter = new NativeEventEmitter(moduleRef as unknown as never)
47
+ return emitter
48
+ }
49
+
50
+ export function onAudioState(
51
+ handler: (event: AudioStateEvent) => void,
52
+ ): EventSubscription | null {
53
+ return getEmitter()?.addListener('ChatSdkAudioState', handler) ?? null
54
+ }