@repzo/widget-react-native 1.0.1 → 1.0.5

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 (131) hide show
  1. package/lib/commonjs/api/client.js +3 -1
  2. package/lib/commonjs/api/client.js.map +1 -1
  3. package/lib/commonjs/api/realtime.js +2 -0
  4. package/lib/commonjs/api/realtime.js.map +1 -1
  5. package/lib/commonjs/i18n.js +26 -0
  6. package/lib/commonjs/i18n.js.map +1 -1
  7. package/lib/commonjs/locales.js +5 -0
  8. package/lib/commonjs/locales.js.map +1 -1
  9. package/lib/commonjs/state/widget-store.js +129 -10
  10. package/lib/commonjs/state/widget-store.js.map +1 -1
  11. package/lib/commonjs/types.js +2 -0
  12. package/lib/commonjs/types.js.map +1 -1
  13. package/lib/commonjs/ui/WidgetProvider.js +97 -66
  14. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  15. package/lib/commonjs/ui/components/Gradient.js +18 -2
  16. package/lib/commonjs/ui/components/Gradient.js.map +1 -1
  17. package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
  18. package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
  19. package/lib/commonjs/ui/components/icons.js +50 -0
  20. package/lib/commonjs/ui/components/icons.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/use-top-inset.js +22 -0
  22. package/lib/commonjs/ui/hooks/use-top-inset.js.map +1 -0
  23. package/lib/commonjs/ui/screens/chat/ChatView.js +113 -7
  24. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  25. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +51 -0
  26. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  27. package/lib/commonjs/ui/screens/home/HomeView.js +42 -9
  28. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  29. package/lib/commonjs/ui/screens/home/HomeView.styles.js +5 -3
  30. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  31. package/lib/module/api/client.js +3 -1
  32. package/lib/module/api/client.js.map +1 -1
  33. package/lib/module/api/realtime.js +2 -0
  34. package/lib/module/api/realtime.js.map +1 -1
  35. package/lib/module/i18n.js +25 -0
  36. package/lib/module/i18n.js.map +1 -1
  37. package/lib/module/locales.js +5 -0
  38. package/lib/module/locales.js.map +1 -1
  39. package/lib/module/state/widget-store.js +129 -10
  40. package/lib/module/state/widget-store.js.map +1 -1
  41. package/lib/module/types.js +2 -0
  42. package/lib/module/types.js.map +1 -1
  43. package/lib/module/ui/WidgetProvider.js +101 -70
  44. package/lib/module/ui/WidgetProvider.js.map +1 -1
  45. package/lib/module/ui/components/Gradient.js +18 -2
  46. package/lib/module/ui/components/Gradient.js.map +1 -1
  47. package/lib/module/ui/components/TeamAvatars.js +59 -0
  48. package/lib/module/ui/components/TeamAvatars.js.map +1 -0
  49. package/lib/module/ui/components/icons.js +49 -0
  50. package/lib/module/ui/components/icons.js.map +1 -1
  51. package/lib/module/ui/hooks/use-top-inset.js +17 -0
  52. package/lib/module/ui/hooks/use-top-inset.js.map +1 -0
  53. package/lib/module/ui/screens/chat/ChatView.js +115 -9
  54. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  55. package/lib/module/ui/screens/chat/ChatView.styles.js +51 -0
  56. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  57. package/lib/module/ui/screens/home/HomeView.js +44 -12
  58. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  59. package/lib/module/ui/screens/home/HomeView.styles.js +4 -2
  60. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  61. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/i18n.d.ts +7 -0
  64. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/state/widget-store.d.ts +24 -0
  67. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/types.d.ts +14 -1
  69. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/components/Gradient.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
  73. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
  75. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/hooks/use-top-inset.d.ts +11 -0
  77. package/lib/typescript/commonjs/ui/hooks/use-top-inset.d.ts.map +1 -0
  78. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
  79. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +51 -0
  81. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +13 -0
  83. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -0
  85. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +3 -1
  87. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  88. package/lib/typescript/module/api/client.d.ts.map +1 -1
  89. package/lib/typescript/module/api/realtime.d.ts.map +1 -1
  90. package/lib/typescript/module/i18n.d.ts +7 -0
  91. package/lib/typescript/module/i18n.d.ts.map +1 -1
  92. package/lib/typescript/module/locales.d.ts.map +1 -1
  93. package/lib/typescript/module/state/widget-store.d.ts +24 -0
  94. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  95. package/lib/typescript/module/types.d.ts +14 -1
  96. package/lib/typescript/module/types.d.ts.map +1 -1
  97. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/components/Gradient.d.ts.map +1 -1
  99. package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
  100. package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
  101. package/lib/typescript/module/ui/components/icons.d.ts +2 -0
  102. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  103. package/lib/typescript/module/ui/hooks/use-top-inset.d.ts +11 -0
  104. package/lib/typescript/module/ui/hooks/use-top-inset.d.ts.map +1 -0
  105. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
  106. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  107. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +51 -0
  108. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  109. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +13 -0
  110. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  111. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -0
  112. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  113. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +3 -1
  114. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  115. package/package.json +1 -1
  116. package/src/api/client.ts +3 -1
  117. package/src/api/realtime.ts +2 -0
  118. package/src/i18n.ts +28 -0
  119. package/src/locales.ts +5 -0
  120. package/src/state/widget-store.ts +124 -8
  121. package/src/types.ts +16 -0
  122. package/src/ui/WidgetProvider.tsx +66 -7
  123. package/src/ui/components/Gradient.tsx +19 -2
  124. package/src/ui/components/TeamAvatars.tsx +58 -0
  125. package/src/ui/components/icons.tsx +37 -0
  126. package/src/ui/hooks/use-top-inset.ts +16 -0
  127. package/src/ui/screens/chat/ChatView.styles.ts +51 -0
  128. package/src/ui/screens/chat/ChatView.tsx +120 -22
  129. package/src/ui/screens/chat/ChatView.types.ts +13 -0
  130. package/src/ui/screens/home/HomeView.styles.ts +4 -2
  131. package/src/ui/screens/home/HomeView.tsx +36 -8
@@ -35,7 +35,7 @@ import type {
35
35
  } from '../types';
36
36
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
37
37
 
38
- const SDK_VERSION = '@repzo/widget-react-native@1.0.1';
38
+ const SDK_VERSION = '@repzo/widget-react-native@1.0.5';
39
39
 
40
40
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
41
41
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -168,6 +168,8 @@ export class WidgetStore {
168
168
  private visitorTypingActive = false;
169
169
 
170
170
  private seenMessageIds: string[] = [];
171
+ /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
172
+ private earlyStatuses = new Map<string, string>();
171
173
 
172
174
  /**
173
175
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -487,7 +489,7 @@ export class WidgetStore {
487
489
  loading: false,
488
490
  error: null,
489
491
  errorCode: null,
490
- config: result.config,
492
+ config: this.hydrateConfig(result.config),
491
493
  identityId: result.identityId,
492
494
  conversations,
493
495
  // Also seeds the launcher badge, so a reply that landed while the app
@@ -510,7 +512,7 @@ export class WidgetStore {
510
512
  },
511
513
  }));
512
514
 
513
- this.persistConfig(result.config);
515
+ this.persistConfig(this.hydrateConfig(result.config));
514
516
  this.connectRealtime(result.token);
515
517
  this.watchAppState();
516
518
  this.loadOutboundMessage();
@@ -556,13 +558,28 @@ export class WidgetStore {
556
558
  cached.platform === sessionPlatform() &&
557
559
  !this.state.config
558
560
  ) {
559
- this.setState({ config: cached.config });
561
+ this.setState({ config: this.hydrateConfig(cached.config) });
560
562
  }
561
563
  } catch {
562
564
  // Corrupt cache — the network config overwrites it shortly.
563
565
  }
564
566
  }
565
567
 
568
+ /**
569
+ * The server sends the home logo and background image as paths relative to
570
+ * Workstation (`/api/media/:id/download`), which a React Native Image can't
571
+ * load. Resolved against the boot `baseUrl` they become full links (public
572
+ * media redirects to the CDN). Absolute URLs pass through unchanged.
573
+ */
574
+ private hydrateConfig(config: WidgetConfig): WidgetConfig {
575
+ if (!config) return config;
576
+ return {
577
+ ...config,
578
+ homeLogoUrl: this.client.resolveUrl(config.homeLogoUrl),
579
+ homeBackgroundImageUrl: this.client.resolveUrl(config.homeBackgroundImageUrl),
580
+ };
581
+ }
582
+
566
583
  private persistConfig(config: WidgetConfig): void {
567
584
  if (!this.bootConfig) return;
568
585
  const entry: CachedConfig = {
@@ -888,7 +905,7 @@ export class WidgetStore {
888
905
 
889
906
  private handleRealtimeEvent(event: string, data: any) {
890
907
  if (
891
- (event === 'message.new' || event === 'conversation.event') &&
908
+ (event === 'message.new' || event === 'message.deleted' || event === 'message.status' || event === 'conversation.event') &&
892
909
  this.shouldHoldForDraft(data?.conversationId)
893
910
  ) {
894
911
  this.draftEvents.push({ event, data });
@@ -915,6 +932,14 @@ export class WidgetStore {
915
932
  this.handleNewMessage(data);
916
933
  break;
917
934
 
935
+ case 'message.deleted':
936
+ this.handleDeletedMessage(data);
937
+ break;
938
+
939
+ case 'message.status':
940
+ this.handleMessageStatus(data);
941
+ break;
942
+
918
943
  case 'conversation.updated':
919
944
  this.rememberConversationAgent(data);
920
945
  this.setState((current) => ({
@@ -927,6 +952,7 @@ export class WidgetStore {
927
952
  data.agentAvatar === undefined
928
953
  ? conversation.agentAvatar
929
954
  : this.client.resolveUrl(data.agentAvatar),
955
+ agentOnline: data.agentOnline === undefined ? conversation.agentOnline : data.agentOnline,
930
956
  }
931
957
  : conversation
932
958
  ),
@@ -1044,6 +1070,50 @@ export class WidgetStore {
1044
1070
  return false;
1045
1071
  }
1046
1072
 
1073
+ /**
1074
+ * A teammate deleted a message: the visitor sees "This message was deleted"
1075
+ * in its place (they can't delete anything themselves), and the list is
1076
+ * refreshed so its preview stops showing the text. The id stays in
1077
+ * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1078
+ */
1079
+ /**
1080
+ * A teammate opened the conversation: the visitor's messages are now Seen.
1081
+ * The ids are also remembered, because the event can arrive before the send
1082
+ * response swaps a pending bubble's temporary id for the server id.
1083
+ */
1084
+ private handleMessageStatus(data: any) {
1085
+ const ids: string[] = Array.isArray(data?.messageIds) ? data.messageIds : [];
1086
+ const status: string | undefined = typeof data?.status === 'string' ? data.status : undefined;
1087
+ if (!status || ids.length === 0) return;
1088
+ for (const id of ids) this.earlyStatuses.set(id, status);
1089
+ if (this.earlyStatuses.size > SEEN_MESSAGE_LIMIT) {
1090
+ this.earlyStatuses.delete(this.earlyStatuses.keys().next().value as string);
1091
+ }
1092
+ if (!this.isThreadOnScreen(data?.conversationId)) return;
1093
+ this.setState((current) => ({
1094
+ messages: current.messages.map((message) =>
1095
+ ids.includes(message.id) ? { ...message, status } : message
1096
+ ),
1097
+ }));
1098
+ }
1099
+
1100
+ private handleDeletedMessage(data: any) {
1101
+ const messageId: string | undefined = data?.messageId;
1102
+ if (!messageId) return;
1103
+ if (!this.seenMessageIds.includes(messageId)) this.isReplayedMessage(messageId);
1104
+ if (this.isThreadOnScreen(data?.conversationId)) {
1105
+ this.setState((current) => ({
1106
+ messages: current.messages.map((message) =>
1107
+ message.id === messageId ? asDeletedPlaceholder(message, new Date().toISOString()) : message
1108
+ ),
1109
+ }));
1110
+ }
1111
+ if (this.state.inAppNotification?.messageId === messageId) {
1112
+ this.setState({ inAppNotification: null });
1113
+ }
1114
+ void this.refreshConversations();
1115
+ }
1116
+
1047
1117
  private handleNewMessage(data: any) {
1048
1118
  if (this.isReplayedMessage(data?.messageId)) return;
1049
1119
 
@@ -1060,7 +1130,7 @@ export class WidgetStore {
1060
1130
  body: data?.body || null,
1061
1131
  bodyPlain: data?.bodyPlain || null,
1062
1132
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1063
- metadata: data?.metadata || null,
1133
+ metadata: this.hydrateMetadata(data?.metadata || null),
1064
1134
  status: 'delivered',
1065
1135
  createdAt: data?.createdAt || new Date().toISOString(),
1066
1136
  };
@@ -1557,7 +1627,10 @@ export class WidgetStore {
1557
1627
  this.setState((current) => {
1558
1628
  const known = new Set(current.messages.map((message) => message.id));
1559
1629
  const missing = fresh.filter((message) => !known.has(message.id));
1560
- const upgraded = adoptServerAttachments(current.messages, fresh);
1630
+ const upgraded = adoptServerStatuses(
1631
+ adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh),
1632
+ fresh
1633
+ );
1561
1634
  if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
1562
1635
 
1563
1636
  const local = current.messages.filter(isLocalMessage);
@@ -1588,10 +1661,17 @@ export class WidgetStore {
1588
1661
  .map((message) => ({
1589
1662
  ...message,
1590
1663
  attachments: this.hydrateAttachments(message.attachments, message.id),
1664
+ metadata: this.hydrateMetadata(message.metadata),
1591
1665
  }))
1592
1666
  .reverse();
1593
1667
  }
1594
1668
 
1669
+ /** The "joined" line's avatar can be a server-relative media path. */
1670
+ private hydrateMetadata(metadata: Message['metadata']): Message['metadata'] {
1671
+ if (!metadata || typeof metadata.agentAvatar !== 'string') return metadata ?? null;
1672
+ return { ...metadata, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
1673
+ }
1674
+
1595
1675
  /**
1596
1676
  * Rejects when the message wasn't sent, so the composer can keep
1597
1677
  * attachments for a retry. A text-only bubble is marked failed; one with
@@ -1710,7 +1790,9 @@ export class WidgetStore {
1710
1790
  const messages = delivered
1711
1791
  ? current.messages.filter((message) => message.id !== tempId)
1712
1792
  : current.messages.map((message) =>
1713
- message.id === tempId ? { ...message, id: result.messageId, status: 'sent' } : message
1793
+ message.id === tempId
1794
+ ? { ...message, id: result.messageId, status: this.earlyStatuses.get(result.messageId) ?? 'sent' }
1795
+ : message
1714
1796
  );
1715
1797
 
1716
1798
  if (!createdId) return { messages };
@@ -1856,6 +1938,40 @@ function isLocalMessage(message: Message): boolean {
1856
1938
  * at all) until the server copy shows up — the stream echo or a history page.
1857
1939
  * Its attachments, the only ones that can be opened, then replace them.
1858
1940
  */
1941
+ /** A "Seen" missed while the stream was down shows up on the next re-read. */
1942
+ function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
1943
+ const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
1944
+ if (seen.size === 0) return messages;
1945
+ let changed = false;
1946
+ const next = messages.map((message) => {
1947
+ if (!seen.has(message.id) || message.status === 'read') return message;
1948
+ changed = true;
1949
+ return { ...message, status: 'read' };
1950
+ });
1951
+ return changed ? next : messages;
1952
+ }
1953
+
1954
+ /** Keeps who/when, drops everything the visitor could read. */
1955
+ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
1956
+ return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
1957
+ }
1958
+
1959
+ /** A deletion missed while the stream was down shows up on the next re-read. */
1960
+ function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
1961
+ const deleted = new Map(
1962
+ server.filter((message) => message.deletedAt).map((message) => [message.id, message.deletedAt!])
1963
+ );
1964
+ if (deleted.size === 0) return messages;
1965
+ let changed = false;
1966
+ const next = messages.map((message) => {
1967
+ const deletedAt = deleted.get(message.id);
1968
+ if (!deletedAt || message.deletedAt) return message;
1969
+ changed = true;
1970
+ return asDeletedPlaceholder(message, deletedAt);
1971
+ });
1972
+ return changed ? next : messages;
1973
+ }
1974
+
1859
1975
  function adoptServerAttachments(messages: Message[], server: Message[]): Message[] {
1860
1976
  const serverCopies = new Map(
1861
1977
  server.filter((message) => message.attachments?.length).map((message) => [message.id, message])
package/src/types.ts CHANGED
@@ -21,6 +21,12 @@ export interface PreChatField {
21
21
  options?: string[];
22
22
  }
23
23
 
24
+ /** A teammate's face for the stacked header / "Waiting for a teammate" avatars. */
25
+ export interface TeamAvatar {
26
+ initials: string;
27
+ avatarUrl: string | null;
28
+ }
29
+
24
30
  export interface WidgetConfig {
25
31
  primaryColor: string;
26
32
  homeLogoUrl: string | null;
@@ -66,6 +72,10 @@ export interface WidgetConfig {
66
72
  identityVerificationEnabled: boolean;
67
73
  hmacMandatory: boolean;
68
74
  isOnline: boolean;
75
+ /** Header title while no teammate has joined (and after the conversation closes). */
76
+ workspaceName?: string | null;
77
+ /** Up to three teammates shown stacked beside it. Absent on older servers. */
78
+ teamAvatars?: TeamAvatar[];
69
79
  }
70
80
 
71
81
  /** Received attachments are normalized: a missing `id`/`size` is filled in. */
@@ -102,6 +112,8 @@ export interface Conversation {
102
112
  createdAt: string;
103
113
  agentName: string | null;
104
114
  agentAvatar: string | null;
115
+ /** The assigned teammate's own presence ("Active"/"Away"); absent on older servers. */
116
+ agentOnline?: boolean | null;
105
117
  /**
106
118
  * Agent replies the visitor hasn't seen, computed server-side from their
107
119
  * read cursor. Absent on older Workstation deployments — the SDK then falls
@@ -122,6 +134,8 @@ export interface Message {
122
134
  metadata?: Record<string, unknown> | null;
123
135
  status: string | null;
124
136
  createdAt: string;
137
+ /** Set when a teammate deleted the message; the content fields are then empty. */
138
+ deletedAt?: string | null;
125
139
  }
126
140
 
127
141
  export interface OutboundMessage {
@@ -438,6 +452,8 @@ export interface AgentProcedureForm {
438
452
  export type WidgetRealtimeEvent =
439
453
  | 'connected'
440
454
  | 'message.new'
455
+ | 'message.deleted'
456
+ | 'message.status'
441
457
  | 'typing.start'
442
458
  | 'typing.stop'
443
459
  | 'conversation.updated'
@@ -12,6 +12,7 @@ import {
12
12
  Modal,
13
13
  Platform,
14
14
  Pressable,
15
+ StatusBar,
15
16
  StyleSheet,
16
17
  Text,
17
18
  View,
@@ -26,7 +27,7 @@ import {
26
27
  pickAttachments,
27
28
  } from '../attachments/pickers';
28
29
  import { controller } from '../controller';
29
- import { buildLabels, createTranslator, formatLabel } from '../i18n';
30
+ import { buildLabels, createTranslator, formatLabel, localizeWelcomeText } from '../i18n';
30
31
  import { useWidgetState } from '../state/use-widget-state';
31
32
  import type { AgentUiAction, OutgoingAttachment, WidgetSpaceType } from '../types';
32
33
  import { WidgetErrorBoundary } from './components/ErrorBoundary';
@@ -35,13 +36,22 @@ import { InAppNotification } from './components/InAppNotification';
35
36
  import { OutboundMessageCard } from './components/OutboundMessageCard';
36
37
  import { WidgetPanel } from './components/WidgetPanel';
37
38
  import { useKeyboardOverlap } from './hooks/use-keyboard-overlap';
39
+ import { TopInsetContext } from './hooks/use-top-inset';
38
40
  import { useWidgetInsets } from './hooks/use-widget-insets';
39
41
  import { ChatView } from './screens/chat/ChatView';
40
42
  import { ConversationsList } from './screens/conversations/ConversationsList';
41
- import { HomeView } from './screens/home/HomeView';
43
+ import { HomeView, resolveHeaderTextColor } from './screens/home/HomeView';
42
44
  import { PreChatForm } from './screens/pre-chat/PreChatForm';
43
45
  import { globalStyles } from './styles/global';
44
- import { buildTheme, palette, radius, spacing, fontSize, withAlpha } from './styles/theme';
46
+ import {
47
+ buildTheme,
48
+ contrastingTextColor,
49
+ palette,
50
+ radius,
51
+ spacing,
52
+ fontSize,
53
+ withAlpha,
54
+ } from './styles/theme';
45
55
  import { pickRecentConversations, pickTeamAgents } from './utils/conversations';
46
56
 
47
57
  export interface RepzoWidgetProviderProps {
@@ -69,7 +79,20 @@ export function RepzoWidgetProvider({
69
79
  // not, so they own the top inset.
70
80
  const topInset = Platform.OS === 'ios' && presentationStyle === 'pageSheet' ? 0 : insets.top;
71
81
 
72
- const theme = useMemo(() => buildTheme(state.config), [state.config]);
82
+ const localizedConfig = useMemo(
83
+ () => localizeWelcomeText(state.config, state.locale),
84
+ [state.config, state.locale]
85
+ );
86
+ const theme = useMemo(() => buildTheme(localizedConfig), [localizedConfig]);
87
+ // Teammate photos can be server-relative media paths.
88
+ const teamAvatars = useMemo(
89
+ () =>
90
+ (state.config?.teamAvatars ?? []).map((avatar) => ({
91
+ ...avatar,
92
+ avatarUrl: store.client.resolveUrl(avatar.avatarUrl),
93
+ })),
94
+ [state.config, store]
95
+ );
73
96
  const labels = useMemo(
74
97
  () => buildLabels(createTranslator(state.config, state.locale)),
75
98
  [state.config, state.locale]
@@ -179,6 +202,21 @@ export function RepzoWidgetProvider({
179
202
  : null;
180
203
  const isHomeSurface = state.view === 'home';
181
204
 
205
+ // Home and chat paint their brand header under the status bar; every other
206
+ // screen (and an outbound card stacked above home) keeps a plain white top.
207
+ const outboundVisible =
208
+ !!state.outboundMessage && !blockingError && state.view !== 'chat' && state.view !== 'prechat';
209
+ const headerOwnsTopInset =
210
+ !blockingError &&
211
+ !(state.loading && !state.config) &&
212
+ !outboundVisible &&
213
+ (state.view === 'chat' || isHomeSurface);
214
+ const topBarColor = !headerOwnsTopInset
215
+ ? palette.ink
216
+ : state.view === 'chat'
217
+ ? contrastingTextColor(theme.primaryColor)
218
+ : resolveHeaderTextColor(localizedConfig, theme.primaryColor);
219
+
182
220
  const renderContent = () => {
183
221
  if (state.loading && !state.config) {
184
222
  return (
@@ -251,7 +289,11 @@ export function RepzoWidgetProvider({
251
289
  color={theme.primaryColor}
252
290
  agentName={activeConversation?.agentName}
253
291
  agentAvatar={activeConversation?.agentAvatar}
292
+ agentOnline={activeConversation?.agentOnline}
254
293
  isOnline={theme.isOnline}
294
+ teamName={state.config?.workspaceName}
295
+ teamAvatars={teamAvatars}
296
+ replyTimeLabel={theme.replyTimeLabel}
255
297
  locale={state.locale}
256
298
  prefillText={state.prefillText}
257
299
  offlineMessage={theme.offlineMessage}
@@ -270,6 +312,11 @@ export function RepzoWidgetProvider({
270
312
  today: labels.today,
271
313
  yesterday: labels.yesterday,
272
314
  conversationUpdated: labels.conversationUpdated,
315
+ messageDeleted: labels.messageDeleted,
316
+ messageSent: labels.messageSent,
317
+ messageSeen: labels.messageSeen,
318
+ waitingForTeammate: labels.waitingForTeammate,
319
+ joinedConversation: labels.joinedConversation,
273
320
  procedureSend: labels.procedureSend,
274
321
  procedureSent: labels.procedureSent,
275
322
  blocks: blockLabels,
@@ -295,7 +342,7 @@ export function RepzoWidgetProvider({
295
342
  if (isHomeSurface) {
296
343
  return (
297
344
  <HomeView
298
- config={state.config}
345
+ config={localizedConfig}
299
346
  color={theme.primaryColor}
300
347
  visitorName={state.visitorProfile.name}
301
348
  conversationCount={state.conversations.length}
@@ -383,8 +430,19 @@ export function RepzoWidgetProvider({
383
430
  animationType="slide"
384
431
  presentationStyle={Platform.OS === 'ios' ? presentationStyle : undefined}
385
432
  onRequestClose={handleClose}
386
- statusBarTranslucent={false}>
387
- <View style={[globalStyles.screen, { paddingTop: topInset, paddingBottom: insets.bottom }]}>
433
+ // Edge to edge on every Android version, so the insets below are the
434
+ // only top spacing (a non-translucent modal used to add a second one).
435
+ statusBarTranslucent>
436
+ <StatusBar
437
+ barStyle={topBarColor === palette.white ? 'light-content' : 'dark-content'}
438
+ animated
439
+ />
440
+ <View
441
+ style={[
442
+ globalStyles.screen,
443
+ { paddingTop: headerOwnsTopInset ? 0 : topInset, paddingBottom: insets.bottom },
444
+ ]}>
445
+ <TopInsetContext.Provider value={headerOwnsTopInset ? topInset : 0}>
388
446
  <View
389
447
  onLayout={keyboard.onLayout}
390
448
  style={[globalStyles.fill, { paddingBottom: keyboard.overlap }]}>
@@ -441,6 +499,7 @@ export function RepzoWidgetProvider({
441
499
  </WidgetPanel>
442
500
  </WidgetErrorBoundary>
443
501
  </View>
502
+ </TopInsetContext.Provider>
444
503
  </View>
445
504
  </Modal>
446
505
  </>
@@ -104,6 +104,22 @@ function GradientFill({ colors, bands }: { colors: string[]; bands?: number }) {
104
104
  );
105
105
  }
106
106
 
107
+ /**
108
+ * Alpha at evenly spaced points down the fade. An eased curve instead of a
109
+ * straight line: a linear ramp shows a visible edge where it starts and goes
110
+ * opaque too late, so the brand colour reads as a band rather than melting
111
+ * into the page. Shared with the web widget's fade so both look the same.
112
+ */
113
+ const FADE_STOPS = [0, 0.04, 0.12, 0.26, 0.46, 0.68, 0.86, 0.96, 1];
114
+
115
+ function fadeAlphaAt(progress: number): number {
116
+ const scaled = Math.min(Math.max(progress, 0), 1) * (FADE_STOPS.length - 1);
117
+ const index = Math.min(Math.floor(scaled), FADE_STOPS.length - 2);
118
+ const from = FADE_STOPS[index] as number;
119
+ const to = FADE_STOPS[index + 1] as number;
120
+ return from + (to - from) * (scaled - index);
121
+ }
122
+
107
123
  /**
108
124
  * Washes the bottom of a header out into the page colour: a straight ramp from
109
125
  * fully transparent at the top to opaque at the bottom edge, the same
@@ -129,7 +145,8 @@ export function FadeOverlay({
129
145
  <View style={[styles.fadeOverlay, { height }, style]} pointerEvents="none">
130
146
  {NativeGradient ? (
131
147
  <NativeGradient
132
- colors={[withAlpha(color, 0), color]}
148
+ colors={FADE_STOPS.map((alpha) => withAlpha(color, alpha))}
149
+ locations={FADE_STOPS.map((_, index) => index / (FADE_STOPS.length - 1))}
133
150
  start={VERTICAL.start}
134
151
  end={VERTICAL.end}
135
152
  style={StyleSheet.absoluteFill}
@@ -144,7 +161,7 @@ export function FadeOverlay({
144
161
  // body colour, with no hairline where the two meet.
145
162
  <View
146
163
  key={index}
147
- style={[styles.band, { backgroundColor: withAlpha(color, (index + 1) / all.length) }]}
164
+ style={[styles.band, { backgroundColor: withAlpha(color, fadeAlphaAt((index + 1) / all.length)) }]}
148
165
  />
149
166
  ))
150
167
  )}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Up to three overlapping teammate avatars — the Messenger header when no
3
+ * teammate has joined yet, and the "Waiting for a teammate" row (Intercom-style).
4
+ * Falls back to a single placeholder avatar when the workspace sent none.
5
+ */
6
+
7
+ import { StyleSheet, View } from 'react-native';
8
+
9
+ import type { TeamAvatar } from '../../types';
10
+ import { palette } from '../styles/theme';
11
+ import { Avatar } from './Avatar';
12
+
13
+ interface TeamAvatarsProps {
14
+ avatars: TeamAvatar[];
15
+ size?: number;
16
+ color?: string;
17
+ /** Ring between overlapping avatars; matches the surface behind them. */
18
+ ringColor?: string;
19
+ translucent?: boolean;
20
+ }
21
+
22
+ export function TeamAvatars({
23
+ avatars,
24
+ size = 32,
25
+ color = palette.ink,
26
+ ringColor = palette.white,
27
+ translucent = false,
28
+ }: TeamAvatarsProps) {
29
+ if (avatars.length === 0) {
30
+ return <Avatar size={size} color={color} translucent={translucent} />;
31
+ }
32
+ const ring = Math.max(1.5, size * 0.06);
33
+ return (
34
+ <View style={styles.row} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
35
+ {avatars.slice(0, 3).map((avatar, index) => (
36
+ <View
37
+ key={`${avatar.initials}-${index}`}
38
+ style={[
39
+ styles.slot,
40
+ {
41
+ marginStart: index === 0 ? 0 : -size * 0.3,
42
+ borderRadius: (size + ring * 2) / 2,
43
+ borderWidth: ring,
44
+ borderColor: ringColor,
45
+ zIndex: 3 - index,
46
+ },
47
+ ]}>
48
+ <Avatar avatar={avatar.avatarUrl} name={avatar.initials} size={size} color={color} translucent={translucent} />
49
+ </View>
50
+ ))}
51
+ </View>
52
+ );
53
+ }
54
+
55
+ const styles = StyleSheet.create({
56
+ row: { flexDirection: 'row', alignItems: 'center' },
57
+ slot: { overflow: 'hidden' },
58
+ });
@@ -313,3 +313,40 @@ const styles = StyleSheet.create({
313
313
  justifyContent: 'center',
314
314
  },
315
315
  });
316
+
317
+ /** A ring with two hands — the reply-time hint beside the team name. */
318
+ export function ClockIcon({ size = 14, color = palette.textMuted, style }: IconProps) {
319
+ const thickness = Math.max(1.25, size * 0.1);
320
+ const hand: ViewStyle = {
321
+ position: 'absolute',
322
+ width: thickness,
323
+ borderRadius: thickness,
324
+ backgroundColor: color,
325
+ bottom: size / 2 - thickness / 2,
326
+ };
327
+ return (
328
+ <View style={[{ width: size, height: size }, styles.center, style]}>
329
+ <View
330
+ style={{
331
+ position: 'absolute',
332
+ width: size,
333
+ height: size,
334
+ borderRadius: size / 2,
335
+ borderWidth: thickness,
336
+ borderColor: color,
337
+ }}
338
+ />
339
+ <View style={[hand, { height: size * 0.32, left: size / 2 - thickness / 2 }]} />
340
+ <View
341
+ style={[
342
+ hand,
343
+ {
344
+ height: size * 0.24,
345
+ left: size / 2 - thickness / 2,
346
+ transform: [{ translateX: size * 0.1 }, { translateY: size * 0.04 }, { rotate: '-90deg' }],
347
+ },
348
+ ]}
349
+ />
350
+ </View>
351
+ );
352
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How far a screen's header must reach up to sit under the status bar.
3
+ *
4
+ * The messenger draws edge to edge. Screens with a brand header (home, chat)
5
+ * extend that header's background into the status-bar area instead of leaving
6
+ * a white strip above it; the provider gives them the inset through this
7
+ * context and pads every other screen itself, so this is 0 for them.
8
+ */
9
+
10
+ import { createContext, useContext } from 'react';
11
+
12
+ export const TopInsetContext = createContext(0);
13
+
14
+ export function useTopInset(): number {
15
+ return useContext(TopInsetContext);
16
+ }
@@ -50,6 +50,36 @@ export const styles = StyleSheet.create({
50
50
  fontSize: fontSize.caption,
51
51
  marginTop: 1,
52
52
  },
53
+ headerReplyTime: {
54
+ flexDirection: 'row',
55
+ alignItems: 'center',
56
+ gap: 4,
57
+ marginTop: 1,
58
+ },
59
+ headerReplyTimeText: {
60
+ flexShrink: 1,
61
+ marginTop: 0,
62
+ },
63
+ waitingRow: {
64
+ flexDirection: 'row',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ gap: spacing.sm,
68
+ paddingVertical: spacing.sm,
69
+ },
70
+ waitingText: {
71
+ color: palette.textMuted,
72
+ fontSize: fontSize.caption,
73
+ },
74
+ joinedRow: {
75
+ flexDirection: 'row',
76
+ justifyContent: 'center',
77
+ gap: spacing.xs,
78
+ },
79
+ joinedName: {
80
+ fontWeight: '700',
81
+ color: palette.ink,
82
+ },
53
83
  messagesContentEmpty: {
54
84
  flexGrow: 1,
55
85
  justifyContent: 'center',
@@ -108,6 +138,13 @@ export const styles = StyleSheet.create({
108
138
  marginBottom: 3,
109
139
  marginStart: spacing.xs,
110
140
  },
141
+ receiptText: {
142
+ alignSelf: 'flex-end',
143
+ marginTop: 3,
144
+ marginEnd: spacing.xs,
145
+ fontSize: 11,
146
+ color: palette.textSubtle,
147
+ },
111
148
  messageTime: {
112
149
  fontSize: 11,
113
150
  alignSelf: 'flex-end',
@@ -190,6 +227,20 @@ export const styles = StyleSheet.create({
190
227
  fontSize: fontSize.caption,
191
228
  marginTop: spacing.xs,
192
229
  },
230
+ deletedBubble: {
231
+ maxWidth: '80%',
232
+ paddingHorizontal: spacing.md,
233
+ paddingVertical: spacing.sm,
234
+ borderRadius: radius.lg + 4,
235
+ borderWidth: 1,
236
+ borderStyle: 'dashed',
237
+ borderColor: palette.border,
238
+ },
239
+ deletedText: {
240
+ color: palette.textMuted,
241
+ fontSize: fontSize.caption,
242
+ fontStyle: 'italic',
243
+ },
193
244
  systemRow: {
194
245
  alignItems: 'center',
195
246
  paddingVertical: spacing.sm,