@repzo/widget-react-native 1.2.2 → 1.3.1
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.
- package/CHANGELOG.md +88 -0
- package/README.md +43 -33
- package/android/build.gradle +22 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
- package/expo-module.config.json +9 -0
- package/ios/RepzoVoicePlayer.swift +175 -0
- package/ios/RepzoWidgetAudio.podspec +26 -0
- package/ios/RepzoWidgetAudioModule.swift +65 -0
- package/lib/commonjs/api/client.js +40 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +80 -23
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/audio/native-player.js +97 -0
- package/lib/commonjs/audio/native-player.js.map +1 -0
- package/lib/commonjs/audio/voice.js +34 -7
- package/lib/commonjs/audio/voice.js.map +1 -1
- package/lib/commonjs/i18n.js +2 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +1 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +238 -40
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +8 -6
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +20 -37
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
- package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/commonjs/ui/components/VoiceNote.js +8 -9
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
- package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +22 -16
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +42 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +17 -9
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/styles/theme.js +1 -0
- package/lib/commonjs/ui/styles/theme.js.map +1 -1
- package/lib/commonjs/ui/utils/conversations.js +17 -1
- package/lib/commonjs/ui/utils/conversations.js.map +1 -1
- package/lib/module/api/client.js +40 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +80 -23
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/audio/native-player.js +93 -0
- package/lib/module/audio/native-player.js.map +1 -0
- package/lib/module/audio/voice.js +32 -6
- package/lib/module/audio/voice.js.map +1 -1
- package/lib/module/i18n.js +2 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +1 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +238 -40
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +8 -6
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +21 -38
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/MessagesSkeleton.js +124 -0
- package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/module/ui/components/VoiceNote.js +9 -10
- package/lib/module/ui/components/VoiceNote.js.map +1 -1
- package/lib/module/ui/components/ZoomableImage.js +291 -0
- package/lib/module/ui/components/ZoomableImage.js.map +1 -0
- package/lib/module/ui/components/icons.js +21 -15
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +44 -20
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +17 -9
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/styles/theme.js +1 -0
- package/lib/module/ui/styles/theme.js.map +1 -1
- package/lib/module/ui/utils/conversations.js +15 -1
- package/lib/module/ui/utils/conversations.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +27 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
- package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +1 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +38 -3
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +17 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +14 -9
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +27 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +22 -10
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/audio/native-player.d.ts +36 -0
- package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/module/audio/voice.d.ts +17 -4
- package/lib/typescript/module/audio/voice.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +1 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +38 -3
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +17 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +2 -1
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +14 -9
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
- package/package.json +10 -3
- package/react-native.config.js +12 -0
- package/src/api/client.ts +66 -2
- package/src/attachments/pickers.ts +87 -25
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +30 -6
- package/src/i18n.ts +2 -0
- package/src/locales.ts +1 -0
- package/src/state/widget-store.ts +233 -33
- package/src/types.ts +17 -0
- package/src/ui/WidgetProvider.tsx +16 -7
- package/src/ui/components/ImageViewer.tsx +21 -36
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +6 -14
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/screens/chat/ChatView.styles.ts +17 -9
- package/src/ui/screens/chat/ChatView.tsx +49 -25
- package/src/ui/screens/chat/ChatView.types.ts +4 -0
- package/src/ui/styles/theme.ts +6 -0
- package/src/ui/utils/conversations.ts +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,94 @@ All notable changes to `@repzo/widget-react-native`, newest first.
|
|
|
12
12
|
|
|
13
13
|
## Unreleased
|
|
14
14
|
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Visitors can attach files up to 50 MB.** With a Workstation server that
|
|
18
|
+
takes uploads, a picked file is uploaded when the message is sent, instead of
|
|
19
|
+
being sent inside the message, so the 1.5 MB cap per file (6 MB per message)
|
|
20
|
+
is gone; the server sets the cap (50 MB a file, up to five files). Large files
|
|
21
|
+
are read from the device as they upload, never held in memory as text. The
|
|
22
|
+
"File is too large" notice shows the server's cap. Nothing to set up.
|
|
23
|
+
|
|
24
|
+
### Older versions
|
|
25
|
+
|
|
26
|
+
- Apps on earlier versions keep the 1.5 MB cap. With an older server, this
|
|
27
|
+
version keeps it too.
|
|
28
|
+
|
|
29
|
+
## 1.3.1 — 2026-10-29
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **Tap to retry a message that didn't send.** A text message that fails to
|
|
34
|
+
send (no connection, say) shows **Failed to send · Tap to retry** under it,
|
|
35
|
+
where Sent and Seen usually are. Tapping the line sends it again, and it
|
|
36
|
+
moves to the end of the conversation, after anything sent in the meantime. A
|
|
37
|
+
tap on an agent card button (add to cart, book a time and so on) is sent
|
|
38
|
+
again with its text. Tapping the card button again also works and replaces
|
|
39
|
+
the failed message, so the action runs only once. Nothing to set up.
|
|
40
|
+
- **No double messages after a dropped connection,** with a Workstation
|
|
41
|
+
update. If the connection drops after Workstation has already stored a
|
|
42
|
+
message, the message shows as sent instead of failed, and a retry doesn't
|
|
43
|
+
post it a second time. With older Workstation deployments, a retry in that
|
|
44
|
+
case can still post the message twice. New optional field
|
|
45
|
+
`Message.clientMessageId`.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **The attach button is now a plus (+).** It replaces the paperclip beside the
|
|
50
|
+
message field, which was hard to recognize.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **Visitors can open the photos and files they send.** Tapping them used to
|
|
55
|
+
do nothing until the stored copy happened to arrive. A photo now opens in
|
|
56
|
+
the full-screen viewer right away, from the copy on the phone. A PDF or
|
|
57
|
+
other file opens in the phone's viewer once Workstation has stored it: the
|
|
58
|
+
messenger fetches the stored copy after sending, and checks again for about
|
|
59
|
+
20 seconds if it isn't ready yet. When several photos or files go in one
|
|
60
|
+
message, each becomes openable as soon as its own copy is stored.
|
|
61
|
+
|
|
62
|
+
## 1.3.0 — 2026-10-27
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **Voice messages play without `expo-audio`.** The SDK now brings its own
|
|
67
|
+
small player on the phone's audio player (AVPlayer on iOS, MediaPlayer on
|
|
68
|
+
Android), with no extra libraries or permissions. In apps with Expo modules
|
|
69
|
+
it links automatically on the next native build. On iOS, notes now play with
|
|
70
|
+
the ringer switch on silent and pause music while they play, like in
|
|
71
|
+
messaging apps; an app that set its own audio session category (including
|
|
72
|
+
`ambient`) keeps it. A note pauses when the visitor leaves the app, and when
|
|
73
|
+
a call, navigation prompt or another app's audio starts.
|
|
74
|
+
Where the player isn't built in (Expo Go, bare React Native without Expo
|
|
75
|
+
modules), notes still play with `expo-audio` if the app has it, or open in
|
|
76
|
+
the browser.
|
|
77
|
+
- **Placeholder bubbles while a conversation loads.** Opening a conversation
|
|
78
|
+
shows the outline of a thread, gently pulsing, until its messages arrive,
|
|
79
|
+
instead of a "Loading..." line. It holds still when the device has Reduce
|
|
80
|
+
Motion on.
|
|
81
|
+
- **Zoom into images.** In the full-screen image viewer, visitors can pinch
|
|
82
|
+
to zoom, drag a zoomed image around, and double-tap to zoom in on a spot
|
|
83
|
+
(and again to zoom out). This now works on Android too; before, only iOS
|
|
84
|
+
had pinch-to-zoom. No new dependency.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- **Back from a conversation returns to where the visitor opened it.** A
|
|
89
|
+
conversation opened from the home screen (a recent conversation or "send us
|
|
90
|
+
a message") now goes back to home instead of the conversation list. One
|
|
91
|
+
opened from the list still goes back to the list, and one opened with the
|
|
92
|
+
messenger closed (a push or a notification card) goes to the list as before.
|
|
93
|
+
|
|
94
|
+
### Upgrading
|
|
95
|
+
|
|
96
|
+
- **A native build picks up the voice player.** Nothing else to do: build the
|
|
97
|
+
app again (development or store build) after upgrading. `expo-audio` can be
|
|
98
|
+
removed if nothing else in your app uses it. Until the new build, an
|
|
99
|
+
over-the-air update with this version keeps using `expo-audio` or the file
|
|
100
|
+
row. If your `runtimeVersion` uses the `fingerprint` policy, it changes with
|
|
101
|
+
this update.
|
|
102
|
+
|
|
15
103
|
## 1.2.2 — 2026-10-26
|
|
16
104
|
|
|
17
105
|
### Added
|
package/README.md
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|

|
|
8
8
|
|
|
9
9
|
The Repzo chat widget for React Native — an Intercom-style messenger that drops
|
|
10
|
-
into any app. Pure JavaScript/TypeScript
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
into any app. Pure JavaScript/TypeScript, apart from one small optional
|
|
11
|
+
native piece (the voice message player, linked automatically in Expo apps): **no
|
|
12
|
+
config plugin and nothing extra to set up**. Works on iOS and Android, Expo and
|
|
13
|
+
bare React Native, old and new architecture.
|
|
13
14
|
|
|
14
15
|
It connects to the same channel as your Repzo website chat (Workstation →
|
|
15
16
|
Settings → Inbox Channels → Website Chat), so a message sent from the app lands
|
|
@@ -21,11 +22,12 @@ in your Repzo Inbox and agent replies arrive live.
|
|
|
21
22
|
- Session resume, anonymous → identified migration, HMAC identity verification
|
|
22
23
|
- Shared-device safe: logout revokes the token and wipes all visitor data; tokens can live in Keychain/Keystore
|
|
23
24
|
- Live agent replies, typing indicators, unread badges (server-backed counts)
|
|
24
|
-
- Sent/Seen receipts, teammate avatars and presence, deleted-message placeholders
|
|
25
|
+
- Sent/Seen receipts, tap to retry a message that didn't send (*1.3.1+*), teammate avatars and presence, deleted-message placeholders
|
|
25
26
|
- Catches up after a dropped connection or a trip to the background — no missed or duplicated replies
|
|
26
27
|
- Agent UI blocks: product cards, slot pickers, lead forms, quotes, receipts
|
|
28
|
+
- **Image viewer** with pinch and double-tap zoom for photos in the conversation
|
|
27
29
|
- **Photo and file attachments** (optional — with `expo-image-picker` / `expo-document-picker`, or your own picker)
|
|
28
|
-
- **Voice messages play in the chat
|
|
30
|
+
- **Voice messages play in the chat**, with the phone's own audio player — nothing to install in Expo apps
|
|
29
31
|
- **In-app notification cards** for new replies while the messenger is closed
|
|
30
32
|
- **Posts** from Workstation over the app, with replies or emoji reactions
|
|
31
33
|
- **Push notifications via FCM** (optional — needs `@react-native-firebase/messaging` in the host app), including Mobile Push campaigns from Workstation with images
|
|
@@ -46,7 +48,7 @@ in your Repzo Inbox and agent replies arrive live.
|
|
|
46
48
|
| `expo-document-picker` | optional — "File" attachments |
|
|
47
49
|
| `expo-file-system` | optional — reads picked files as base64 (falls back to `fetch` without it) |
|
|
48
50
|
| `expo-image-manipulator` | optional — shrinks photos so camera shots fit the 1.5 MB attachment limit |
|
|
49
|
-
| `expo-audio` | optional (*1.2.2+*) —
|
|
51
|
+
| `expo-audio` | optional (*1.2.2+*) — no longer needed from *1.3.0+* in apps with Expo modules (see "Voice messages"); still used where the SDK's own player isn't built in |
|
|
50
52
|
| `expo-crypto` | optional (*1.1.0+*) — secure random source for the anonymous device id when the runtime has no `crypto` global (see below) |
|
|
51
53
|
|
|
52
54
|
Realtime uses React Native’s built-in streaming XMLHttpRequest; there are no bundled runtime dependencies.
|
|
@@ -307,9 +309,8 @@ your bank. Ordinary link text ("Book here") is shown as written.
|
|
|
307
309
|
|
|
308
310
|
## Attachments
|
|
309
311
|
|
|
310
|
-
Visitors can send photos and files from the
|
|
311
|
-
|
|
312
|
-
installs:
|
|
312
|
+
Visitors can send photos and files from the **+** button beside the message
|
|
313
|
+
field. The SDK has no picker of its own; it uses whatever your app installs:
|
|
313
314
|
|
|
314
315
|
```sh
|
|
315
316
|
npx expo install expo-image-picker expo-document-picker expo-file-system expo-image-manipulator
|
|
@@ -345,6 +346,11 @@ a message. A message can be attachments only,
|
|
|
345
346
|
with no text. If a send fails, the attachments stay in the composer so the
|
|
346
347
|
visitor can retry.
|
|
347
348
|
|
|
349
|
+
*1.3.1+.* Visitors can open what they sent with a tap. A photo opens in the
|
|
350
|
+
full-screen viewer right away, from the copy on the phone. A PDF or other file
|
|
351
|
+
opens in the phone's viewer once Workstation has stored it, usually a moment
|
|
352
|
+
after it's sent.
|
|
353
|
+
|
|
348
354
|
### Your own picker
|
|
349
355
|
|
|
350
356
|
Apps that use `react-native-image-picker`, `react-native-document-picker` or
|
|
@@ -380,30 +386,33 @@ the app regardless of the channel setting, pass `attachments: { enabled: false }
|
|
|
380
386
|
|
|
381
387
|
*1.2.2+.* Voice messages from your team, and any other audio file in the
|
|
382
388
|
conversation, play right in the chat — a play button, a waveform to tap or
|
|
383
|
-
drag through, and 1×/1.5×/2× speed
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
389
|
+
drag through, and 1×/1.5×/2× speed.
|
|
390
|
+
|
|
391
|
+
*1.3.0+.* The SDK brings its own small player, built on the phone's audio
|
|
392
|
+
player (AVPlayer on iOS, MediaPlayer on Android), so there's nothing to
|
|
393
|
+
install. In an app with Expo modules (any Expo app, or bare React Native with
|
|
394
|
+
`expo` installed) it's linked automatically the next time you build the app (a
|
|
395
|
+
new development build or store build; an over-the-air update alone isn't
|
|
396
|
+
enough). It adds no permissions and no other libraries.
|
|
397
|
+
|
|
398
|
+
Where the SDK's player isn't built in — Expo Go, bare React Native without
|
|
399
|
+
Expo modules, or an app binary built with 1.2.2 or older — notes play with
|
|
400
|
+
`expo-audio` if your app has it, and otherwise show as a file the visitor taps
|
|
401
|
+
to open in the browser, as before. A note only downloads once the visitor taps
|
|
402
|
+
play, starting one pauses any other, and a note that can't be played falls
|
|
403
|
+
back to that file.
|
|
404
|
+
|
|
405
|
+
With the SDK's player, notes play like in messaging apps: on iOS they play
|
|
406
|
+
with the ringer switch on silent, and they pause music while they play and
|
|
407
|
+
let it carry on after. A note pauses when the visitor leaves the app, and when
|
|
408
|
+
a phone call, a navigation prompt or another app's audio starts. If your app
|
|
409
|
+
has set its own iOS audio session category (including `ambient`), the player
|
|
410
|
+
keeps it.
|
|
411
|
+
|
|
412
|
+
With `expo-audio` instead, the widget leaves your app's audio session alone, so
|
|
413
|
+
notes follow your expo-audio settings. On iOS that means they're muted while the
|
|
414
|
+
ringer switch is on silent, unless your app calls
|
|
415
|
+
`setAudioModeAsync({ playsInSilentMode: true })` from `expo-audio`.
|
|
407
416
|
|
|
408
417
|
## Errors
|
|
409
418
|
|
|
@@ -730,6 +739,7 @@ action from you:
|
|
|
730
739
|
| 1.0.0 | In-app notification cards are now on by default (`inAppNotifications: false` turns them off). The attach button appears if `expo-image-picker` or `expo-document-picker` is installed (`attachments: { enabled: false }` hides it). The `withRepzoWidget` Metro wrapper is optional now. |
|
|
731
740
|
| 1.0.9 or older | Nothing required. Development builds now warn once if the visitor token isn't in secure storage, or if the device has no secure random source — see "Secure token storage" and "Requirements". |
|
|
732
741
|
| 1.1.0 or older | Posts from Workstation now show over the app by default, once one is set live for Mobile (`posts: false` turns them off). They need `<RepzoWidgetProvider>` mounted at the root. |
|
|
742
|
+
| 1.2.2 or older | Nothing required. In apps with Expo modules, the SDK now includes a small native voice player, so build the app again (development or store build) to get it; `expo-audio` can be removed if nothing else in your app uses it. If your `runtimeVersion` uses the `fingerprint` policy, it changes with this update. |
|
|
733
743
|
|
|
734
744
|
## Troubleshooting
|
|
735
745
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'expo.modules.repzowidget'
|
|
4
|
+
version = '1.0.0'
|
|
5
|
+
|
|
6
|
+
// The way Expo modules were built before Expo SDK 52, which newer SDKs still
|
|
7
|
+
// support. The `expo-module-gradle-plugin` form would only build on SDK 52+.
|
|
8
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
9
|
+
apply from: expoModulesCorePlugin
|
|
10
|
+
applyKotlinExpoModulesCorePlugin()
|
|
11
|
+
useCoreDependencies()
|
|
12
|
+
useDefaultAndroidSdkVersions()
|
|
13
|
+
|
|
14
|
+
android {
|
|
15
|
+
namespace "expo.modules.repzowidget"
|
|
16
|
+
defaultConfig {
|
|
17
|
+
versionCode 1
|
|
18
|
+
versionName "1.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// No other dependencies: voice notes play through Android's own MediaPlayer.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
package expo.modules.repzowidget
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioAttributes
|
|
5
|
+
import android.media.AudioFocusRequest
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.media.MediaPlayer
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.Looper
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One voice note on Android's own MediaPlayer. Created, driven and released
|
|
14
|
+
* on the main thread, so MediaPlayer's callbacks arrive there too.
|
|
15
|
+
*/
|
|
16
|
+
internal class RepzoVoicePlayer(
|
|
17
|
+
context: Context,
|
|
18
|
+
url: String,
|
|
19
|
+
private val onStatus: (Map<String, Any?>) -> Unit
|
|
20
|
+
) {
|
|
21
|
+
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
22
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
23
|
+
private val player = MediaPlayer()
|
|
24
|
+
private val attributes = AudioAttributes.Builder()
|
|
25
|
+
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
26
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
27
|
+
.build()
|
|
28
|
+
|
|
29
|
+
private var prepared = false
|
|
30
|
+
private var released = false
|
|
31
|
+
// The visitor asked for playback; it starts once the note has loaded.
|
|
32
|
+
private var wantsPlay = false
|
|
33
|
+
private var buffering = false
|
|
34
|
+
private var error: String? = null
|
|
35
|
+
private var rate = 1f
|
|
36
|
+
private var pendingSeek: (() -> Unit)? = null
|
|
37
|
+
private var focusRequest: AudioFocusRequest? = null
|
|
38
|
+
|
|
39
|
+
// A call or another app taking the audio pauses the note, as messaging apps do.
|
|
40
|
+
// That includes a request to duck (navigation prompts): Android doesn't duck
|
|
41
|
+
// speech for us, and two voices at once can't be followed.
|
|
42
|
+
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
|
|
43
|
+
when (change) {
|
|
44
|
+
AudioManager.AUDIOFOCUS_LOSS,
|
|
45
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
|
|
46
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> pause()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Moves the playhead while the note plays.
|
|
51
|
+
private val tick = object : Runnable {
|
|
52
|
+
override fun run() {
|
|
53
|
+
if (released) return
|
|
54
|
+
emit()
|
|
55
|
+
if (isPlaying()) handler.postDelayed(this, TICK_MS)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
init {
|
|
60
|
+
player.setAudioAttributes(attributes)
|
|
61
|
+
player.setOnPreparedListener {
|
|
62
|
+
prepared = true
|
|
63
|
+
if (wantsPlay) startPlayback() else emit()
|
|
64
|
+
}
|
|
65
|
+
player.setOnCompletionListener {
|
|
66
|
+
wantsPlay = false
|
|
67
|
+
handler.removeCallbacks(tick)
|
|
68
|
+
abandonFocus()
|
|
69
|
+
emit(didJustFinish = true)
|
|
70
|
+
}
|
|
71
|
+
player.setOnErrorListener { _, what, extra ->
|
|
72
|
+
error = "Can't play this audio ($what/$extra)"
|
|
73
|
+
prepared = false
|
|
74
|
+
wantsPlay = false
|
|
75
|
+
handler.removeCallbacks(tick)
|
|
76
|
+
abandonFocus()
|
|
77
|
+
emit()
|
|
78
|
+
true
|
|
79
|
+
}
|
|
80
|
+
player.setOnInfoListener { _, what, _ ->
|
|
81
|
+
when (what) {
|
|
82
|
+
MediaPlayer.MEDIA_INFO_BUFFERING_START -> { buffering = true; emit() }
|
|
83
|
+
MediaPlayer.MEDIA_INFO_BUFFERING_END -> { buffering = false; emit() }
|
|
84
|
+
}
|
|
85
|
+
false
|
|
86
|
+
}
|
|
87
|
+
player.setOnSeekCompleteListener {
|
|
88
|
+
val done = pendingSeek
|
|
89
|
+
pendingSeek = null
|
|
90
|
+
emit()
|
|
91
|
+
done?.invoke()
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
player.setDataSource(url)
|
|
95
|
+
player.prepareAsync()
|
|
96
|
+
} catch (e: Exception) {
|
|
97
|
+
error = e.message ?: "Can't play this audio"
|
|
98
|
+
handler.post { emit() }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fun play() {
|
|
103
|
+
if (released) return
|
|
104
|
+
wantsPlay = true
|
|
105
|
+
if (prepared) startPlayback() else emit()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fun pause() {
|
|
109
|
+
if (released) return
|
|
110
|
+
wantsPlay = false
|
|
111
|
+
if (isPlaying()) player.pause()
|
|
112
|
+
handler.removeCallbacks(tick)
|
|
113
|
+
abandonFocus()
|
|
114
|
+
emit()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fun seekTo(seconds: Double, done: () -> Unit) {
|
|
118
|
+
if (released || !prepared) {
|
|
119
|
+
done()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
// A seek that hasn't landed yet is overtaken by this one.
|
|
123
|
+
pendingSeek?.invoke()
|
|
124
|
+
pendingSeek = done
|
|
125
|
+
val ms = (seconds.coerceAtLeast(0.0) * 1000).toLong()
|
|
126
|
+
try {
|
|
127
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
128
|
+
player.seekTo(ms, MediaPlayer.SEEK_CLOSEST)
|
|
129
|
+
} else {
|
|
130
|
+
player.seekTo(ms.toInt())
|
|
131
|
+
}
|
|
132
|
+
} catch (e: IllegalStateException) {
|
|
133
|
+
pendingSeek = null
|
|
134
|
+
done()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fun setRate(value: Float) {
|
|
139
|
+
rate = value
|
|
140
|
+
// Setting the speed of a paused MediaPlayer starts it, so a paused note
|
|
141
|
+
// keeps the new speed for its next start instead.
|
|
142
|
+
if (isPlaying()) applyRate()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fun release() {
|
|
146
|
+
if (released) return
|
|
147
|
+
released = true
|
|
148
|
+
handler.removeCallbacks(tick)
|
|
149
|
+
abandonFocus()
|
|
150
|
+
pendingSeek?.invoke()
|
|
151
|
+
pendingSeek = null
|
|
152
|
+
player.release()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun startPlayback() {
|
|
156
|
+
if (released || !prepared) return
|
|
157
|
+
requestFocus()
|
|
158
|
+
try {
|
|
159
|
+
applyRate()
|
|
160
|
+
if (!player.isPlaying) player.start()
|
|
161
|
+
} catch (e: IllegalStateException) {
|
|
162
|
+
error = e.message ?: "Can't play this audio"
|
|
163
|
+
}
|
|
164
|
+
handler.removeCallbacks(tick)
|
|
165
|
+
handler.post(tick)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private fun applyRate() {
|
|
169
|
+
try {
|
|
170
|
+
player.playbackParams = player.playbackParams.setSpeed(rate)
|
|
171
|
+
} catch (e: Exception) {
|
|
172
|
+
// Speed unsupported for this note: it plays at 1×.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private fun isPlaying(): Boolean =
|
|
177
|
+
prepared && !released && try { player.isPlaying } catch (e: IllegalStateException) { false }
|
|
178
|
+
|
|
179
|
+
private fun requestFocus() {
|
|
180
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
181
|
+
val request = focusRequest ?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
182
|
+
.setAudioAttributes(attributes)
|
|
183
|
+
.setOnAudioFocusChangeListener(focusListener, handler)
|
|
184
|
+
.build()
|
|
185
|
+
.also { focusRequest = it }
|
|
186
|
+
audioManager.requestAudioFocus(request)
|
|
187
|
+
} else {
|
|
188
|
+
@Suppress("DEPRECATION")
|
|
189
|
+
audioManager.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun abandonFocus() {
|
|
194
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
195
|
+
focusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
|
196
|
+
} else {
|
|
197
|
+
@Suppress("DEPRECATION")
|
|
198
|
+
audioManager.abandonAudioFocus(focusListener)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private fun emit(didJustFinish: Boolean = false) {
|
|
203
|
+
if (released) return
|
|
204
|
+
val duration = if (prepared) player.duration.coerceAtLeast(0) / 1000.0 else 0.0
|
|
205
|
+
val position = if (prepared) player.currentPosition.coerceAtLeast(0) / 1000.0 else 0.0
|
|
206
|
+
onStatus(
|
|
207
|
+
mapOf(
|
|
208
|
+
"currentTime" to position,
|
|
209
|
+
"duration" to duration,
|
|
210
|
+
"playing" to isPlaying(),
|
|
211
|
+
"isLoaded" to prepared,
|
|
212
|
+
"isBuffering" to (buffering || (wantsPlay && !prepared && error == null)),
|
|
213
|
+
"didJustFinish" to didJustFinish,
|
|
214
|
+
"error" to error
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private companion object {
|
|
220
|
+
const val TICK_MS = 100L
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package expo.modules.repzowidget
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import expo.modules.kotlin.Promise
|
|
6
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
7
|
+
import expo.modules.kotlin.functions.Queues
|
|
8
|
+
import expo.modules.kotlin.modules.Module
|
|
9
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Plays the chat's voice notes, so apps don't need an audio package of their
|
|
13
|
+
* own. JS addresses each note's player by an id it picks (see
|
|
14
|
+
* `src/audio/native-player.ts`); every change comes back as an `onStatus`
|
|
15
|
+
* event carrying that id.
|
|
16
|
+
*/
|
|
17
|
+
class RepzoWidgetAudioModule : Module() {
|
|
18
|
+
// Only touched on the main thread: every function below runs there.
|
|
19
|
+
private val players = HashMap<String, RepzoVoicePlayer>()
|
|
20
|
+
|
|
21
|
+
override fun definition() = ModuleDefinition {
|
|
22
|
+
Name("RepzoWidgetAudio")
|
|
23
|
+
|
|
24
|
+
Events("onStatus")
|
|
25
|
+
|
|
26
|
+
AsyncFunction("create") { id: String, url: String ->
|
|
27
|
+
val context = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
|
28
|
+
players.remove(id)?.release()
|
|
29
|
+
players[id] = RepzoVoicePlayer(context, url) { status ->
|
|
30
|
+
sendEvent("onStatus", status + ("id" to id))
|
|
31
|
+
}
|
|
32
|
+
}.runOnQueue(Queues.MAIN)
|
|
33
|
+
|
|
34
|
+
AsyncFunction("play") { id: String ->
|
|
35
|
+
players[id]?.play()
|
|
36
|
+
}.runOnQueue(Queues.MAIN)
|
|
37
|
+
|
|
38
|
+
AsyncFunction("pause") { id: String ->
|
|
39
|
+
players[id]?.pause()
|
|
40
|
+
}.runOnQueue(Queues.MAIN)
|
|
41
|
+
|
|
42
|
+
AsyncFunction("seekTo") { id: String, seconds: Double, promise: Promise ->
|
|
43
|
+
val player = players[id]
|
|
44
|
+
if (player == null) promise.resolve(null) else player.seekTo(seconds) { promise.resolve(null) }
|
|
45
|
+
}.runOnQueue(Queues.MAIN)
|
|
46
|
+
|
|
47
|
+
AsyncFunction("setRate") { id: String, rate: Double ->
|
|
48
|
+
players[id]?.setRate(rate.toFloat())
|
|
49
|
+
}.runOnQueue(Queues.MAIN)
|
|
50
|
+
|
|
51
|
+
AsyncFunction("release") { id: String ->
|
|
52
|
+
players.remove(id)?.release()
|
|
53
|
+
}.runOnQueue(Queues.MAIN)
|
|
54
|
+
|
|
55
|
+
// Leaving the app pauses every note, including one still loading, so it
|
|
56
|
+
// doesn't keep playing with its controls out of sight.
|
|
57
|
+
OnActivityEntersBackground {
|
|
58
|
+
players.values.forEach { it.pause() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
OnDestroy {
|
|
62
|
+
Handler(Looper.getMainLooper()).post {
|
|
63
|
+
players.values.forEach { it.release() }
|
|
64
|
+
players.clear()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|