@repzo/widget-react-native 1.2.1 → 1.2.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 (117) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +31 -0
  3. package/lib/commonjs/api/client.js +47 -2
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/audio/voice.js +119 -0
  6. package/lib/commonjs/audio/voice.js.map +1 -0
  7. package/lib/commonjs/i18n.js +6 -0
  8. package/lib/commonjs/i18n.js.map +1 -1
  9. package/lib/commonjs/locales.js +4 -0
  10. package/lib/commonjs/locales.js.map +1 -1
  11. package/lib/commonjs/state/use-widget-state.js +31 -0
  12. package/lib/commonjs/state/use-widget-state.js.map +1 -1
  13. package/lib/commonjs/state/widget-store.js +22 -1
  14. package/lib/commonjs/state/widget-store.js.map +1 -1
  15. package/lib/commonjs/ui/WidgetProvider.js +6 -0
  16. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  17. package/lib/commonjs/ui/components/FileCard.js +134 -0
  18. package/lib/commonjs/ui/components/FileCard.js.map +1 -0
  19. package/lib/commonjs/ui/components/Launcher.js +15 -1
  20. package/lib/commonjs/ui/components/Launcher.js.map +1 -1
  21. package/lib/commonjs/ui/components/VoiceNote.js +354 -0
  22. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -0
  23. package/lib/commonjs/ui/components/icons.js +57 -0
  24. package/lib/commonjs/ui/components/icons.js.map +1 -1
  25. package/lib/commonjs/ui/screens/chat/ChatView.js +33 -14
  26. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  27. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +0 -9
  28. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  29. package/lib/module/api/client.js +48 -2
  30. package/lib/module/api/client.js.map +1 -1
  31. package/lib/module/audio/voice.js +110 -0
  32. package/lib/module/audio/voice.js.map +1 -0
  33. package/lib/module/i18n.js +6 -0
  34. package/lib/module/i18n.js.map +1 -1
  35. package/lib/module/locales.js +4 -0
  36. package/lib/module/locales.js.map +1 -1
  37. package/lib/module/state/use-widget-state.js +30 -1
  38. package/lib/module/state/use-widget-state.js.map +1 -1
  39. package/lib/module/state/widget-store.js +22 -1
  40. package/lib/module/state/widget-store.js.map +1 -1
  41. package/lib/module/ui/WidgetProvider.js +6 -0
  42. package/lib/module/ui/WidgetProvider.js.map +1 -1
  43. package/lib/module/ui/components/FileCard.js +130 -0
  44. package/lib/module/ui/components/FileCard.js.map +1 -0
  45. package/lib/module/ui/components/Launcher.js +16 -2
  46. package/lib/module/ui/components/Launcher.js.map +1 -1
  47. package/lib/module/ui/components/VoiceNote.js +350 -0
  48. package/lib/module/ui/components/VoiceNote.js.map +1 -0
  49. package/lib/module/ui/components/icons.js +55 -0
  50. package/lib/module/ui/components/icons.js.map +1 -1
  51. package/lib/module/ui/screens/chat/ChatView.js +33 -14
  52. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  53. package/lib/module/ui/screens/chat/ChatView.styles.js +0 -9
  54. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  55. package/lib/typescript/commonjs/api/client.d.ts +12 -0
  56. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/audio/voice.d.ts +29 -0
  58. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/i18n.d.ts +4 -0
  60. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/state/use-widget-state.d.ts +8 -0
  63. package/lib/typescript/commonjs/state/use-widget-state.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/state/widget-store.d.ts +2 -0
  65. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/components/FileCard.d.ts +21 -0
  68. package/lib/typescript/commonjs/ui/components/FileCard.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +32 -0
  71. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/components/icons.d.ts +4 -0
  73. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +0 -9
  76. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  78. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  79. package/lib/typescript/module/api/client.d.ts +12 -0
  80. package/lib/typescript/module/api/client.d.ts.map +1 -1
  81. package/lib/typescript/module/audio/voice.d.ts +29 -0
  82. package/lib/typescript/module/audio/voice.d.ts.map +1 -0
  83. package/lib/typescript/module/i18n.d.ts +4 -0
  84. package/lib/typescript/module/i18n.d.ts.map +1 -1
  85. package/lib/typescript/module/locales.d.ts.map +1 -1
  86. package/lib/typescript/module/state/use-widget-state.d.ts +8 -0
  87. package/lib/typescript/module/state/use-widget-state.d.ts.map +1 -1
  88. package/lib/typescript/module/state/widget-store.d.ts +2 -0
  89. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/components/FileCard.d.ts +21 -0
  92. package/lib/typescript/module/ui/components/FileCard.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/components/VoiceNote.d.ts +32 -0
  95. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -0
  96. package/lib/typescript/module/ui/components/icons.d.ts +4 -0
  97. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  99. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +0 -9
  100. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  101. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  102. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  103. package/package.json +5 -1
  104. package/src/api/client.ts +56 -2
  105. package/src/audio/voice.ts +103 -0
  106. package/src/i18n.ts +6 -0
  107. package/src/locales.ts +4 -0
  108. package/src/state/use-widget-state.ts +30 -1
  109. package/src/state/widget-store.ts +22 -1
  110. package/src/ui/WidgetProvider.tsx +6 -0
  111. package/src/ui/components/FileCard.tsx +111 -0
  112. package/src/ui/components/Launcher.tsx +7 -2
  113. package/src/ui/components/VoiceNote.tsx +363 -0
  114. package/src/ui/components/icons.tsx +33 -0
  115. package/src/ui/screens/chat/ChatView.styles.ts +0 -9
  116. package/src/ui/screens/chat/ChatView.tsx +39 -13
  117. package/src/ui/screens/chat/ChatView.types.ts +4 -0
package/src/api/client.ts CHANGED
@@ -62,6 +62,16 @@ export class RepzoWidgetError extends Error {
62
62
  }
63
63
  }
64
64
 
65
+ /** A Send message part asked for on its own, which may share one request with others. */
66
+ type SendPart = 'post' | 'outbound';
67
+
68
+ interface SendPartAsk {
69
+ part: SendPart;
70
+ own: () => Promise<unknown>;
71
+ resolve: (value: unknown) => void;
72
+ reject: (error: unknown) => void;
73
+ }
74
+
65
75
  interface RequestOptions {
66
76
  method?: 'GET' | 'POST' | 'DELETE';
67
77
  body?: unknown;
@@ -92,6 +102,9 @@ export class WidgetApiClient {
92
102
  /** Set by the store: a 403 with a terminal `code` came back from any endpoint. */
93
103
  onTerminalError: ((error: RepzoWidgetError) => void) | null = null;
94
104
 
105
+ /** Send parts asked for in this tick — see askSendPart. */
106
+ private sendPartBatch: SendPartAsk[] | null = null;
107
+
95
108
  configure(baseUrl: string, channelAccountId: string) {
96
109
  this.baseUrl = baseUrl.replace(/\/$/, '');
97
110
  this.channelAccountId = channelAccountId;
@@ -194,7 +207,9 @@ export class WidgetApiClient {
194
207
  }
195
208
 
196
209
  getOutboundMessage(): Promise<{ message: OutboundMessage | null }> {
197
- return this.request(`/api/widget/outbound/${encodeURIComponent(this.channelAccountId)}`);
210
+ return this.askSendPart('outbound', () =>
211
+ this.request(`/api/widget/outbound/${encodeURIComponent(this.channelAccountId)}`)
212
+ );
198
213
  }
199
214
 
200
215
  /**
@@ -202,7 +217,46 @@ export class WidgetApiClient {
202
217
  * `recheckInMs`: ask again after this long — the shown one stops then.
203
218
  */
204
219
  getPost(): Promise<{ post: WidgetPost | null; recheckInMs?: number | null }> {
205
- return this.request('/api/widget/send/post?platform=mobile');
220
+ return this.askSendPart('post', () => this.request('/api/widget/send/post?platform=mobile'));
221
+ }
222
+
223
+ /**
224
+ * The post and the launcher's chat message are both asked for right after a
225
+ * session starts: asks made in the same tick go out as one request (each
226
+ * part answers the way its own endpoint does). A lone ask, a Workstation
227
+ * deployment that doesn't know a part yet (it leaves it out — the
228
+ * `outbound` part is newer than the combined endpoint), or a failed combined
229
+ * request falls back to each part's own endpoint, as before.
230
+ */
231
+ private askSendPart<T>(part: SendPart, own: () => Promise<T>): Promise<T> {
232
+ return new Promise<T>((resolve, reject) => {
233
+ if (!this.sendPartBatch) {
234
+ this.sendPartBatch = [];
235
+ setTimeout(() => void this.flushSendParts(), 0);
236
+ }
237
+ this.sendPartBatch.push({ part, own, resolve: resolve as (value: unknown) => void, reject });
238
+ });
239
+ }
240
+
241
+ private async flushSendParts(): Promise<void> {
242
+ const asks = this.sendPartBatch ?? [];
243
+ this.sendPartBatch = null;
244
+ const parts = Array.from(new Set(asks.map((ask) => ask.part)));
245
+ let answer: Record<string, unknown> | null = null;
246
+ if (parts.length > 1) {
247
+ try {
248
+ answer = await this.request<Record<string, unknown>>(
249
+ `/api/widget/send/messages?include=${parts.join(',')}&platform=mobile`
250
+ );
251
+ } catch {
252
+ answer = null;
253
+ }
254
+ }
255
+ for (const ask of asks) {
256
+ const value = answer?.[ask.part];
257
+ if (value && typeof value === 'object') ask.resolve(value);
258
+ else ask.own().then(ask.resolve, ask.reject);
259
+ }
206
260
  }
207
261
 
208
262
  /**
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Voice notes (and any audio file) play inside the chat when the host app has
3
+ * `expo-audio`; without it they stay a file row that opens in the browser.
4
+ *
5
+ * Resolved once at module load, like the attachment pickers: each `require` is
6
+ * static so Metro can see it, and with `allowOptionalDependencies` (see
7
+ * metro.js) an uninstalled one bundles as a require that throws, which the
8
+ * `try` around it catches.
9
+ */
10
+
11
+ let expoAudio: any = null;
12
+ try {
13
+ const mod = require('expo-audio');
14
+ if (typeof mod?.useAudioPlayer === 'function' && typeof mod?.useAudioPlayerStatus === 'function') {
15
+ expoAudio = mod;
16
+ }
17
+ } catch {
18
+ expoAudio = null;
19
+ }
20
+
21
+ // expo-audio 1.x (Expo SDK 54+) takes `{ updateInterval }`; 0.x (SDK 53) took
22
+ // the interval itself. Passing the wrong shape would break the player.
23
+ let takesOptionsObject = true;
24
+ try {
25
+ const version: unknown = require('expo-audio/package.json')?.version;
26
+ if (typeof version === 'string') takesOptionsObject = Number(version.split('.')[0]) >= 1;
27
+ } catch {
28
+ takesOptionsObject = true;
29
+ }
30
+
31
+ /** How often the playhead moves while a note plays. */
32
+ const UPDATE_INTERVAL_MS = 100;
33
+
34
+ /** The installed expo-audio module, or null when the app doesn't have it. */
35
+ export function voicePlaybackModule(): any {
36
+ return expoAudio;
37
+ }
38
+
39
+ /** The second argument to `useAudioPlayer` for the installed expo-audio. */
40
+ export function audioPlayerOptions(): unknown {
41
+ return takesOptionsObject ? { updateInterval: UPDATE_INTERVAL_MS } : UPDATE_INTERVAL_MS;
42
+ }
43
+
44
+ /** Audio the chat can play in place: an `audio/*` attachment with a web address. */
45
+ export function isPlayableAudio(contentType: string | null | undefined, url: string | null | undefined): boolean {
46
+ return !!expoAudio && (contentType ?? '').toLowerCase().startsWith('audio/') && /^https?:\/\//i.test(url ?? '');
47
+ }
48
+
49
+ // ─── One note at a time ─────────────────────────────────────────────────────
50
+ // Each bubble owns its own player, so starting a second note would otherwise
51
+ // talk over the first.
52
+
53
+ let playing: { pause: () => void } | null = null;
54
+
55
+ /**
56
+ * Called as a note starts: pauses whichever note was playing and returns a
57
+ * release for this one's claim (call it when the note goes away).
58
+ */
59
+ export function claimVoicePlayback(pause: () => void): () => void {
60
+ const claim = { pause };
61
+ const previous = playing;
62
+ playing = claim;
63
+ if (previous) {
64
+ try {
65
+ previous.pause();
66
+ } catch {
67
+ // Its player was released with its bubble: nothing is playing.
68
+ }
69
+ }
70
+ return () => {
71
+ if (playing === claim) playing = null;
72
+ };
73
+ }
74
+
75
+ // ─── Waveform ───────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Bar heights (0.15–1) for a note's waveform. Workstation doesn't send the
79
+ * recorded loudness to visitors, so the pattern is made up — seeded from the
80
+ * attachment, so the same note always draws the same bars.
81
+ */
82
+ export function voiceWaveformLevels(seed: string, count: number): number[] {
83
+ let h = 2166136261;
84
+ for (let i = 0; i < seed.length; i += 1) {
85
+ h ^= seed.charCodeAt(i);
86
+ h = Math.imul(h, 16777619);
87
+ }
88
+ const levels: number[] = [];
89
+ for (let i = 0; i < count; i += 1) {
90
+ h ^= h << 13;
91
+ h ^= h >>> 17;
92
+ h ^= h << 5;
93
+ const n = ((h >>> 0) % 1000) / 1000;
94
+ levels.push(0.15 + Math.pow(n, 0.85) * 0.85);
95
+ }
96
+ return levels;
97
+ }
98
+
99
+ /** `m:ss` for a playback position or length. */
100
+ export function formatVoiceClock(seconds: number): string {
101
+ const s = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
102
+ return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
103
+ }
package/src/i18n.ts CHANGED
@@ -70,6 +70,12 @@ export const DEFAULT_LABELS = {
70
70
  attachmentsTooLargeTogether:
71
71
  'Attachments are too large to send together — send some in another message',
72
72
  attachmentFailed: "Couldn't attach that file",
73
+ // A file in a message; tapping opens it. `{name}` is the file name.
74
+ openAttachment: 'Open {name}',
75
+ // Voice notes played in the chat (with expo-audio).
76
+ voicePlay: 'Play voice message',
77
+ voicePause: 'Pause voice message',
78
+ voiceSpeed: 'Playback speed',
73
79
  // Full-screen image viewer.
74
80
  imageLoadFailed: "Couldn't load this image",
75
81
  openImageExternally: 'Open image',
package/src/locales.ts CHANGED
@@ -29,6 +29,10 @@ const ar: LabelMap = {
29
29
  attachmentTooMany: 'يمكنك إرفاق {count} ملفات كحد أقصى',
30
30
  attachmentsTooLargeTogether: 'المرفقات كبيرة جدًا لإرسالها معًا — أرسل بعضها في رسالة أخرى',
31
31
  attachmentFailed: 'تعذر إرفاق هذا الملف',
32
+ openAttachment: 'فتح {name}',
33
+ voicePlay: 'تشغيل الرسالة الصوتية',
34
+ voicePause: 'إيقاف الرسالة الصوتية مؤقتًا',
35
+ voiceSpeed: 'سرعة التشغيل',
32
36
  imageLoadFailed: 'تعذر تحميل هذه الصورة',
33
37
  openImageExternally: 'فتح الصورة',
34
38
  cameraPermissionDenied: 'اسمح بالوصول إلى الكاميرا من الإعدادات لالتقاط صورة',
@@ -4,7 +4,7 @@
4
4
  * component is mounted; this hook only subscribes to it.
5
5
  */
6
6
 
7
- import { useSyncExternalStore } from 'react';
7
+ import { useRef, useSyncExternalStore } from 'react';
8
8
 
9
9
  import { controller } from '../controller';
10
10
  import type { WidgetState } from '../types';
@@ -18,3 +18,32 @@ export function useWidgetState(): WidgetState {
18
18
  export function useWidgetStore(): WidgetStore {
19
19
  return controller.store;
20
20
  }
21
+
22
+ /**
23
+ * A slice of the state that re-renders only when the slice changes (by
24
+ * `isEqual`) — for UI that lives on the host's own screens, like the launcher,
25
+ * which shouldn't redraw on every message or typing event. Internal.
26
+ */
27
+ export function useWidgetSelector<T>(
28
+ select: (state: WidgetState) => T,
29
+ isEqual: (previous: T, next: T) => boolean
30
+ ): T {
31
+ const store = controller.store;
32
+ const last = useRef<{ state: WidgetState; value: T } | null>(null);
33
+ const getSnapshot = () => {
34
+ const state = store.getState();
35
+ const previous = last.current;
36
+ if (previous && previous.state === state) return previous.value;
37
+ const value = select(state);
38
+ // Same slice from a newer state: keep the old value so React bails out.
39
+ last.current = { state, value: previous && isEqual(previous.value, value) ? previous.value : value };
40
+ return last.current.value;
41
+ };
42
+ return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
43
+ }
44
+
45
+ /** Same keys, same values (by identity). */
46
+ export function shallowEqual<T extends Record<string, unknown>>(previous: T, next: T): boolean {
47
+ const keys = Object.keys(previous);
48
+ return keys.length === Object.keys(next).length && keys.every((key) => Object.is(previous[key], next[key]));
49
+ }
@@ -40,7 +40,7 @@ import type {
40
40
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
41
41
 
42
42
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
43
- const SDK_VERSION = '@repzo/widget-react-native@1.2.1';
43
+ const SDK_VERSION = '@repzo/widget-react-native@1.2.2';
44
44
 
45
45
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
46
46
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -67,6 +67,13 @@ const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
67
67
  const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
68
68
  /** Live replies in the open thread advance the server read cursor, batched. */
69
69
  const MARK_READ_DEBOUNCE_MS = 1_000;
70
+ /**
71
+ * Back from the background sooner than this, availability and the post are
72
+ * still current enough to skip re-asking. Reps hop to maps or the camera and
73
+ * back all day; the list and the open thread still catch up every time, since
74
+ * the stream was closed meanwhile.
75
+ */
76
+ const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
70
77
  /** Live events held back while a draft's first send is creating its thread. */
71
78
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
72
79
  /** How long a post stays up after a reaction, thanking them, before it goes. */
@@ -211,6 +218,9 @@ export class WidgetStore {
211
218
 
212
219
  private appStateSubscription: { remove(): void } | null = null;
213
220
 
221
+ /** When the app last went to the background (0: hasn't this session). */
222
+ private backgroundedAt = 0;
223
+
214
224
  private lastTypingPingAt = 0;
215
225
 
216
226
  private visitorTypingActive = false;
@@ -914,10 +924,14 @@ export class WidgetStore {
914
924
 
915
925
  private watchAppState() {
916
926
  if (this.appStateSubscription) return;
927
+ // Booted while already in the background (a push woke the app): coming
928
+ // to the foreground is a return from the background too.
929
+ if (AppState.currentState === 'background') this.backgroundedAt = Date.now();
917
930
  this.appStateSubscription = AppState.addEventListener('change', (status: AppStateStatus) => {
918
931
  if (status === 'active') {
919
932
  void this.resumeFromBackground();
920
933
  } else if (status === 'background') {
934
+ this.backgroundedAt = Date.now();
921
935
  this.realtime.close();
922
936
  }
923
937
  });
@@ -931,6 +945,12 @@ export class WidgetStore {
931
945
  if (this.state.error) await this.retry();
932
946
  return;
933
947
  }
948
+ // Only the background closes the stream. Back from merely inactive (a
949
+ // system sheet, Face ID, the notification shade) it never stopped, so
950
+ // there is nothing to catch up on.
951
+ if (!this.backgroundedAt) return;
952
+ const awayMs = Date.now() - this.backgroundedAt;
953
+ this.backgroundedAt = 0;
934
954
  const epoch = this.epoch;
935
955
  const token = await this.client.getToken();
936
956
  if (!token || this.isStale(epoch)) return;
@@ -938,6 +958,7 @@ export class WidgetStore {
938
958
  // The stream had a gap: refresh the list so unread/snippets are correct,
939
959
  // and the thread on screen so replies sent meanwhile show up.
940
960
  await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
961
+ if (awayMs < FOREGROUND_REFRESH_AFTER_MS) return;
941
962
  await this.refreshConfig();
942
963
  // A post may have gone live (or ended) while the app was away.
943
964
  if (!this.isStale(epoch)) this.loadPost();
@@ -182,6 +182,7 @@ export function RepzoWidgetProvider({
182
182
  emptyConversation: labels.emptyConversation,
183
183
  messagePlaceholder: labels.messagePlaceholder,
184
184
  failedToSend: labels.failedToSend,
185
+ openAttachment: labels.openAttachment,
185
186
  thinking: labels.thinking,
186
187
  team: labels.team,
187
188
  statusOnline: labels.homeStatusOnline,
@@ -224,6 +225,11 @@ export function RepzoWidgetProvider({
224
225
  loadFailed: labels.imageLoadFailed,
225
226
  openExternally: labels.openImageExternally,
226
227
  },
228
+ voice: {
229
+ play: labels.voicePlay,
230
+ pause: labels.voicePause,
231
+ speed: labels.voiceSpeed,
232
+ },
227
233
  }),
228
234
  [labels, blockLabels, attachLabels]
229
235
  );
@@ -0,0 +1,111 @@
1
+ /**
2
+ * An attachment that opens outside the chat (PDF, video, sheet…), drawn as a
3
+ * card that reads as tappable: a tile with the file's type, its name, the
4
+ * type and size under it, and an arrow — instead of a bare name in a box.
5
+ */
6
+
7
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
8
+
9
+ import { formatBytes } from '../../attachments/pickers';
10
+ import { ChevronIcon, PlayIcon } from './icons';
11
+ import { fontSize, palette, radius, spacing, withAlpha } from '../styles/theme';
12
+ import { forwardDirection, START_ALIGN } from '../utils/rtl';
13
+
14
+ /** `PDF`, `MP4`, `XLSX` — from the name, else the MIME subtype; at most 4 letters. */
15
+ export function fileTypeLabel(name: string, contentType: string | null | undefined): string {
16
+ const fromName = /\.([a-z0-9]{1,8})$/i.exec(name.trim())?.[1];
17
+ const fromType = (contentType ?? '').split('/')[1]?.split(/[;+.-]/)[0];
18
+ const label = (fromName || fromType || 'file').toUpperCase();
19
+ return label.length > 4 ? label.slice(0, 4) : label;
20
+ }
21
+
22
+ export function FileCard({
23
+ name,
24
+ contentType,
25
+ size,
26
+ color,
27
+ onBrandBubble,
28
+ openLabel,
29
+ onPress,
30
+ }: {
31
+ name: string;
32
+ contentType: string;
33
+ size: number;
34
+ /** The workspace color. */
35
+ color: string;
36
+ /** In a visitor bubble, which is filled with `color`: the card turns white-on-color. */
37
+ onBrandBubble: boolean;
38
+ /** Screen-reader label, e.g. "Open report.pdf". */
39
+ openLabel: string;
40
+ /** Missing while there's nothing to open yet (an unsent file). */
41
+ onPress?: () => void;
42
+ }) {
43
+ const type = fileTypeLabel(name, contentType);
44
+ const isVideo = (contentType ?? '').toLowerCase().startsWith('video/');
45
+ const meta = size > 0 ? `${type} · ${formatBytes(size)}` : type;
46
+ const accent = onBrandBubble ? palette.white : color;
47
+
48
+ return (
49
+ <Pressable
50
+ onPress={onPress}
51
+ disabled={!onPress}
52
+ accessibilityRole="button"
53
+ accessibilityLabel={openLabel}
54
+ style={({ pressed }) => [
55
+ styles.card,
56
+ onBrandBubble
57
+ ? { backgroundColor: withAlpha(palette.white, 0.16), borderColor: withAlpha(palette.white, 0.35) }
58
+ : { backgroundColor: palette.white, borderColor: palette.border },
59
+ !onPress && styles.unavailable,
60
+ pressed && styles.pressed,
61
+ ]}>
62
+ <View style={[styles.tile, { backgroundColor: onBrandBubble ? palette.white : withAlpha(color, 0.12) }]}>
63
+ {isVideo ? (
64
+ <PlayIcon size={16} color={color} />
65
+ ) : (
66
+ <Text style={[styles.tileLabel, { color }]} numberOfLines={1}>
67
+ {type}
68
+ </Text>
69
+ )}
70
+ </View>
71
+
72
+ <View style={styles.text}>
73
+ <Text style={[styles.name, { color: onBrandBubble ? palette.white : palette.ink }]} numberOfLines={2}>
74
+ {name}
75
+ </Text>
76
+ <Text style={[styles.meta, { color: onBrandBubble ? withAlpha(palette.white, 0.8) : palette.textMuted }]}>
77
+ {meta}
78
+ </Text>
79
+ </View>
80
+
81
+ {onPress ? <ChevronIcon size={14} color={accent} direction={forwardDirection()} /> : null}
82
+ </Pressable>
83
+ );
84
+ }
85
+
86
+ const styles = StyleSheet.create({
87
+ card: {
88
+ flexDirection: 'row',
89
+ alignItems: 'center',
90
+ gap: spacing.sm + 2,
91
+ minWidth: 220,
92
+ paddingVertical: spacing.sm + 1,
93
+ paddingHorizontal: spacing.sm + 2,
94
+ borderWidth: StyleSheet.hairlineWidth,
95
+ borderRadius: radius.lg,
96
+ },
97
+ pressed: { opacity: 0.7 },
98
+ unavailable: { opacity: 0.6 },
99
+ tile: {
100
+ width: 40,
101
+ height: 40,
102
+ borderRadius: radius.sm,
103
+ alignItems: 'center',
104
+ justifyContent: 'center',
105
+ },
106
+ tileLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.3 },
107
+ text: { flex: 1, minWidth: 0, gap: 2 },
108
+ // A Latin file name in an Arabic chat still lines up with the card's start.
109
+ name: { fontSize: fontSize.meta + 1, fontWeight: '600', textAlign: START_ALIGN },
110
+ meta: { fontSize: fontSize.caption, textAlign: START_ALIGN },
111
+ });
@@ -10,7 +10,7 @@
10
10
  import { Pressable, StyleSheet, Text, View } from 'react-native';
11
11
 
12
12
  import { createTranslator, DEFAULT_LABELS } from '../../i18n';
13
- import { useWidgetState } from '../../state/use-widget-state';
13
+ import { shallowEqual, useWidgetSelector } from '../../state/use-widget-state';
14
14
  import { controller } from '../../controller';
15
15
  import { ChatIcon, ChevronIcon } from './icons';
16
16
  import {
@@ -37,7 +37,12 @@ export function Launcher({
37
37
  horizontalPadding,
38
38
  hidden = false,
39
39
  }: LauncherProps) {
40
- const state = useWidgetState();
40
+ // Only what the launcher draws: it sits on the host's screens and shouldn't
41
+ // redraw on every message or typing event.
42
+ const state = useWidgetSelector(
43
+ ({ booted, config, isOpen, locale, unreadCount }) => ({ booted, config, isOpen, locale, unreadCount }),
44
+ shallowEqual
45
+ );
41
46
  const config = state.config;
42
47
 
43
48
  if (hidden || !state.booted || config?.showLauncher === false) return null;