@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +43 -33
  3. package/android/build.gradle +22 -0
  4. package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
  5. package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
  6. package/expo-module.config.json +9 -0
  7. package/ios/RepzoVoicePlayer.swift +175 -0
  8. package/ios/RepzoWidgetAudio.podspec +26 -0
  9. package/ios/RepzoWidgetAudioModule.swift +65 -0
  10. package/lib/commonjs/api/client.js +40 -0
  11. package/lib/commonjs/api/client.js.map +1 -1
  12. package/lib/commonjs/attachments/pickers.js +80 -23
  13. package/lib/commonjs/attachments/pickers.js.map +1 -1
  14. package/lib/commonjs/audio/native-player.js +97 -0
  15. package/lib/commonjs/audio/native-player.js.map +1 -0
  16. package/lib/commonjs/audio/voice.js +34 -7
  17. package/lib/commonjs/audio/voice.js.map +1 -1
  18. package/lib/commonjs/i18n.js +2 -0
  19. package/lib/commonjs/i18n.js.map +1 -1
  20. package/lib/commonjs/locales.js +1 -0
  21. package/lib/commonjs/locales.js.map +1 -1
  22. package/lib/commonjs/state/widget-store.js +238 -40
  23. package/lib/commonjs/state/widget-store.js.map +1 -1
  24. package/lib/commonjs/types.js.map +1 -1
  25. package/lib/commonjs/ui/WidgetProvider.js +8 -6
  26. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  27. package/lib/commonjs/ui/components/ImageViewer.js +20 -37
  28. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  29. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  30. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  31. package/lib/commonjs/ui/components/VoiceNote.js +8 -9
  32. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
  33. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  34. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  35. package/lib/commonjs/ui/components/icons.js +22 -16
  36. package/lib/commonjs/ui/components/icons.js.map +1 -1
  37. package/lib/commonjs/ui/screens/chat/ChatView.js +42 -18
  38. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +17 -9
  40. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  41. package/lib/commonjs/ui/styles/theme.js +1 -0
  42. package/lib/commonjs/ui/styles/theme.js.map +1 -1
  43. package/lib/commonjs/ui/utils/conversations.js +17 -1
  44. package/lib/commonjs/ui/utils/conversations.js.map +1 -1
  45. package/lib/module/api/client.js +40 -0
  46. package/lib/module/api/client.js.map +1 -1
  47. package/lib/module/attachments/pickers.js +80 -23
  48. package/lib/module/attachments/pickers.js.map +1 -1
  49. package/lib/module/audio/native-player.js +93 -0
  50. package/lib/module/audio/native-player.js.map +1 -0
  51. package/lib/module/audio/voice.js +32 -6
  52. package/lib/module/audio/voice.js.map +1 -1
  53. package/lib/module/i18n.js +2 -0
  54. package/lib/module/i18n.js.map +1 -1
  55. package/lib/module/locales.js +1 -0
  56. package/lib/module/locales.js.map +1 -1
  57. package/lib/module/state/widget-store.js +238 -40
  58. package/lib/module/state/widget-store.js.map +1 -1
  59. package/lib/module/types.js.map +1 -1
  60. package/lib/module/ui/WidgetProvider.js +8 -6
  61. package/lib/module/ui/WidgetProvider.js.map +1 -1
  62. package/lib/module/ui/components/ImageViewer.js +21 -38
  63. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  64. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  65. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  66. package/lib/module/ui/components/VoiceNote.js +9 -10
  67. package/lib/module/ui/components/VoiceNote.js.map +1 -1
  68. package/lib/module/ui/components/ZoomableImage.js +291 -0
  69. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  70. package/lib/module/ui/components/icons.js +21 -15
  71. package/lib/module/ui/components/icons.js.map +1 -1
  72. package/lib/module/ui/screens/chat/ChatView.js +44 -20
  73. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  74. package/lib/module/ui/screens/chat/ChatView.styles.js +17 -9
  75. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  76. package/lib/module/ui/styles/theme.js +1 -0
  77. package/lib/module/ui/styles/theme.js.map +1 -1
  78. package/lib/module/ui/utils/conversations.js +15 -1
  79. package/lib/module/ui/utils/conversations.js.map +1 -1
  80. package/lib/typescript/commonjs/api/client.d.ts +27 -1
  81. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
  83. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  85. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
  87. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/i18n.d.ts +1 -0
  89. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/state/widget-store.d.ts +38 -3
  92. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/types.d.ts +17 -0
  94. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  97. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  99. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
  101. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  103. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
  105. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
  107. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +14 -9
  109. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  111. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
  113. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
  115. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
  116. package/lib/typescript/module/api/client.d.ts +27 -1
  117. package/lib/typescript/module/api/client.d.ts.map +1 -1
  118. package/lib/typescript/module/attachments/pickers.d.ts +22 -10
  119. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  120. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  121. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  122. package/lib/typescript/module/audio/voice.d.ts +17 -4
  123. package/lib/typescript/module/audio/voice.d.ts.map +1 -1
  124. package/lib/typescript/module/i18n.d.ts +1 -0
  125. package/lib/typescript/module/i18n.d.ts.map +1 -1
  126. package/lib/typescript/module/locales.d.ts.map +1 -1
  127. package/lib/typescript/module/state/widget-store.d.ts +38 -3
  128. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  129. package/lib/typescript/module/types.d.ts +17 -0
  130. package/lib/typescript/module/types.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  133. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  134. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  135. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  136. package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
  137. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  139. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  140. package/lib/typescript/module/ui/components/icons.d.ts +2 -1
  141. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
  143. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +14 -9
  145. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  147. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
  149. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
  151. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
  152. package/package.json +10 -3
  153. package/react-native.config.js +12 -0
  154. package/src/api/client.ts +66 -2
  155. package/src/attachments/pickers.ts +87 -25
  156. package/src/audio/native-player.ts +106 -0
  157. package/src/audio/voice.ts +30 -6
  158. package/src/i18n.ts +2 -0
  159. package/src/locales.ts +1 -0
  160. package/src/state/widget-store.ts +233 -33
  161. package/src/types.ts +17 -0
  162. package/src/ui/WidgetProvider.tsx +16 -7
  163. package/src/ui/components/ImageViewer.tsx +21 -36
  164. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  165. package/src/ui/components/VoiceNote.tsx +6 -14
  166. package/src/ui/components/ZoomableImage.tsx +250 -0
  167. package/src/ui/components/icons.tsx +10 -12
  168. package/src/ui/screens/chat/ChatView.styles.ts +17 -9
  169. package/src/ui/screens/chat/ChatView.tsx +49 -25
  170. package/src/ui/screens/chat/ChatView.types.ts +4 -0
  171. package/src/ui/styles/theme.ts +6 -0
  172. 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
  ![Expo Go](https://img.shields.io/badge/Expo%20Go-supported-000020.svg)
8
8
 
9
9
  The Repzo chat widget for React Native — an Intercom-style messenger that drops
10
- into any app. Pure JavaScript/TypeScript: **no native modules of its own, no
11
- config plugin, no `pod install`**. Works on iOS and Android, Expo and bare
12
- React Native, old and new architecture.
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** (optional — with `expo-audio`)
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+*) — plays voice messages and audio files inside the chat (Expo SDK 53+) |
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 paperclip button beside the
311
- message field. The SDK has no picker of its own; it uses whatever your app
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 — once your app has `expo-audio`:
384
-
385
- ```sh
386
- npx expo install expo-audio
387
- ```
388
-
389
- It's a native module, so rebuild the app (a new development build or store
390
- build; an over-the-air update alone isn't enough). Without it, audio shows as a
391
- file the visitor taps to open in the browser, as before. A note only downloads
392
- once the visitor taps play, starting one pauses any other, and a note that
393
- can't be played falls back to that file.
394
-
395
- The widget leaves your app's audio session alone, so notes follow your
396
- expo-audio settings. On iOS that means they're muted while the ringer switch is
397
- on silent, unless your app turns that on:
398
-
399
- ```ts
400
- import { setAudioModeAsync } from 'expo-audio';
401
-
402
- await setAudioModeAsync({ playsInSilentMode: true });
403
- ```
404
-
405
- On iOS, `setAudioModeAsync` resets any field you leave out to its default, so
406
- pass the rest of your app's audio mode along with it.
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
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["RepzoWidgetAudioModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.repzowidget.RepzoWidgetAudioModule"]
8
+ }
9
+ }