@repzo/widget-react-native 1.0.9 → 1.2.0
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 +400 -0
- package/README.md +104 -5
- package/lib/commonjs/agent-ui-blocks.js +2 -1
- package/lib/commonjs/agent-ui-blocks.js.map +1 -1
- package/lib/commonjs/api/client.js +25 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +3 -6
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/device-id.js +31 -3
- package/lib/commonjs/device-id.js.map +1 -1
- package/lib/commonjs/i18n.js +40 -4
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +34 -1
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/push/push-notifications.js +9 -8
- package/lib/commonjs/push/push-notifications.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +507 -26
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/storage.js +13 -1
- package/lib/commonjs/storage.js.map +1 -1
- package/lib/commonjs/types.js +20 -2
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +138 -49
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AgentUiBlocks.js +35 -15
- package/lib/commonjs/ui/components/AgentUiBlocks.js.map +1 -1
- package/lib/commonjs/ui/components/Launcher.js +4 -1
- package/lib/commonjs/ui/components/Launcher.js.map +1 -1
- package/lib/commonjs/ui/components/Markdown.js +32 -2
- package/lib/commonjs/ui/components/Markdown.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/components/RatingCard.js +6 -3
- package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
- package/lib/commonjs/ui/hooks/use-widget-insets.js +0 -1
- package/lib/commonjs/ui/hooks/use-widget-insets.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +67 -29
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/utils/html.js +9 -1
- package/lib/commonjs/ui/utils/html.js.map +1 -1
- package/lib/commonjs/ui/utils/native-gradient.js +0 -1
- package/lib/commonjs/ui/utils/native-gradient.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/agent-ui-blocks.js +2 -1
- package/lib/module/agent-ui-blocks.js.map +1 -1
- package/lib/module/api/client.js +25 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +3 -6
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/device-id.js +31 -3
- package/lib/module/device-id.js.map +1 -1
- package/lib/module/i18n.js +40 -4
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +34 -1
- package/lib/module/locales.js.map +1 -1
- package/lib/module/push/push-notifications.js +9 -8
- package/lib/module/push/push-notifications.js.map +1 -1
- package/lib/module/state/widget-store.js +507 -25
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/storage.js +13 -1
- package/lib/module/storage.js.map +1 -1
- package/lib/module/types.js +20 -2
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +139 -50
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AgentUiBlocks.js +35 -15
- package/lib/module/ui/components/AgentUiBlocks.js.map +1 -1
- package/lib/module/ui/components/Launcher.js +4 -1
- package/lib/module/ui/components/Launcher.js.map +1 -1
- package/lib/module/ui/components/Markdown.js +32 -3
- package/lib/module/ui/components/Markdown.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/components/RatingCard.js +6 -3
- package/lib/module/ui/components/RatingCard.js.map +1 -1
- package/lib/module/ui/hooks/use-widget-insets.js +0 -1
- package/lib/module/ui/hooks/use-widget-insets.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +68 -30
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/utils/html.js +9 -2
- package/lib/module/ui/utils/html.js.map +1 -1
- package/lib/module/ui/utils/native-gradient.js +0 -1
- package/lib/module/ui/utils/native-gradient.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/agent-ui-blocks.d.ts.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +19 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/device-id.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +35 -1
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -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 +6 -2
- package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +97 -8
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/storage.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +91 -8
- 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/AgentUiBlocks.d.ts +14 -0
- package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Markdown.d.ts +8 -0
- package/lib/typescript/commonjs/ui/components/Markdown.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/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/use-widget-insets.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +10 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- 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/native-gradient.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/agent-ui-blocks.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +19 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/device-id.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +35 -1
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -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 +6 -2
- package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +97 -8
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/storage.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +91 -8
- 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/AgentUiBlocks.d.ts +14 -0
- package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Markdown.d.ts +8 -0
- package/lib/typescript/module/ui/components/Markdown.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/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/use-widget-insets.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +10 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- 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/native-gradient.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 +14 -4
- package/src/agent-ui-blocks.ts +2 -1
- package/src/api/client.ts +27 -0
- package/src/attachments/pickers.ts +3 -6
- package/src/device-id.ts +34 -3
- package/src/i18n.ts +39 -2
- package/src/index.tsx +4 -0
- package/src/locales.ts +33 -0
- package/src/push/push-notifications.ts +10 -8
- package/src/state/widget-store.ts +480 -24
- package/src/storage.ts +18 -1
- package/src/types.ts +91 -8
- package/src/ui/WidgetProvider.tsx +166 -48
- package/src/ui/components/AgentUiBlocks.tsx +40 -12
- package/src/ui/components/Launcher.tsx +6 -1
- package/src/ui/components/Markdown.tsx +34 -3
- package/src/ui/components/Post.tsx +943 -0
- package/src/ui/components/RatingCard.tsx +6 -2
- package/src/ui/hooks/use-widget-insets.ts +0 -1
- package/src/ui/screens/chat/ChatView.tsx +77 -42
- package/src/ui/screens/chat/ChatView.types.ts +10 -0
- package/src/ui/utils/html.ts +10 -2
- package/src/ui/utils/native-gradient.ts +0 -1
- package/src/ui/utils/post-html.ts +256 -0
- package/src/ui/utils/safe-url.ts +15 -0
|
@@ -25,10 +25,13 @@ import type {
|
|
|
25
25
|
ConversationRatingResult,
|
|
26
26
|
Message,
|
|
27
27
|
OutgoingAttachment,
|
|
28
|
+
PostInteractionKind,
|
|
29
|
+
PostReply,
|
|
28
30
|
RepzoWidgetErrorCode,
|
|
29
31
|
WidgetAttachment,
|
|
30
32
|
WidgetConfig,
|
|
31
33
|
WidgetCustomAttributes,
|
|
34
|
+
WidgetPost,
|
|
32
35
|
WidgetSpaceType,
|
|
33
36
|
WidgetState,
|
|
34
37
|
WidgetUser,
|
|
@@ -36,7 +39,8 @@ import type {
|
|
|
36
39
|
} from '../types';
|
|
37
40
|
import { setOpenUrlHandler } from '../ui/utils/safe-url';
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
/** Sent on every init; must match `package.json` (tests/version.test.js checks). */
|
|
43
|
+
const SDK_VERSION = '@repzo/widget-react-native@1.2.0';
|
|
40
44
|
|
|
41
45
|
/** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
|
|
42
46
|
function sessionPlatform(): 'ios' | 'android' | 'web' {
|
|
@@ -65,6 +69,12 @@ const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
|
|
|
65
69
|
const MARK_READ_DEBOUNCE_MS = 1_000;
|
|
66
70
|
/** Live events held back while a draft's first send is creating its thread. */
|
|
67
71
|
const DRAFT_EVENT_BUFFER_LIMIT = 50;
|
|
72
|
+
/** How long a post stays up after a reaction, thanking them, before it goes. */
|
|
73
|
+
export const POST_THANKS_MS = 1600;
|
|
74
|
+
|
|
75
|
+
/** Ask a moment after the server's "post stops" time, never waiting longer than this per timer. */
|
|
76
|
+
const POST_RECHECK_SLACK_MS = 1_000;
|
|
77
|
+
const POST_RECHECK_MAX_MS = 6 * 60 * 60 * 1000;
|
|
68
78
|
/** Plain-http `baseUrl` is only accepted for these (plus `*.local` and `__DEV__`). */
|
|
69
79
|
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '10.0.2.2']);
|
|
70
80
|
|
|
@@ -95,6 +105,8 @@ const INITIAL_STATE: WidgetState = {
|
|
|
95
105
|
outboundMessage: null,
|
|
96
106
|
visitorProfile: {},
|
|
97
107
|
inAppNotification: null,
|
|
108
|
+
post: null,
|
|
109
|
+
postExpanded: false,
|
|
98
110
|
};
|
|
99
111
|
|
|
100
112
|
/** Everything in state that belongs to the signed-in visitor. */
|
|
@@ -115,6 +127,8 @@ const USER_SCOPED_STATE: Partial<WidgetState> = {
|
|
|
115
127
|
outboundMessage: null,
|
|
116
128
|
visitorProfile: {},
|
|
117
129
|
inAppNotification: null,
|
|
130
|
+
post: null,
|
|
131
|
+
postExpanded: false,
|
|
118
132
|
};
|
|
119
133
|
|
|
120
134
|
export class WidgetStore {
|
|
@@ -122,9 +136,13 @@ export class WidgetStore {
|
|
|
122
136
|
|
|
123
137
|
readonly events = new EventEmitter();
|
|
124
138
|
|
|
125
|
-
readonly push = new PushManager(
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
readonly push = new PushManager(
|
|
140
|
+
this.client,
|
|
141
|
+
(conversationId) => {
|
|
142
|
+
void this.openConversation(conversationId);
|
|
143
|
+
},
|
|
144
|
+
(campaignId) => this.openPostFromPush(campaignId)
|
|
145
|
+
);
|
|
128
146
|
|
|
129
147
|
private state: WidgetState = INITIAL_STATE;
|
|
130
148
|
|
|
@@ -144,6 +162,35 @@ export class WidgetStore {
|
|
|
144
162
|
/** `boot({ inAppNotifications })` — the card over the app for new replies. */
|
|
145
163
|
private inAppNotificationsEnabled = true;
|
|
146
164
|
|
|
165
|
+
/** `boot({ posts })` — Send posts over the app. */
|
|
166
|
+
private postsEnabled = true;
|
|
167
|
+
|
|
168
|
+
/** Posts whose view was reported this session (the server also dedupes per day). */
|
|
169
|
+
private viewedPostIds = new Set<string>();
|
|
170
|
+
|
|
171
|
+
/** Bumped per post load, so an older answer never lands over a newer one. */
|
|
172
|
+
private postRequest = 0;
|
|
173
|
+
|
|
174
|
+
/** Bumped when a live answer or a saved reply joins the post, so a load sent before it is retried, not shown. */
|
|
175
|
+
private postRevision = 0;
|
|
176
|
+
|
|
177
|
+
/** Conversations whose unread replies include one the post can't show (attachments only): opening the post leaves them unread. */
|
|
178
|
+
private postUnshownReplies = new Set<string>();
|
|
179
|
+
|
|
180
|
+
private postRecheckTimer: ReturnType<typeof setTimeout> | null = null;
|
|
181
|
+
|
|
182
|
+
/** A post push tapped before the session was ready: open it once it is. */
|
|
183
|
+
private pendingPostId: string | null = null;
|
|
184
|
+
|
|
185
|
+
/** Posts closed with × whose report failed (offline, say): kept down, and the close re-sent when they come back. */
|
|
186
|
+
private unsentDismissIds = new Set<string>();
|
|
187
|
+
|
|
188
|
+
/** Bumped when the visitor's posts are reset (logout, wipe, posts off): older post callbacks stand down. */
|
|
189
|
+
private postSession = 0;
|
|
190
|
+
|
|
191
|
+
/** A reaction's "thanks" moment: when it ends, the post goes. */
|
|
192
|
+
private postThanksTimer: ReturnType<typeof setTimeout> | null = null;
|
|
193
|
+
|
|
147
194
|
/** The launch card (see showUnreadOnLaunch) is offered once per visitor session. */
|
|
148
195
|
private launchCardOffered = false;
|
|
149
196
|
|
|
@@ -313,6 +360,17 @@ export class WidgetStore {
|
|
|
313
360
|
if (options.inAppNotifications !== undefined) {
|
|
314
361
|
this.inAppNotificationsEnabled = options.inAppNotifications !== false;
|
|
315
362
|
}
|
|
363
|
+
if (options.posts !== undefined) {
|
|
364
|
+
const wasEnabled = this.postsEnabled;
|
|
365
|
+
this.postsEnabled = options.posts !== false;
|
|
366
|
+
if (!this.postsEnabled) {
|
|
367
|
+
this.resetPost();
|
|
368
|
+
this.pendingPostId = null;
|
|
369
|
+
} else if (!wasEnabled) {
|
|
370
|
+
// A booted session returns early below, so turning posts back on asks here.
|
|
371
|
+
this.loadPost();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
316
374
|
|
|
317
375
|
const sameTarget =
|
|
318
376
|
this.bootConfig?.baseUrl === baseUrl &&
|
|
@@ -517,6 +575,8 @@ export class WidgetStore {
|
|
|
517
575
|
this.connectRealtime(result.token);
|
|
518
576
|
this.watchAppState();
|
|
519
577
|
this.loadOutboundMessage();
|
|
578
|
+
// Audiences and closed posts are per person, so every new session asks again.
|
|
579
|
+
this.loadPost();
|
|
520
580
|
// Re-runs on login switches too — the token moves to the new identity.
|
|
521
581
|
void this.push.start();
|
|
522
582
|
|
|
@@ -723,6 +783,8 @@ export class WidgetStore {
|
|
|
723
783
|
this.resetThreadTracking();
|
|
724
784
|
this.appStateSubscription?.remove();
|
|
725
785
|
this.appStateSubscription = null;
|
|
786
|
+
this.resetPost();
|
|
787
|
+
this.pendingPostId = null;
|
|
726
788
|
this.setState({ ...INITIAL_STATE });
|
|
727
789
|
|
|
728
790
|
await this.enqueueIdentity(() => this.wipeSession());
|
|
@@ -755,9 +817,11 @@ export class WidgetStore {
|
|
|
755
817
|
this.userKeyLoaded = true;
|
|
756
818
|
this.pendingAgentByConversation = {};
|
|
757
819
|
this.seenMessageIds = [];
|
|
820
|
+
this.earlyStatuses.clear();
|
|
758
821
|
this.visitorTypingActive = false;
|
|
759
822
|
this.lastTypingPingAt = 0;
|
|
760
823
|
this.launchCardOffered = false;
|
|
824
|
+
this.resetPost();
|
|
761
825
|
this.setState({ ...USER_SCOPED_STATE });
|
|
762
826
|
|
|
763
827
|
const token = await this.client.getToken();
|
|
@@ -875,6 +939,8 @@ export class WidgetStore {
|
|
|
875
939
|
// and the thread on screen so replies sent meanwhile show up.
|
|
876
940
|
await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
|
|
877
941
|
await this.refreshConfig();
|
|
942
|
+
// A post may have gone live (or ended) while the app was away.
|
|
943
|
+
if (!this.isStale(epoch)) this.loadPost();
|
|
878
944
|
}
|
|
879
945
|
|
|
880
946
|
/**
|
|
@@ -920,6 +986,8 @@ export class WidgetStore {
|
|
|
920
986
|
if (this.streamConnectedOnce || this.streamErrored) {
|
|
921
987
|
void this.refreshConversations();
|
|
922
988
|
void this.refreshOpenThread();
|
|
989
|
+
// Answers under the post too.
|
|
990
|
+
this.loadPost();
|
|
923
991
|
}
|
|
924
992
|
this.streamConnectedOnce = true;
|
|
925
993
|
this.streamErrored = false;
|
|
@@ -1071,12 +1139,6 @@ export class WidgetStore {
|
|
|
1071
1139
|
return false;
|
|
1072
1140
|
}
|
|
1073
1141
|
|
|
1074
|
-
/**
|
|
1075
|
-
* A teammate deleted a message: the visitor sees "This message was deleted"
|
|
1076
|
-
* in its place (they can't delete anything themselves), and the list is
|
|
1077
|
-
* refreshed so its preview stops showing the text. The id stays in
|
|
1078
|
-
* `seenMessageIds`, so a replayed `message.new` can't restore the content.
|
|
1079
|
-
*/
|
|
1080
1142
|
/**
|
|
1081
1143
|
* A teammate opened the conversation: the visitor's messages are now Seen.
|
|
1082
1144
|
* The ids are also remembered, because the event can arrive before the send
|
|
@@ -1086,9 +1148,15 @@ export class WidgetStore {
|
|
|
1086
1148
|
const ids: string[] = Array.isArray(data?.messageIds) ? data.messageIds : [];
|
|
1087
1149
|
const status: string | undefined = typeof data?.status === 'string' ? data.status : undefined;
|
|
1088
1150
|
if (!status || ids.length === 0) return;
|
|
1089
|
-
for (const id of ids)
|
|
1090
|
-
|
|
1091
|
-
this.earlyStatuses.delete(
|
|
1151
|
+
for (const id of ids) {
|
|
1152
|
+
// Re-inserted, so a repeated id counts as newest when trimming.
|
|
1153
|
+
this.earlyStatuses.delete(id);
|
|
1154
|
+
this.earlyStatuses.set(id, status);
|
|
1155
|
+
}
|
|
1156
|
+
// One event can carry many ids: trim back to the cap, oldest first.
|
|
1157
|
+
for (const id of this.earlyStatuses.keys()) {
|
|
1158
|
+
if (this.earlyStatuses.size <= SEEN_MESSAGE_LIMIT) break;
|
|
1159
|
+
this.earlyStatuses.delete(id);
|
|
1092
1160
|
}
|
|
1093
1161
|
if (!this.isThreadOnScreen(data?.conversationId)) return;
|
|
1094
1162
|
this.setState((current) => ({
|
|
@@ -1098,6 +1166,12 @@ export class WidgetStore {
|
|
|
1098
1166
|
}));
|
|
1099
1167
|
}
|
|
1100
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* A teammate deleted a message: the visitor sees "This message was deleted"
|
|
1171
|
+
* in its place (they can't delete anything themselves), and the list is
|
|
1172
|
+
* refreshed so its preview stops showing the text. The id stays in
|
|
1173
|
+
* `seenMessageIds`, so a replayed `message.new` can't restore the content.
|
|
1174
|
+
*/
|
|
1101
1175
|
private handleDeletedMessage(data: any) {
|
|
1102
1176
|
const messageId: string | undefined = data?.messageId;
|
|
1103
1177
|
if (!messageId) return;
|
|
@@ -1147,6 +1221,18 @@ export class WidgetStore {
|
|
|
1147
1221
|
message.direction !== 'inbound' &&
|
|
1148
1222
|
message.senderType !== 'visitor';
|
|
1149
1223
|
const onScreen = this.isThreadOnScreen(conversationId);
|
|
1224
|
+
// A team answer in the shown post's conversation shows under the post —
|
|
1225
|
+
// and, with the post open in front of them, counts as read there. The
|
|
1226
|
+
// post only shows text: an attachment-only answer stays unread (card,
|
|
1227
|
+
// badge) for the messenger, and so does everything after it until the
|
|
1228
|
+
// conversation is opened there, since reading moves the server cursor past it.
|
|
1229
|
+
const post = this.state.post;
|
|
1230
|
+
const inPost = isTeamReply && !!conversationId && post?.conversationId === conversationId;
|
|
1231
|
+
const answerText = (message.bodyPlain ?? '').trim();
|
|
1232
|
+
if (inPost && !this.state.unreadByConversation[conversationId]) this.postUnshownReplies.delete(conversationId);
|
|
1233
|
+
const readInPost =
|
|
1234
|
+
inPost && !!answerText && this.state.postExpanded && !this.state.isOpen && !this.state.unreadByConversation[conversationId];
|
|
1235
|
+
if (inPost && !answerText) this.postUnshownReplies.add(conversationId);
|
|
1150
1236
|
|
|
1151
1237
|
// Set inside the updater, read after: `setState` runs synchronously.
|
|
1152
1238
|
let countedAsUnread = false;
|
|
@@ -1179,7 +1265,7 @@ export class WidgetStore {
|
|
|
1179
1265
|
};
|
|
1180
1266
|
}
|
|
1181
1267
|
|
|
1182
|
-
if (!isTeamReply || !conversationId) return { conversations };
|
|
1268
|
+
if (!isTeamReply || !conversationId || readInPost) return { conversations };
|
|
1183
1269
|
|
|
1184
1270
|
const unreadByConversation = {
|
|
1185
1271
|
...current.unreadByConversation,
|
|
@@ -1209,7 +1295,14 @@ export class WidgetStore {
|
|
|
1209
1295
|
|
|
1210
1296
|
// The visitor is reading it live — move the server cursor too, or the
|
|
1211
1297
|
// next list/init would badge a reply they already saw.
|
|
1212
|
-
if (onScreen && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
1298
|
+
if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
|
|
1299
|
+
|
|
1300
|
+
const current = this.state.post;
|
|
1301
|
+
if (inPost && answerText && current?.id === post?.id) {
|
|
1302
|
+
const answer = { from: 'team' as const, text: answerText, senderName: message.senderName, at: message.createdAt };
|
|
1303
|
+
this.postRevision += 1;
|
|
1304
|
+
this.setState({ post: { ...current, thread: [...current.thread, answer] } });
|
|
1305
|
+
}
|
|
1213
1306
|
|
|
1214
1307
|
// Emitted after the state write so a listener reading the unread count sees
|
|
1215
1308
|
// the new value. Only unread-counting replies fire it, which keeps the event
|
|
@@ -1263,7 +1356,7 @@ export class WidgetStore {
|
|
|
1263
1356
|
body: null,
|
|
1264
1357
|
bodyPlain: data.bodyPlain || 'Conversation updated',
|
|
1265
1358
|
attachments: null,
|
|
1266
|
-
metadata: data.metadata || null,
|
|
1359
|
+
metadata: this.hydrateMetadata(data.metadata || null),
|
|
1267
1360
|
status: 'delivered',
|
|
1268
1361
|
createdAt: data.createdAt || new Date().toISOString(),
|
|
1269
1362
|
};
|
|
@@ -1391,6 +1484,8 @@ export class WidgetStore {
|
|
|
1391
1484
|
this.setState({ isOpen: false, view: 'bubble', agentTyping: false });
|
|
1392
1485
|
this.clearTypingTimer();
|
|
1393
1486
|
this.track('widget_closed');
|
|
1487
|
+
// A post left open behind the messenger is back in front of them.
|
|
1488
|
+
this.readPostConversation();
|
|
1394
1489
|
}
|
|
1395
1490
|
|
|
1396
1491
|
toggle(space?: WidgetSpaceType): void {
|
|
@@ -1667,10 +1762,35 @@ export class WidgetStore {
|
|
|
1667
1762
|
.reverse();
|
|
1668
1763
|
}
|
|
1669
1764
|
|
|
1670
|
-
/**
|
|
1765
|
+
/**
|
|
1766
|
+
* Media URLs inside metadata (the "joined" line's avatar, product photos in
|
|
1767
|
+
* agent blocks) can be server-relative paths, and are remote content: like
|
|
1768
|
+
* every other media URL they resolve against `baseUrl`, and anything that
|
|
1769
|
+
* isn't http(s) is dropped before it reaches an `<Image>`.
|
|
1770
|
+
*/
|
|
1671
1771
|
private hydrateMetadata(metadata: Message['metadata']): Message['metadata'] {
|
|
1672
|
-
if (!metadata
|
|
1673
|
-
|
|
1772
|
+
if (!metadata) return null;
|
|
1773
|
+
let next = metadata;
|
|
1774
|
+
if (typeof metadata.agentAvatar === 'string') {
|
|
1775
|
+
next = { ...next, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
|
|
1776
|
+
}
|
|
1777
|
+
if (Array.isArray(metadata.agentUiBlocks)) {
|
|
1778
|
+
next = {
|
|
1779
|
+
...next,
|
|
1780
|
+
agentUiBlocks: metadata.agentUiBlocks.map((block: unknown) => this.hydrateBlockMedia(block)),
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
return next;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
private hydrateBlockMedia(block: unknown): unknown {
|
|
1787
|
+
const candidate = block as { type?: unknown; product?: { imageUrl?: unknown } } | null;
|
|
1788
|
+
const imageUrl = candidate?.product?.imageUrl;
|
|
1789
|
+
if (candidate?.type !== 'product_service_card' || typeof imageUrl !== 'string') return block;
|
|
1790
|
+
return {
|
|
1791
|
+
...candidate,
|
|
1792
|
+
product: { ...candidate.product, imageUrl: this.client.resolveUrl(imageUrl) ?? undefined },
|
|
1793
|
+
};
|
|
1674
1794
|
}
|
|
1675
1795
|
|
|
1676
1796
|
/**
|
|
@@ -1933,9 +2053,345 @@ export class WidgetStore {
|
|
|
1933
2053
|
.catch(() => {});
|
|
1934
2054
|
}
|
|
1935
2055
|
|
|
2056
|
+
// ─── Send posts ─────────────────────────────────────────────────────────
|
|
2057
|
+
|
|
2058
|
+
/** Ask the server for the post this visitor should see now. */
|
|
2059
|
+
loadPost(): void {
|
|
2060
|
+
if (!this.postsEnabled || !this.state.booted) return;
|
|
2061
|
+
const session = this.postSession;
|
|
2062
|
+
const request = ++this.postRequest;
|
|
2063
|
+
const revision = this.postRevision;
|
|
2064
|
+
this.client
|
|
2065
|
+
.getPost()
|
|
2066
|
+
.then(({ post, recheckInMs }) => {
|
|
2067
|
+
if (this.postStale(session) || request !== this.postRequest) return;
|
|
2068
|
+
// An answer or reply joined the post meanwhile, maybe after this was
|
|
2069
|
+
// read: ask again rather than show the post without it.
|
|
2070
|
+
if (revision !== this.postRevision) {
|
|
2071
|
+
this.loadPost();
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
const hydrated = post ? this.hydratePost(post) : null;
|
|
2075
|
+
const pushed = this.pendingPostId;
|
|
2076
|
+
this.pendingPostId = null;
|
|
2077
|
+
this.schedulePostRecheck(recheckInMs, session);
|
|
2078
|
+
if (hydrated && this.unsentDismissIds.has(hydrated.id)) {
|
|
2079
|
+
// Closed already; the server only missed hearing it.
|
|
2080
|
+
this.showPost(null);
|
|
2081
|
+
this.resendPostDismiss(hydrated.id, session);
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
// A full post opens by itself unless they minimized it; a tapped push always opens it.
|
|
2085
|
+
this.showPost(hydrated, !!hydrated && ((hydrated.sentAs === 'full' && !hydrated.minimized) || hydrated.id === pushed));
|
|
2086
|
+
})
|
|
2087
|
+
// Keep whatever is on screen; the next session or foreground asks again.
|
|
2088
|
+
.catch(() => {});
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* Coerces the server's post into the shape the view relies on: missing
|
|
2093
|
+
* strings become empty, unknown enum values fall back to the plainest
|
|
2094
|
+
* option, and malformed reactions or replies are dropped.
|
|
2095
|
+
*/
|
|
2096
|
+
private hydratePost(post: WidgetPost): WidgetPost | null {
|
|
2097
|
+
if (!post || typeof post.id !== 'string' || !post.id || typeof post.bodyHtml !== 'string') return null;
|
|
2098
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
2099
|
+
const sender = post.sender && text(post.sender.name) ? post.sender : null;
|
|
2100
|
+
const replies = Array.isArray(post.replies)
|
|
2101
|
+
? post.replies
|
|
2102
|
+
.filter((reply) => typeof reply?.text === 'string' && reply.text.length > 0)
|
|
2103
|
+
.map((reply) => ({ text: reply.text, at: text(reply.at) }))
|
|
2104
|
+
: [];
|
|
2105
|
+
return {
|
|
2106
|
+
id: post.id,
|
|
2107
|
+
bodyHtml: post.bodyHtml,
|
|
2108
|
+
snippet: text(post.snippet),
|
|
2109
|
+
sender: sender
|
|
2110
|
+
? {
|
|
2111
|
+
name: sender.name,
|
|
2112
|
+
initials: text(sender.initials),
|
|
2113
|
+
// Teammate photos can be server-relative media paths.
|
|
2114
|
+
avatarUrl: this.client.resolveUrl(typeof sender.avatarUrl === 'string' ? sender.avatarUrl : null),
|
|
2115
|
+
}
|
|
2116
|
+
: null,
|
|
2117
|
+
workspaceName: text(post.workspaceName),
|
|
2118
|
+
format: post.format === 'large' ? 'large' : 'small',
|
|
2119
|
+
replyType: post.replyType === 'text' || post.replyType === 'reaction' ? post.replyType : 'none',
|
|
2120
|
+
reactions: Array.isArray(post.reactions)
|
|
2121
|
+
? post.reactions.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
|
|
2122
|
+
: [],
|
|
2123
|
+
sentAs: post.sentAs === 'badge' || post.sentAs === 'full' ? post.sentAs : 'snippet',
|
|
2124
|
+
reacted: typeof post.reacted === 'string' && post.reacted ? post.reacted : null,
|
|
2125
|
+
// Older Workstation versions don't send it.
|
|
2126
|
+
minimized: post.minimized === true,
|
|
2127
|
+
replies,
|
|
2128
|
+
thread: Array.isArray(post.thread)
|
|
2129
|
+
? post.thread
|
|
2130
|
+
.filter((message) => typeof message?.text === 'string' && message.text.length > 0)
|
|
2131
|
+
.map((message) => ({
|
|
2132
|
+
from: message.from === 'team' ? ('team' as const) : ('person' as const),
|
|
2133
|
+
text: message.text,
|
|
2134
|
+
senderName: message.from === 'team' ? text(message.senderName) || null : null,
|
|
2135
|
+
at: text(message.at),
|
|
2136
|
+
}))
|
|
2137
|
+
: replies.map((reply) => ({ from: 'person' as const, text: reply.text, senderName: null, at: reply.at })),
|
|
2138
|
+
conversationId: text(post.conversationId) || null,
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* Puts a post on screen (or takes it down). A different post starts open or
|
|
2144
|
+
* as its card per `expand`; the same one keeps what the visitor left it as,
|
|
2145
|
+
* so a full post put back to its card doesn't pop open on every foreground.
|
|
2146
|
+
*/
|
|
2147
|
+
private showPost(post: WidgetPost | null, expand = false): void {
|
|
2148
|
+
const current = this.state.post;
|
|
2149
|
+
if (current && current.id !== post?.id) this.viewedPostIds.delete(current.id);
|
|
2150
|
+
const samePost = !!post && current?.id === post.id;
|
|
2151
|
+
const expanded = !!post && (samePost ? this.state.postExpanded : expand);
|
|
2152
|
+
this.setState({ post, postExpanded: expanded });
|
|
2153
|
+
if (post && expanded && !samePost) this.reportPostOpen(post);
|
|
2154
|
+
this.readPostConversation();
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** The post's card or full view is on screen. Reported once each time it's shown. */
|
|
2158
|
+
postDidShow(campaignId: string): void {
|
|
2159
|
+
if (this.state.post?.id !== campaignId || this.viewedPostIds.has(campaignId)) return;
|
|
2160
|
+
this.viewedPostIds.add(campaignId);
|
|
2161
|
+
this.client.sendPostInteraction(campaignId, 'view').catch(() => {});
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/** The visitor tapped the post's card: open it in full. */
|
|
2165
|
+
expandPost(campaignId: string): void {
|
|
2166
|
+
const post = this.state.post;
|
|
2167
|
+
if (!post || post.id !== campaignId || this.state.postExpanded) return;
|
|
2168
|
+
this.setState({ postExpanded: true });
|
|
2169
|
+
this.reportPostOpen(post);
|
|
2170
|
+
this.readPostConversation();
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* The post is open in front of them (and the messenger isn't): the team's
|
|
2175
|
+
* answers under it are read, here and on the server. Not when one of them
|
|
2176
|
+
* is something the post can't show, or the post has fewer answers since
|
|
2177
|
+
* their last reply than are unread (an attachment from before this launch,
|
|
2178
|
+
* say): those wait for the messenger.
|
|
2179
|
+
*/
|
|
2180
|
+
private readPostConversation(): void {
|
|
2181
|
+
const { post, postExpanded, isOpen, unreadByConversation } = this.state;
|
|
2182
|
+
const conversationId = post?.conversationId;
|
|
2183
|
+
const unread = conversationId ? (unreadByConversation[conversationId] ?? 0) : 0;
|
|
2184
|
+
if (!post || !conversationId || !postExpanded || isOpen || unread === 0) return;
|
|
2185
|
+
if (this.postUnshownReplies.has(conversationId)) return;
|
|
2186
|
+
let shown = 0;
|
|
2187
|
+
for (let index = post.thread.length - 1; index >= 0 && post.thread[index]?.from === 'team'; index -= 1) shown += 1;
|
|
2188
|
+
if (shown < unread) return;
|
|
2189
|
+
this.setState({ unreadByConversation: this.clearUnreadFor(conversationId) });
|
|
2190
|
+
this.events.emit(RepzoWidgetEvents.ConversationDidRead, { conversationId });
|
|
2191
|
+
this.scheduleMarkRead(conversationId);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/** Back to its pill or card for now, without telling the server (see PostOverlay's onCollapse). */
|
|
2195
|
+
collapsePost(campaignId: string): void {
|
|
2196
|
+
if (this.state.post?.id !== campaignId || !this.state.postExpanded) return;
|
|
2197
|
+
this.setState({ postExpanded: false });
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* "Not now" — the ⌄ button, a swipe down, or Android's back: the post goes
|
|
2202
|
+
* back to its badge (or snippet card) and stays there, on later launches
|
|
2203
|
+
* too, instead of opening by itself again. Unlike ×, it can be opened again.
|
|
2204
|
+
*/
|
|
2205
|
+
minimizePost(campaignId: string): void {
|
|
2206
|
+
const post = this.state.post;
|
|
2207
|
+
if (!post || post.id !== campaignId || !this.state.postExpanded) return;
|
|
2208
|
+
this.setState({ post: { ...post, minimized: true }, postExpanded: false });
|
|
2209
|
+
if (!post.minimized) this.client.sendPostInteraction(campaignId, 'minimize').catch(() => {});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
private reportPostOpen(post: WidgetPost): void {
|
|
2213
|
+
// "Opened" is opening it from its card; a full post is opened by showing it.
|
|
2214
|
+
if (post.sentAs === 'full') return;
|
|
2215
|
+
this.client.sendPostInteraction(post.id, 'open').catch(() => {});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* The visitor tapped a link, reacted, replied to or closed the post. Closing
|
|
2220
|
+
* takes it down at once and loads the next one; a reaction shows as picked
|
|
2221
|
+
* right away. A reply resolves with what was saved (shown under the post)
|
|
2222
|
+
* and rejects when it wasn't, so the reply box can keep the text.
|
|
2223
|
+
*/
|
|
2224
|
+
async recordPostInteraction(
|
|
2225
|
+
campaignId: string,
|
|
2226
|
+
kind: Exclude<PostInteractionKind, 'view' | 'open' | 'minimize'>,
|
|
2227
|
+
value?: string
|
|
2228
|
+
): Promise<PostReply | undefined> {
|
|
2229
|
+
const post = this.state.post;
|
|
2230
|
+
if (!post || post.id !== campaignId) return undefined;
|
|
2231
|
+
const session = this.postSession;
|
|
2232
|
+
if (kind === 'dismiss') {
|
|
2233
|
+
// A load already in flight would put the closed post back.
|
|
2234
|
+
this.postRequest += 1;
|
|
2235
|
+
this.showPost(null);
|
|
2236
|
+
}
|
|
2237
|
+
if (kind === 'reaction' && value) this.setState({ post: { ...post, reacted: value } });
|
|
2238
|
+
|
|
2239
|
+
const sent = this.client.sendPostInteraction(campaignId, kind, value);
|
|
2240
|
+
// A reaction answers the post: it thanks them for a moment, then goes
|
|
2241
|
+
// (another tap in the meantime changes their pick and restarts it).
|
|
2242
|
+
if (kind === 'reaction' && value) this.startPostThanks(campaignId, sent, session);
|
|
2243
|
+
|
|
2244
|
+
let result: { hidden: boolean; reply?: PostReply; conversationId?: string | null };
|
|
2245
|
+
try {
|
|
2246
|
+
result = await sent;
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
if (kind === 'reply') throw error;
|
|
2249
|
+
if (this.postStale(session)) return undefined;
|
|
2250
|
+
// × closes it for good, so it stays down until the server hears it.
|
|
2251
|
+
if (kind === 'dismiss') this.unsentDismissIds.add(campaignId);
|
|
2252
|
+
if (kind === 'reaction') {
|
|
2253
|
+
const current = this.state.post;
|
|
2254
|
+
if (current?.id === campaignId && current.reacted === value) {
|
|
2255
|
+
this.setState({ post: { ...current, reacted: post.reacted } });
|
|
2256
|
+
// Not saved: it stays, as it was, so they can try again — unless an
|
|
2257
|
+
// earlier pick was saved, which already answered it: that one leaves.
|
|
2258
|
+
if (post.reacted) this.startPostThanks(campaignId, Promise.resolve(), session);
|
|
2259
|
+
else this.clearPostThanks();
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
return undefined;
|
|
2263
|
+
}
|
|
2264
|
+
if (this.postStale(session)) return undefined;
|
|
2265
|
+
|
|
2266
|
+
if (kind === 'reply') {
|
|
2267
|
+
// Falls back to what was typed when the response has no usable reply.
|
|
2268
|
+
const saved = result?.reply;
|
|
2269
|
+
const reply: PostReply =
|
|
2270
|
+
typeof saved?.text === 'string' && saved.text
|
|
2271
|
+
? { text: saved.text, at: typeof saved.at === 'string' ? saved.at : new Date().toISOString() }
|
|
2272
|
+
: { text: value ?? '', at: new Date().toISOString() };
|
|
2273
|
+
const current = this.state.post;
|
|
2274
|
+
if (current?.id === campaignId) {
|
|
2275
|
+
// Their first reply starts the post's conversation: answers there join the thread from now on.
|
|
2276
|
+
const conversationId =
|
|
2277
|
+
typeof result?.conversationId === 'string' && result.conversationId ? result.conversationId : current.conversationId;
|
|
2278
|
+
this.postRevision += 1;
|
|
2279
|
+
this.setState({
|
|
2280
|
+
post: {
|
|
2281
|
+
...current,
|
|
2282
|
+
replies: [...current.replies, reply],
|
|
2283
|
+
thread: [...current.thread, { from: 'person', text: reply.text, senderName: null, at: reply.at }],
|
|
2284
|
+
conversationId,
|
|
2285
|
+
},
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
// The reply went to the post's conversation: show it in the Messenger's list.
|
|
2289
|
+
void this.refreshConversations();
|
|
2290
|
+
return reply;
|
|
2291
|
+
}
|
|
2292
|
+
// A reaction's post leaves when its thanks ends (see startPostThanks).
|
|
2293
|
+
if (result?.hidden && kind !== 'reaction') {
|
|
2294
|
+
if (this.state.post?.id === campaignId) this.showPost(null);
|
|
2295
|
+
this.loadPost();
|
|
2296
|
+
}
|
|
2297
|
+
return undefined;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
private startPostThanks(campaignId: string, sent: Promise<unknown>, session: number): void {
|
|
2301
|
+
this.clearPostThanks();
|
|
2302
|
+
this.postThanksTimer = setTimeout(() => {
|
|
2303
|
+
this.postThanksTimer = null;
|
|
2304
|
+
if (this.postStale(session) || this.state.post?.id !== campaignId) return;
|
|
2305
|
+
this.postRequest += 1;
|
|
2306
|
+
this.showPost(null);
|
|
2307
|
+
// Ask for the next one once the server has the reaction, so it doesn't send this one back.
|
|
2308
|
+
sent.then(
|
|
2309
|
+
() => {
|
|
2310
|
+
if (!this.postStale(session)) this.loadPost();
|
|
2311
|
+
},
|
|
2312
|
+
() => {
|
|
2313
|
+
if (!this.postStale(session)) this.loadPost();
|
|
2314
|
+
}
|
|
2315
|
+
);
|
|
2316
|
+
}, POST_THANKS_MS);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
private clearPostThanks(): void {
|
|
2320
|
+
if (this.postThanksTimer) clearTimeout(this.postThanksTimer);
|
|
2321
|
+
this.postThanksTimer = null;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
private resendPostDismiss(campaignId: string, session: number): void {
|
|
2325
|
+
this.client
|
|
2326
|
+
.sendPostInteraction(campaignId, 'dismiss')
|
|
2327
|
+
.then(() => {
|
|
2328
|
+
if (this.postStale(session)) return;
|
|
2329
|
+
this.unsentDismissIds.delete(campaignId);
|
|
2330
|
+
this.loadPost();
|
|
2331
|
+
})
|
|
2332
|
+
// Still offline: it stays down and the next load tries again.
|
|
2333
|
+
.catch(() => {});
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* A post push was tapped: show that post, open in full, as soon as the
|
|
2338
|
+
* session is ready. The messenger steps aside for it, the way a reply push
|
|
2339
|
+
* opens its conversation.
|
|
2340
|
+
*/
|
|
2341
|
+
private openPostFromPush(campaignId: string): void {
|
|
2342
|
+
if (!this.postsEnabled) return;
|
|
2343
|
+
if (this.state.isOpen) this.hide();
|
|
2344
|
+
this.pendingPostId = campaignId;
|
|
2345
|
+
if (this.state.post?.id === campaignId) {
|
|
2346
|
+
this.pendingPostId = null;
|
|
2347
|
+
this.expandPost(campaignId);
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
if (this.state.booted) this.loadPost();
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
private clearPostRecheck(): void {
|
|
2354
|
+
if (this.postRecheckTimer) clearTimeout(this.postRecheckTimer);
|
|
2355
|
+
this.postRecheckTimer = null;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/** The server knows when the shown post stops; ask again then, so it leaves on time. */
|
|
2359
|
+
private schedulePostRecheck(recheckInMs: number | null | undefined, session: number): void {
|
|
2360
|
+
this.clearPostRecheck();
|
|
2361
|
+
if (typeof recheckInMs !== 'number' || !Number.isFinite(recheckInMs)) return;
|
|
2362
|
+
const delay = Math.min(Math.max(recheckInMs, 0) + POST_RECHECK_SLACK_MS, POST_RECHECK_MAX_MS);
|
|
2363
|
+
this.postRecheckTimer = setTimeout(() => {
|
|
2364
|
+
this.postRecheckTimer = null;
|
|
2365
|
+
if (!this.postStale(session)) this.loadPost();
|
|
2366
|
+
}, delay);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Post callbacks check this, not the identity epoch: a same-user login or
|
|
2371
|
+
* an anonymous re-login bumps the epoch but keeps the session (and the post),
|
|
2372
|
+
* and would otherwise drop the post's load and strand its timers.
|
|
2373
|
+
*/
|
|
2374
|
+
private postStale(session: number): boolean {
|
|
2375
|
+
return session !== this.postSession;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/** The visitor changed or posts were turned off: drop it and any pending load. */
|
|
2379
|
+
private resetPost(): void {
|
|
2380
|
+
this.postSession += 1;
|
|
2381
|
+
this.clearPostRecheck();
|
|
2382
|
+
this.clearPostThanks();
|
|
2383
|
+
this.postRequest += 1;
|
|
2384
|
+
this.viewedPostIds.clear();
|
|
2385
|
+
this.unsentDismissIds.clear();
|
|
2386
|
+
this.postUnshownReplies.clear();
|
|
2387
|
+
if (this.state.post || this.state.postExpanded) this.setState({ post: null, postExpanded: false });
|
|
2388
|
+
}
|
|
2389
|
+
|
|
1936
2390
|
// ─── Teardown (tests / hot reload) ──────────────────────────────────────
|
|
1937
2391
|
|
|
1938
2392
|
destroy(): void {
|
|
2393
|
+
this.clearPostRecheck();
|
|
2394
|
+
this.clearPostThanks();
|
|
1939
2395
|
this.realtime.close();
|
|
1940
2396
|
this.clearTypingTimer();
|
|
1941
2397
|
this.resetThreadTracking();
|
|
@@ -1967,11 +2423,6 @@ function isLocalMessage(message: Message): boolean {
|
|
|
1967
2423
|
return message.id.startsWith('temp-');
|
|
1968
2424
|
}
|
|
1969
2425
|
|
|
1970
|
-
/**
|
|
1971
|
-
* A sent visitor message keeps its local previews (a `file://` URI, or no URL
|
|
1972
|
-
* at all) until the server copy shows up — the stream echo or a history page.
|
|
1973
|
-
* Its attachments, the only ones that can be opened, then replace them.
|
|
1974
|
-
*/
|
|
1975
2426
|
/** A "Seen" missed while the stream was down shows up on the next re-read. */
|
|
1976
2427
|
function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
|
|
1977
2428
|
const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
|
|
@@ -2006,6 +2457,11 @@ function adoptServerDeletions(messages: Message[], server: Message[]): Message[]
|
|
|
2006
2457
|
return changed ? next : messages;
|
|
2007
2458
|
}
|
|
2008
2459
|
|
|
2460
|
+
/**
|
|
2461
|
+
* A sent visitor message keeps its local previews (a `file://` URI, or no URL
|
|
2462
|
+
* at all) until the server copy shows up — the stream echo or a history page.
|
|
2463
|
+
* Its attachments, the only ones that can be opened, then replace them.
|
|
2464
|
+
*/
|
|
2009
2465
|
function adoptServerAttachments(messages: Message[], server: Message[]): Message[] {
|
|
2010
2466
|
const serverCopies = new Map(
|
|
2011
2467
|
server.filter((message) => message.attachments?.length).map((message) => [message.id, message])
|