@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.
- package/lib/commonjs/api/client.js +3 -1
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/api/realtime.js +2 -0
- package/lib/commonjs/api/realtime.js.map +1 -1
- package/lib/commonjs/i18n.js +26 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +5 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +129 -10
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +97 -66
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/Gradient.js +18 -2
- package/lib/commonjs/ui/components/Gradient.js.map +1 -1
- package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
- package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +50 -0
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/hooks/use-top-inset.js +22 -0
- package/lib/commonjs/ui/hooks/use-top-inset.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +113 -7
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +51 -0
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.js +42 -9
- package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.styles.js +5 -3
- package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/module/api/client.js +3 -1
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/api/realtime.js +2 -0
- package/lib/module/api/realtime.js.map +1 -1
- package/lib/module/i18n.js +25 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +5 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +129 -10
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +101 -70
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/Gradient.js +18 -2
- package/lib/module/ui/components/Gradient.js.map +1 -1
- package/lib/module/ui/components/TeamAvatars.js +59 -0
- package/lib/module/ui/components/TeamAvatars.js.map +1 -0
- package/lib/module/ui/components/icons.js +49 -0
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/hooks/use-top-inset.js +17 -0
- package/lib/module/ui/hooks/use-top-inset.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +115 -9
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +51 -0
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.js +44 -12
- package/lib/module/ui/screens/home/HomeView.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.styles.js +4 -2
- package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +7 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +24 -0
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +14 -1
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Gradient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
- package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/use-top-inset.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/use-top-inset.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +51 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +13 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -0
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +3 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/api/realtime.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +7 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +24 -0
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +14 -1
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Gradient.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
- package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +2 -0
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/use-top-inset.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/use-top-inset.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +51 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +13 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -0
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +3 -1
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/client.ts +3 -1
- package/src/api/realtime.ts +2 -0
- package/src/i18n.ts +28 -0
- package/src/locales.ts +5 -0
- package/src/state/widget-store.ts +124 -8
- package/src/types.ts +16 -0
- package/src/ui/WidgetProvider.tsx +66 -7
- package/src/ui/components/Gradient.tsx +19 -2
- package/src/ui/components/TeamAvatars.tsx +58 -0
- package/src/ui/components/icons.tsx +37 -0
- package/src/ui/hooks/use-top-inset.ts +16 -0
- package/src/ui/screens/chat/ChatView.styles.ts +51 -0
- package/src/ui/screens/chat/ChatView.tsx +120 -22
- package/src/ui/screens/chat/ChatView.types.ts +13 -0
- package/src/ui/screens/home/HomeView.styles.ts +4 -2
- 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.
|
|
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 =
|
|
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
|
|
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 {
|
|
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
|
|
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={
|
|
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
|
-
|
|
387
|
-
|
|
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={
|
|
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,
|