@repzo/widget-react-native 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +92 -3
- package/lib/commonjs/api/client.js +35 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/i18n.js +14 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js +2 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +13 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/push/push-notifications.js +65 -8
- package/lib/commonjs/push/push-notifications.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +451 -7
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +15 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +64 -2
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/Post.js +1241 -0
- package/lib/commonjs/ui/components/Post.js.map +1 -0
- package/lib/commonjs/ui/utils/html.js +1 -0
- package/lib/commonjs/ui/utils/html.js.map +1 -1
- package/lib/commonjs/ui/utils/post-html.js +276 -0
- package/lib/commonjs/ui/utils/post-html.js.map +1 -0
- package/lib/commonjs/ui/utils/safe-url.js +16 -0
- package/lib/commonjs/ui/utils/safe-url.js.map +1 -1
- package/lib/module/api/client.js +35 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/i18n.js +14 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +13 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/push/push-notifications.js +65 -8
- package/lib/module/push/push-notifications.js.map +1 -1
- package/lib/module/state/widget-store.js +450 -6
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +15 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +65 -3
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/Post.js +1236 -0
- package/lib/module/ui/components/Post.js.map +1 -0
- package/lib/module/ui/utils/html.js +1 -1
- package/lib/module/ui/utils/html.js.map +1 -1
- package/lib/module/ui/utils/post-html.js +271 -0
- package/lib/module/ui/utils/post-html.js.map +1 -0
- package/lib/module/ui/utils/safe-url.js +15 -0
- package/lib/module/ui/utils/safe-url.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +21 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +13 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +14 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/push/push-notifications.d.ts +26 -2
- package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +84 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +77 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Post.d.ts +69 -0
- package/lib/typescript/commonjs/ui/components/Post.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/html.d.ts +1 -0
- package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/post-html.d.ts +54 -0
- package/lib/typescript/commonjs/ui/utils/post-html.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/safe-url.d.ts +6 -0
- package/lib/typescript/commonjs/ui/utils/safe-url.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +21 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +13 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +14 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/push/push-notifications.d.ts +26 -2
- package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +84 -1
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +77 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Post.d.ts +69 -0
- package/lib/typescript/module/ui/components/Post.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/html.d.ts +1 -0
- package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/post-html.d.ts +54 -0
- package/lib/typescript/module/ui/utils/post-html.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/safe-url.d.ts +6 -0
- package/lib/typescript/module/ui/utils/safe-url.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/client.ts +32 -0
- package/src/i18n.ts +14 -0
- package/src/index.tsx +17 -0
- package/src/locales.ts +13 -0
- package/src/push/push-notifications.ts +74 -8
- package/src/state/widget-store.ts +429 -6
- package/src/types.ts +77 -0
- package/src/ui/WidgetProvider.tsx +78 -2
- package/src/ui/components/Post.tsx +943 -0
- package/src/ui/utils/html.ts +1 -1
- package/src/ui/utils/post-html.ts +256 -0
- package/src/ui/utils/safe-url.ts +15 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.WidgetStore = void 0;
|
|
6
|
+
exports.WidgetStore = exports.POST_THANKS_MS = void 0;
|
|
7
7
|
var _reactNative = require("react-native");
|
|
8
8
|
var _client = require("../api/client.js");
|
|
9
9
|
var _realtime = require("../api/realtime.js");
|
|
@@ -25,7 +25,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
28
|
-
const SDK_VERSION = '@repzo/widget-react-native@1.1
|
|
28
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.2.1';
|
|
29
29
|
|
|
30
30
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
31
31
|
function sessionPlatform() {
|
|
@@ -48,6 +48,12 @@ const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
|
|
|
48
48
|
const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
49
49
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
50
50
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
51
|
+
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
52
|
+
const POST_THANKS_MS = exports.POST_THANKS_MS = 1600;
|
|
53
|
+
|
|
54
|
+
/** Ask a moment after the server's "post stops" time, never waiting longer than this per timer. */
|
|
55
|
+
const POST_RECHECK_SLACK_MS = 1_000;
|
|
56
|
+
const POST_RECHECK_MAX_MS = 6 * 60 * 60 * 1000;
|
|
51
57
|
/** Plain-http `baseUrl` is only accepted for these (plus `*.local` and `__DEV__`). */
|
|
52
58
|
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '10.0.2.2']);
|
|
53
59
|
const sumUnread = map => Object.values(map).reduce((sum, count) => sum + count, 0);
|
|
@@ -74,7 +80,9 @@ const INITIAL_STATE = {
|
|
|
74
80
|
prefillText: '',
|
|
75
81
|
outboundMessage: null,
|
|
76
82
|
visitorProfile: {},
|
|
77
|
-
inAppNotification: null
|
|
83
|
+
inAppNotification: null,
|
|
84
|
+
post: null,
|
|
85
|
+
postExpanded: false
|
|
78
86
|
};
|
|
79
87
|
|
|
80
88
|
/** Everything in state that belongs to the signed-in visitor. */
|
|
@@ -94,14 +102,16 @@ const USER_SCOPED_STATE = {
|
|
|
94
102
|
prefillText: '',
|
|
95
103
|
outboundMessage: null,
|
|
96
104
|
visitorProfile: {},
|
|
97
|
-
inAppNotification: null
|
|
105
|
+
inAppNotification: null,
|
|
106
|
+
post: null,
|
|
107
|
+
postExpanded: false
|
|
98
108
|
};
|
|
99
109
|
class WidgetStore {
|
|
100
110
|
client = new _client.WidgetApiClient();
|
|
101
111
|
events = new _events.EventEmitter();
|
|
102
112
|
push = new _pushNotifications.PushManager(this.client, conversationId => {
|
|
103
113
|
void this.openConversation(conversationId);
|
|
104
|
-
});
|
|
114
|
+
}, campaignId => this.openPostFromPush(campaignId));
|
|
105
115
|
state = INITIAL_STATE;
|
|
106
116
|
subscribers = new Set();
|
|
107
117
|
realtime = new _realtime.WidgetRealtime();
|
|
@@ -112,6 +122,34 @@ class WidgetStore {
|
|
|
112
122
|
/** `boot({ inAppNotifications })` — the card over the app for new replies. */
|
|
113
123
|
inAppNotificationsEnabled = true;
|
|
114
124
|
|
|
125
|
+
/** `boot({ posts })` — Send posts over the app. */
|
|
126
|
+
postsEnabled = true;
|
|
127
|
+
|
|
128
|
+
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
129
|
+
viewedPostIds = new Set();
|
|
130
|
+
|
|
131
|
+
/** Bumped per post load, so an older answer never lands over a newer one. */
|
|
132
|
+
postRequest = 0;
|
|
133
|
+
|
|
134
|
+
/** Bumped when a live answer or a saved reply joins the post, so a load sent before it is retried, not shown. */
|
|
135
|
+
postRevision = 0;
|
|
136
|
+
|
|
137
|
+
/** Conversations whose unread replies include one the post can't show (attachments only): opening the post leaves them unread. */
|
|
138
|
+
postUnshownReplies = new Set();
|
|
139
|
+
postRecheckTimer = null;
|
|
140
|
+
|
|
141
|
+
/** A post push tapped before the session was ready: open it once it is. */
|
|
142
|
+
pendingPostId = null;
|
|
143
|
+
|
|
144
|
+
/** Posts closed with × whose report failed (offline, say): kept down, and the close re-sent when they come back. */
|
|
145
|
+
unsentDismissIds = new Set();
|
|
146
|
+
|
|
147
|
+
/** Bumped when the visitor's posts are reset (logout, wipe, posts off): older post callbacks stand down. */
|
|
148
|
+
postSession = 0;
|
|
149
|
+
|
|
150
|
+
/** A reaction's "thanks" moment: when it ends, the post goes. */
|
|
151
|
+
postThanksTimer = null;
|
|
152
|
+
|
|
115
153
|
/** The launch card (see showUnreadOnLaunch) is offered once per visitor session. */
|
|
116
154
|
launchCardOffered = false;
|
|
117
155
|
bootPromise = null;
|
|
@@ -267,6 +305,17 @@ class WidgetStore {
|
|
|
267
305
|
if (options.inAppNotifications !== undefined) {
|
|
268
306
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
269
307
|
}
|
|
308
|
+
if (options.posts !== undefined) {
|
|
309
|
+
const wasEnabled = this.postsEnabled;
|
|
310
|
+
this.postsEnabled = options.posts !== false;
|
|
311
|
+
if (!this.postsEnabled) {
|
|
312
|
+
this.resetPost();
|
|
313
|
+
this.pendingPostId = null;
|
|
314
|
+
} else if (!wasEnabled) {
|
|
315
|
+
// A booted session returns early below, so turning posts back on asks here.
|
|
316
|
+
this.loadPost();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
270
319
|
const sameTarget = this.bootConfig?.baseUrl === baseUrl && this.bootConfig?.channelAccountId === options.channelAccountId;
|
|
271
320
|
const userKey = options.user ? identityKey(options.user) : null;
|
|
272
321
|
if (sameTarget && this.bootPromise && (!userKey || userKey === this.bootingUserKey)) {
|
|
@@ -466,6 +515,8 @@ class WidgetStore {
|
|
|
466
515
|
this.connectRealtime(result.token);
|
|
467
516
|
this.watchAppState();
|
|
468
517
|
this.loadOutboundMessage();
|
|
518
|
+
// Audiences and closed posts are per person, so every new session asks again.
|
|
519
|
+
this.loadPost();
|
|
469
520
|
// Re-runs on login switches too — the token moves to the new identity.
|
|
470
521
|
void this.push.start();
|
|
471
522
|
if (this.pendingConversationId) {
|
|
@@ -671,6 +722,8 @@ class WidgetStore {
|
|
|
671
722
|
this.resetThreadTracking();
|
|
672
723
|
this.appStateSubscription?.remove();
|
|
673
724
|
this.appStateSubscription = null;
|
|
725
|
+
this.resetPost();
|
|
726
|
+
this.pendingPostId = null;
|
|
674
727
|
this.setState({
|
|
675
728
|
...INITIAL_STATE
|
|
676
729
|
});
|
|
@@ -707,6 +760,7 @@ class WidgetStore {
|
|
|
707
760
|
this.visitorTypingActive = false;
|
|
708
761
|
this.lastTypingPingAt = 0;
|
|
709
762
|
this.launchCardOffered = false;
|
|
763
|
+
this.resetPost();
|
|
710
764
|
this.setState({
|
|
711
765
|
...USER_SCOPED_STATE
|
|
712
766
|
});
|
|
@@ -812,6 +866,8 @@ class WidgetStore {
|
|
|
812
866
|
// and the thread on screen so replies sent meanwhile show up.
|
|
813
867
|
await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
|
|
814
868
|
await this.refreshConfig();
|
|
869
|
+
// A post may have gone live (or ended) while the app was away.
|
|
870
|
+
if (!this.isStale(epoch)) this.loadPost();
|
|
815
871
|
}
|
|
816
872
|
|
|
817
873
|
/**
|
|
@@ -859,6 +915,8 @@ class WidgetStore {
|
|
|
859
915
|
if (this.streamConnectedOnce || this.streamErrored) {
|
|
860
916
|
void this.refreshConversations();
|
|
861
917
|
void this.refreshOpenThread();
|
|
918
|
+
// Answers under the post too.
|
|
919
|
+
this.loadPost();
|
|
862
920
|
}
|
|
863
921
|
this.streamConnectedOnce = true;
|
|
864
922
|
this.streamErrored = false;
|
|
@@ -1057,6 +1115,17 @@ class WidgetStore {
|
|
|
1057
1115
|
// never system rows like "agent joined" (the server counts the same way).
|
|
1058
1116
|
const isTeamReply = message.messageType === 'message' && message.direction !== 'inbound' && message.senderType !== 'visitor';
|
|
1059
1117
|
const onScreen = this.isThreadOnScreen(conversationId);
|
|
1118
|
+
// A team answer in the shown post's conversation shows under the post —
|
|
1119
|
+
// and, with the post open in front of them, counts as read there. The
|
|
1120
|
+
// post only shows text: an attachment-only answer stays unread (card,
|
|
1121
|
+
// badge) for the messenger, and so does everything after it until the
|
|
1122
|
+
// conversation is opened there, since reading moves the server cursor past it.
|
|
1123
|
+
const post = this.state.post;
|
|
1124
|
+
const inPost = isTeamReply && !!conversationId && post?.conversationId === conversationId;
|
|
1125
|
+
const answerText = (message.bodyPlain ?? '').trim();
|
|
1126
|
+
if (inPost && !this.state.unreadByConversation[conversationId]) this.postUnshownReplies.delete(conversationId);
|
|
1127
|
+
const readInPost = inPost && !!answerText && this.state.postExpanded && !this.state.isOpen && !this.state.unreadByConversation[conversationId];
|
|
1128
|
+
if (inPost && !answerText) this.postUnshownReplies.add(conversationId);
|
|
1060
1129
|
|
|
1061
1130
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1062
1131
|
let countedAsUnread = false;
|
|
@@ -1075,7 +1144,7 @@ class WidgetStore {
|
|
|
1075
1144
|
agentTyping: clearsTyping ? false : current.agentTyping
|
|
1076
1145
|
};
|
|
1077
1146
|
}
|
|
1078
|
-
if (!isTeamReply || !conversationId) return {
|
|
1147
|
+
if (!isTeamReply || !conversationId || readInPost) return {
|
|
1079
1148
|
conversations
|
|
1080
1149
|
};
|
|
1081
1150
|
const unreadByConversation = {
|
|
@@ -1108,7 +1177,23 @@ class WidgetStore {
|
|
|
1108
1177
|
|
|
1109
1178
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1110
1179
|
// next list/init would badge a reply they already saw.
|
|
1111
|
-
if (onScreen && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
1180
|
+
if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
1181
|
+
const current = this.state.post;
|
|
1182
|
+
if (inPost && answerText && current?.id === post?.id) {
|
|
1183
|
+
const answer = {
|
|
1184
|
+
from: 'team',
|
|
1185
|
+
text: answerText,
|
|
1186
|
+
senderName: message.senderName,
|
|
1187
|
+
at: message.createdAt
|
|
1188
|
+
};
|
|
1189
|
+
this.postRevision += 1;
|
|
1190
|
+
this.setState({
|
|
1191
|
+
post: {
|
|
1192
|
+
...current,
|
|
1193
|
+
thread: [...current.thread, answer]
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1112
1197
|
|
|
1113
1198
|
// Emitted after the state write so a listener reading the unread count sees
|
|
1114
1199
|
// the new value. Only unread-counting replies fire it, which keeps the event
|
|
@@ -1277,6 +1362,8 @@ class WidgetStore {
|
|
|
1277
1362
|
});
|
|
1278
1363
|
this.clearTypingTimer();
|
|
1279
1364
|
this.track('widget_closed');
|
|
1365
|
+
// A post left open behind the messenger is back in front of them.
|
|
1366
|
+
this.readPostConversation();
|
|
1280
1367
|
}
|
|
1281
1368
|
toggle(space) {
|
|
1282
1369
|
if (this.state.isOpen) this.hide();else this.present(space);
|
|
@@ -1838,9 +1925,366 @@ class WidgetStore {
|
|
|
1838
1925
|
}).catch(() => {});
|
|
1839
1926
|
}
|
|
1840
1927
|
|
|
1928
|
+
// ─── Send posts ─────────────────────────────────────────────────────────
|
|
1929
|
+
|
|
1930
|
+
/** Ask the server for the post this visitor should see now. */
|
|
1931
|
+
loadPost() {
|
|
1932
|
+
if (!this.postsEnabled || !this.state.booted) return;
|
|
1933
|
+
const session = this.postSession;
|
|
1934
|
+
const request = ++this.postRequest;
|
|
1935
|
+
const revision = this.postRevision;
|
|
1936
|
+
this.client.getPost().then(({
|
|
1937
|
+
post,
|
|
1938
|
+
recheckInMs
|
|
1939
|
+
}) => {
|
|
1940
|
+
if (this.postStale(session) || request !== this.postRequest) return;
|
|
1941
|
+
// An answer or reply joined the post meanwhile, maybe after this was
|
|
1942
|
+
// read: ask again rather than show the post without it.
|
|
1943
|
+
if (revision !== this.postRevision) {
|
|
1944
|
+
this.loadPost();
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
const hydrated = post ? this.hydratePost(post) : null;
|
|
1948
|
+
const pushed = this.pendingPostId;
|
|
1949
|
+
this.pendingPostId = null;
|
|
1950
|
+
this.schedulePostRecheck(recheckInMs, session);
|
|
1951
|
+
if (hydrated && this.unsentDismissIds.has(hydrated.id)) {
|
|
1952
|
+
// Closed already; the server only missed hearing it.
|
|
1953
|
+
this.showPost(null);
|
|
1954
|
+
this.resendPostDismiss(hydrated.id, session);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
// A full post opens by itself unless they minimized it; a tapped push always opens it.
|
|
1958
|
+
this.showPost(hydrated, !!hydrated && (hydrated.sentAs === 'full' && !hydrated.minimized || hydrated.id === pushed));
|
|
1959
|
+
})
|
|
1960
|
+
// Keep whatever is on screen; the next session or foreground asks again.
|
|
1961
|
+
.catch(() => {});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Coerces the server's post into the shape the view relies on: missing
|
|
1966
|
+
* strings become empty, unknown enum values fall back to the plainest
|
|
1967
|
+
* option, and malformed reactions or replies are dropped.
|
|
1968
|
+
*/
|
|
1969
|
+
hydratePost(post) {
|
|
1970
|
+
if (!post || typeof post.id !== 'string' || !post.id || typeof post.bodyHtml !== 'string') return null;
|
|
1971
|
+
const text = value => typeof value === 'string' ? value : '';
|
|
1972
|
+
const sender = post.sender && text(post.sender.name) ? post.sender : null;
|
|
1973
|
+
const replies = Array.isArray(post.replies) ? post.replies.filter(reply => typeof reply?.text === 'string' && reply.text.length > 0).map(reply => ({
|
|
1974
|
+
text: reply.text,
|
|
1975
|
+
at: text(reply.at)
|
|
1976
|
+
})) : [];
|
|
1977
|
+
return {
|
|
1978
|
+
id: post.id,
|
|
1979
|
+
bodyHtml: post.bodyHtml,
|
|
1980
|
+
snippet: text(post.snippet),
|
|
1981
|
+
sender: sender ? {
|
|
1982
|
+
name: sender.name,
|
|
1983
|
+
initials: text(sender.initials),
|
|
1984
|
+
// Teammate photos can be server-relative media paths.
|
|
1985
|
+
avatarUrl: this.client.resolveUrl(typeof sender.avatarUrl === 'string' ? sender.avatarUrl : null)
|
|
1986
|
+
} : null,
|
|
1987
|
+
workspaceName: text(post.workspaceName),
|
|
1988
|
+
format: post.format === 'large' ? 'large' : 'small',
|
|
1989
|
+
replyType: post.replyType === 'text' || post.replyType === 'reaction' ? post.replyType : 'none',
|
|
1990
|
+
reactions: Array.isArray(post.reactions) ? post.reactions.filter(emoji => typeof emoji === 'string' && emoji.length > 0) : [],
|
|
1991
|
+
sentAs: post.sentAs === 'badge' || post.sentAs === 'full' ? post.sentAs : 'snippet',
|
|
1992
|
+
reacted: typeof post.reacted === 'string' && post.reacted ? post.reacted : null,
|
|
1993
|
+
// Older Workstation versions don't send it.
|
|
1994
|
+
minimized: post.minimized === true,
|
|
1995
|
+
replies,
|
|
1996
|
+
thread: Array.isArray(post.thread) ? post.thread.filter(message => typeof message?.text === 'string' && message.text.length > 0).map(message => ({
|
|
1997
|
+
from: message.from === 'team' ? 'team' : 'person',
|
|
1998
|
+
text: message.text,
|
|
1999
|
+
senderName: message.from === 'team' ? text(message.senderName) || null : null,
|
|
2000
|
+
at: text(message.at)
|
|
2001
|
+
})) : replies.map(reply => ({
|
|
2002
|
+
from: 'person',
|
|
2003
|
+
text: reply.text,
|
|
2004
|
+
senderName: null,
|
|
2005
|
+
at: reply.at
|
|
2006
|
+
})),
|
|
2007
|
+
conversationId: text(post.conversationId) || null
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* Puts a post on screen (or takes it down). A different post starts open or
|
|
2013
|
+
* as its card per `expand`; the same one keeps what the visitor left it as,
|
|
2014
|
+
* so a full post put back to its card doesn't pop open on every foreground.
|
|
2015
|
+
*/
|
|
2016
|
+
showPost(post, expand = false) {
|
|
2017
|
+
const current = this.state.post;
|
|
2018
|
+
if (current && current.id !== post?.id) this.viewedPostIds.delete(current.id);
|
|
2019
|
+
const samePost = !!post && current?.id === post.id;
|
|
2020
|
+
const expanded = !!post && (samePost ? this.state.postExpanded : expand);
|
|
2021
|
+
this.setState({
|
|
2022
|
+
post,
|
|
2023
|
+
postExpanded: expanded
|
|
2024
|
+
});
|
|
2025
|
+
if (post && expanded && !samePost) this.reportPostOpen(post);
|
|
2026
|
+
this.readPostConversation();
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/** The post's card or full view is on screen. Reported once each time it's shown. */
|
|
2030
|
+
postDidShow(campaignId) {
|
|
2031
|
+
if (this.state.post?.id !== campaignId || this.viewedPostIds.has(campaignId)) return;
|
|
2032
|
+
this.viewedPostIds.add(campaignId);
|
|
2033
|
+
this.client.sendPostInteraction(campaignId, 'view').catch(() => {});
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** The visitor tapped the post's card: open it in full. */
|
|
2037
|
+
expandPost(campaignId) {
|
|
2038
|
+
const post = this.state.post;
|
|
2039
|
+
if (!post || post.id !== campaignId || this.state.postExpanded) return;
|
|
2040
|
+
this.setState({
|
|
2041
|
+
postExpanded: true
|
|
2042
|
+
});
|
|
2043
|
+
this.reportPostOpen(post);
|
|
2044
|
+
this.readPostConversation();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* The post is open in front of them (and the messenger isn't): the team's
|
|
2049
|
+
* answers under it are read, here and on the server. Not when one of them
|
|
2050
|
+
* is something the post can't show, or the post has fewer answers since
|
|
2051
|
+
* their last reply than are unread (an attachment from before this launch,
|
|
2052
|
+
* say): those wait for the messenger.
|
|
2053
|
+
*/
|
|
2054
|
+
readPostConversation() {
|
|
2055
|
+
const {
|
|
2056
|
+
post,
|
|
2057
|
+
postExpanded,
|
|
2058
|
+
isOpen,
|
|
2059
|
+
unreadByConversation
|
|
2060
|
+
} = this.state;
|
|
2061
|
+
const conversationId = post?.conversationId;
|
|
2062
|
+
const unread = conversationId ? unreadByConversation[conversationId] ?? 0 : 0;
|
|
2063
|
+
if (!post || !conversationId || !postExpanded || isOpen || unread === 0) return;
|
|
2064
|
+
if (this.postUnshownReplies.has(conversationId)) return;
|
|
2065
|
+
let shown = 0;
|
|
2066
|
+
for (let index = post.thread.length - 1; index >= 0 && post.thread[index]?.from === 'team'; index -= 1) shown += 1;
|
|
2067
|
+
if (shown < unread) return;
|
|
2068
|
+
this.setState({
|
|
2069
|
+
unreadByConversation: this.clearUnreadFor(conversationId)
|
|
2070
|
+
});
|
|
2071
|
+
this.events.emit(_events.RepzoWidgetEvents.ConversationDidRead, {
|
|
2072
|
+
conversationId
|
|
2073
|
+
});
|
|
2074
|
+
this.scheduleMarkRead(conversationId);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/** Back to its pill or card for now, without telling the server (see PostOverlay's onCollapse). */
|
|
2078
|
+
collapsePost(campaignId) {
|
|
2079
|
+
if (this.state.post?.id !== campaignId || !this.state.postExpanded) return;
|
|
2080
|
+
this.setState({
|
|
2081
|
+
postExpanded: false
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* "Not now" — the ⌄ button, a swipe down, or Android's back: the post goes
|
|
2087
|
+
* back to its badge (or snippet card) and stays there, on later launches
|
|
2088
|
+
* too, instead of opening by itself again. Unlike ×, it can be opened again.
|
|
2089
|
+
*/
|
|
2090
|
+
minimizePost(campaignId) {
|
|
2091
|
+
const post = this.state.post;
|
|
2092
|
+
if (!post || post.id !== campaignId || !this.state.postExpanded) return;
|
|
2093
|
+
this.setState({
|
|
2094
|
+
post: {
|
|
2095
|
+
...post,
|
|
2096
|
+
minimized: true
|
|
2097
|
+
},
|
|
2098
|
+
postExpanded: false
|
|
2099
|
+
});
|
|
2100
|
+
if (!post.minimized) this.client.sendPostInteraction(campaignId, 'minimize').catch(() => {});
|
|
2101
|
+
}
|
|
2102
|
+
reportPostOpen(post) {
|
|
2103
|
+
// "Opened" is opening it from its card; a full post is opened by showing it.
|
|
2104
|
+
if (post.sentAs === 'full') return;
|
|
2105
|
+
this.client.sendPostInteraction(post.id, 'open').catch(() => {});
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* The visitor tapped a link, reacted, replied to or closed the post. Closing
|
|
2110
|
+
* takes it down at once and loads the next one; a reaction shows as picked
|
|
2111
|
+
* right away. A reply resolves with what was saved (shown under the post)
|
|
2112
|
+
* and rejects when it wasn't, so the reply box can keep the text.
|
|
2113
|
+
*/
|
|
2114
|
+
async recordPostInteraction(campaignId, kind, value) {
|
|
2115
|
+
const post = this.state.post;
|
|
2116
|
+
if (!post || post.id !== campaignId) return undefined;
|
|
2117
|
+
const session = this.postSession;
|
|
2118
|
+
if (kind === 'dismiss') {
|
|
2119
|
+
// A load already in flight would put the closed post back.
|
|
2120
|
+
this.postRequest += 1;
|
|
2121
|
+
this.showPost(null);
|
|
2122
|
+
}
|
|
2123
|
+
if (kind === 'reaction' && value) this.setState({
|
|
2124
|
+
post: {
|
|
2125
|
+
...post,
|
|
2126
|
+
reacted: value
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
const sent = this.client.sendPostInteraction(campaignId, kind, value);
|
|
2130
|
+
// A reaction answers the post: it thanks them for a moment, then goes
|
|
2131
|
+
// (another tap in the meantime changes their pick and restarts it).
|
|
2132
|
+
if (kind === 'reaction' && value) this.startPostThanks(campaignId, sent, session);
|
|
2133
|
+
let result;
|
|
2134
|
+
try {
|
|
2135
|
+
result = await sent;
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
if (kind === 'reply') throw error;
|
|
2138
|
+
if (this.postStale(session)) return undefined;
|
|
2139
|
+
// × closes it for good, so it stays down until the server hears it.
|
|
2140
|
+
if (kind === 'dismiss') this.unsentDismissIds.add(campaignId);
|
|
2141
|
+
if (kind === 'reaction') {
|
|
2142
|
+
const current = this.state.post;
|
|
2143
|
+
if (current?.id === campaignId && current.reacted === value) {
|
|
2144
|
+
this.setState({
|
|
2145
|
+
post: {
|
|
2146
|
+
...current,
|
|
2147
|
+
reacted: post.reacted
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
// Not saved: it stays, as it was, so they can try again — unless an
|
|
2151
|
+
// earlier pick was saved, which already answered it: that one leaves.
|
|
2152
|
+
if (post.reacted) this.startPostThanks(campaignId, Promise.resolve(), session);else this.clearPostThanks();
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return undefined;
|
|
2156
|
+
}
|
|
2157
|
+
if (this.postStale(session)) return undefined;
|
|
2158
|
+
if (kind === 'reply') {
|
|
2159
|
+
// Falls back to what was typed when the response has no usable reply.
|
|
2160
|
+
const saved = result?.reply;
|
|
2161
|
+
const reply = typeof saved?.text === 'string' && saved.text ? {
|
|
2162
|
+
text: saved.text,
|
|
2163
|
+
at: typeof saved.at === 'string' ? saved.at : new Date().toISOString()
|
|
2164
|
+
} : {
|
|
2165
|
+
text: value ?? '',
|
|
2166
|
+
at: new Date().toISOString()
|
|
2167
|
+
};
|
|
2168
|
+
const current = this.state.post;
|
|
2169
|
+
if (current?.id === campaignId) {
|
|
2170
|
+
// Their first reply starts the post's conversation: answers there join the thread from now on.
|
|
2171
|
+
const conversationId = typeof result?.conversationId === 'string' && result.conversationId ? result.conversationId : current.conversationId;
|
|
2172
|
+
this.postRevision += 1;
|
|
2173
|
+
this.setState({
|
|
2174
|
+
post: {
|
|
2175
|
+
...current,
|
|
2176
|
+
replies: [...current.replies, reply],
|
|
2177
|
+
thread: [...current.thread, {
|
|
2178
|
+
from: 'person',
|
|
2179
|
+
text: reply.text,
|
|
2180
|
+
senderName: null,
|
|
2181
|
+
at: reply.at
|
|
2182
|
+
}],
|
|
2183
|
+
conversationId
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
// The reply went to the post's conversation: show it in the Messenger's list.
|
|
2188
|
+
void this.refreshConversations();
|
|
2189
|
+
return reply;
|
|
2190
|
+
}
|
|
2191
|
+
// A reaction's post leaves when its thanks ends (see startPostThanks).
|
|
2192
|
+
if (result?.hidden && kind !== 'reaction') {
|
|
2193
|
+
if (this.state.post?.id === campaignId) this.showPost(null);
|
|
2194
|
+
this.loadPost();
|
|
2195
|
+
}
|
|
2196
|
+
return undefined;
|
|
2197
|
+
}
|
|
2198
|
+
startPostThanks(campaignId, sent, session) {
|
|
2199
|
+
this.clearPostThanks();
|
|
2200
|
+
this.postThanksTimer = setTimeout(() => {
|
|
2201
|
+
this.postThanksTimer = null;
|
|
2202
|
+
if (this.postStale(session) || this.state.post?.id !== campaignId) return;
|
|
2203
|
+
this.postRequest += 1;
|
|
2204
|
+
this.showPost(null);
|
|
2205
|
+
// Ask for the next one once the server has the reaction, so it doesn't send this one back.
|
|
2206
|
+
sent.then(() => {
|
|
2207
|
+
if (!this.postStale(session)) this.loadPost();
|
|
2208
|
+
}, () => {
|
|
2209
|
+
if (!this.postStale(session)) this.loadPost();
|
|
2210
|
+
});
|
|
2211
|
+
}, POST_THANKS_MS);
|
|
2212
|
+
}
|
|
2213
|
+
clearPostThanks() {
|
|
2214
|
+
if (this.postThanksTimer) clearTimeout(this.postThanksTimer);
|
|
2215
|
+
this.postThanksTimer = null;
|
|
2216
|
+
}
|
|
2217
|
+
resendPostDismiss(campaignId, session) {
|
|
2218
|
+
this.client.sendPostInteraction(campaignId, 'dismiss').then(() => {
|
|
2219
|
+
if (this.postStale(session)) return;
|
|
2220
|
+
this.unsentDismissIds.delete(campaignId);
|
|
2221
|
+
this.loadPost();
|
|
2222
|
+
})
|
|
2223
|
+
// Still offline: it stays down and the next load tries again.
|
|
2224
|
+
.catch(() => {});
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* A post push was tapped: show that post, open in full, as soon as the
|
|
2229
|
+
* session is ready. The messenger steps aside for it, the way a reply push
|
|
2230
|
+
* opens its conversation.
|
|
2231
|
+
*/
|
|
2232
|
+
openPostFromPush(campaignId) {
|
|
2233
|
+
if (!this.postsEnabled) return;
|
|
2234
|
+
if (this.state.isOpen) this.hide();
|
|
2235
|
+
this.pendingPostId = campaignId;
|
|
2236
|
+
if (this.state.post?.id === campaignId) {
|
|
2237
|
+
this.pendingPostId = null;
|
|
2238
|
+
this.expandPost(campaignId);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (this.state.booted) this.loadPost();
|
|
2242
|
+
}
|
|
2243
|
+
clearPostRecheck() {
|
|
2244
|
+
if (this.postRecheckTimer) clearTimeout(this.postRecheckTimer);
|
|
2245
|
+
this.postRecheckTimer = null;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/** The server knows when the shown post stops; ask again then, so it leaves on time. */
|
|
2249
|
+
schedulePostRecheck(recheckInMs, session) {
|
|
2250
|
+
this.clearPostRecheck();
|
|
2251
|
+
if (typeof recheckInMs !== 'number' || !Number.isFinite(recheckInMs)) return;
|
|
2252
|
+
const delay = Math.min(Math.max(recheckInMs, 0) + POST_RECHECK_SLACK_MS, POST_RECHECK_MAX_MS);
|
|
2253
|
+
this.postRecheckTimer = setTimeout(() => {
|
|
2254
|
+
this.postRecheckTimer = null;
|
|
2255
|
+
if (!this.postStale(session)) this.loadPost();
|
|
2256
|
+
}, delay);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* Post callbacks check this, not the identity epoch: a same-user login or
|
|
2261
|
+
* an anonymous re-login bumps the epoch but keeps the session (and the post),
|
|
2262
|
+
* and would otherwise drop the post's load and strand its timers.
|
|
2263
|
+
*/
|
|
2264
|
+
postStale(session) {
|
|
2265
|
+
return session !== this.postSession;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/** The visitor changed or posts were turned off: drop it and any pending load. */
|
|
2269
|
+
resetPost() {
|
|
2270
|
+
this.postSession += 1;
|
|
2271
|
+
this.clearPostRecheck();
|
|
2272
|
+
this.clearPostThanks();
|
|
2273
|
+
this.postRequest += 1;
|
|
2274
|
+
this.viewedPostIds.clear();
|
|
2275
|
+
this.unsentDismissIds.clear();
|
|
2276
|
+
this.postUnshownReplies.clear();
|
|
2277
|
+
if (this.state.post || this.state.postExpanded) this.setState({
|
|
2278
|
+
post: null,
|
|
2279
|
+
postExpanded: false
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
1841
2283
|
// ─── Teardown (tests / hot reload) ──────────────────────────────────────
|
|
1842
2284
|
|
|
1843
2285
|
destroy() {
|
|
2286
|
+
this.clearPostRecheck();
|
|
2287
|
+
this.clearPostThanks();
|
|
1844
2288
|
this.realtime.close();
|
|
1845
2289
|
this.clearTypingTimer();
|
|
1846
2290
|
this.resetThreadTracking();
|