@wix/himalaya-cli 0.1487.0 → 0.1488.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/package.json +2 -2
- package/templates/branded-lite/dev/index.ts +146 -66
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +5 -3
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +84 -5
- package/templates/branded-lite/tier5-src/business-chat/index.ts +135 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1488.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -71,5 +71,5 @@
|
|
|
71
71
|
"artifactId": "himalaya-cli"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
|
-
"falconPackageHash": "
|
|
74
|
+
"falconPackageHash": "eda28cbf9b0757670a64bf4f2e5d095b82109dc86c7446f6fe7472fb"
|
|
75
75
|
}
|
|
@@ -86650,6 +86650,26 @@ const SCREENS = runtimeSetting({
|
|
|
86650
86650
|
}
|
|
86651
86651
|
}
|
|
86652
86652
|
},
|
|
86653
|
+
{
|
|
86654
|
+
"id": "himi_business_chat.incoming_attachment_$item.id",
|
|
86655
|
+
"body": {
|
|
86656
|
+
"package": "himalaya",
|
|
86657
|
+
"type": "Image",
|
|
86658
|
+
"props": {
|
|
86659
|
+
"url": "$item.imageUrl",
|
|
86660
|
+
"fillWidth": true,
|
|
86661
|
+
"aspectRatio": 1.5,
|
|
86662
|
+
"contentMode": "fit",
|
|
86663
|
+
"contentDescription": "$item.imageDescription"
|
|
86664
|
+
}
|
|
86665
|
+
},
|
|
86666
|
+
"modifiers": {
|
|
86667
|
+
"fillMaxWidth": true,
|
|
86668
|
+
"cornerRadius": 12,
|
|
86669
|
+
"accessibilityLabel": "$item.imageDescription",
|
|
86670
|
+
"hiddenPath": "$item.noImage"
|
|
86671
|
+
}
|
|
86672
|
+
},
|
|
86653
86673
|
{
|
|
86654
86674
|
"id": "himi_business_chat.incoming_text_$item.id",
|
|
86655
86675
|
"body": {
|
|
@@ -86661,29 +86681,73 @@ const SCREENS = runtimeSetting({
|
|
|
86661
86681
|
"color": "textPrimary",
|
|
86662
86682
|
"collapseWhenEmpty": true,
|
|
86663
86683
|
"textDirection": "auto",
|
|
86664
|
-
"markdown": true
|
|
86684
|
+
"markdown": true,
|
|
86685
|
+
"maxLines": 1000
|
|
86665
86686
|
}
|
|
86666
86687
|
},
|
|
86667
86688
|
"modifiers": {}
|
|
86668
86689
|
},
|
|
86669
86690
|
{
|
|
86670
|
-
"id": "himi_business_chat.
|
|
86691
|
+
"id": "himi_business_chat.coupon_$item.id",
|
|
86671
86692
|
"body": {
|
|
86672
86693
|
"package": "himalaya",
|
|
86673
|
-
"type": "
|
|
86694
|
+
"type": "VStack",
|
|
86674
86695
|
"props": {
|
|
86675
|
-
"
|
|
86676
|
-
|
|
86677
|
-
|
|
86678
|
-
|
|
86679
|
-
|
|
86696
|
+
"children": [
|
|
86697
|
+
{
|
|
86698
|
+
"id": "himi_business_chat.coupon_label_$item.id",
|
|
86699
|
+
"body": {
|
|
86700
|
+
"package": "himalaya",
|
|
86701
|
+
"type": "Text",
|
|
86702
|
+
"props": {
|
|
86703
|
+
"text": "Coupon code",
|
|
86704
|
+
"style": "caption",
|
|
86705
|
+
"color": "textSecondary",
|
|
86706
|
+
"collapseWhenEmpty": true,
|
|
86707
|
+
"textDirection": "auto"
|
|
86708
|
+
}
|
|
86709
|
+
},
|
|
86710
|
+
"modifiers": {}
|
|
86711
|
+
},
|
|
86712
|
+
{
|
|
86713
|
+
"id": "himi_business_chat.coupon_code_$item.id",
|
|
86714
|
+
"body": {
|
|
86715
|
+
"package": "himalaya",
|
|
86716
|
+
"type": "Text",
|
|
86717
|
+
"props": {
|
|
86718
|
+
"text": "$item.couponCode",
|
|
86719
|
+
"style": "title3",
|
|
86720
|
+
"color": "accentPrimary",
|
|
86721
|
+
"collapseWhenEmpty": true,
|
|
86722
|
+
"textDirection": "auto"
|
|
86723
|
+
}
|
|
86724
|
+
},
|
|
86725
|
+
"modifiers": {}
|
|
86726
|
+
}
|
|
86727
|
+
],
|
|
86728
|
+
"spacing": 2,
|
|
86729
|
+
"alignment": "center"
|
|
86680
86730
|
}
|
|
86681
86731
|
},
|
|
86682
86732
|
"modifiers": {
|
|
86683
86733
|
"fillMaxWidth": true,
|
|
86734
|
+
"background": {
|
|
86735
|
+
"token": "accentSurface"
|
|
86736
|
+
},
|
|
86684
86737
|
"cornerRadius": 12,
|
|
86685
|
-
"
|
|
86686
|
-
|
|
86738
|
+
"border": {
|
|
86739
|
+
"width": 1,
|
|
86740
|
+
"color": {
|
|
86741
|
+
"token": "accentPrimary"
|
|
86742
|
+
}
|
|
86743
|
+
},
|
|
86744
|
+
"padding": {
|
|
86745
|
+
"leading": 14,
|
|
86746
|
+
"trailing": 14,
|
|
86747
|
+
"top": 10,
|
|
86748
|
+
"bottom": 10
|
|
86749
|
+
},
|
|
86750
|
+
"hiddenPath": "$item.noCoupon"
|
|
86687
86751
|
}
|
|
86688
86752
|
},
|
|
86689
86753
|
{
|
|
@@ -86701,8 +86765,9 @@ const SCREENS = runtimeSetting({
|
|
|
86701
86765
|
"type": "Button",
|
|
86702
86766
|
"props": {
|
|
86703
86767
|
"title": "$item.label",
|
|
86704
|
-
"style": "
|
|
86705
|
-
"size": "
|
|
86768
|
+
"style": "$item.style",
|
|
86769
|
+
"size": "md",
|
|
86770
|
+
"fullWidth": true,
|
|
86706
86771
|
"action": {
|
|
86707
86772
|
"actionId": "capability.open_url.open",
|
|
86708
86773
|
"params": {
|
|
@@ -86937,21 +87002,6 @@ const SCREENS = runtimeSetting({
|
|
|
86937
87002
|
},
|
|
86938
87003
|
"modifiers": {}
|
|
86939
87004
|
},
|
|
86940
|
-
{
|
|
86941
|
-
"id": "himi_business_chat.outgoing_text_$item.id",
|
|
86942
|
-
"body": {
|
|
86943
|
-
"package": "himalaya",
|
|
86944
|
-
"type": "Text",
|
|
86945
|
-
"props": {
|
|
86946
|
-
"text": "$item.bodyText",
|
|
86947
|
-
"style": "body",
|
|
86948
|
-
"color": "textOnAccent",
|
|
86949
|
-
"collapseWhenEmpty": true,
|
|
86950
|
-
"textDirection": "auto"
|
|
86951
|
-
}
|
|
86952
|
-
},
|
|
86953
|
-
"modifiers": {}
|
|
86954
|
-
},
|
|
86955
87005
|
{
|
|
86956
87006
|
"id": "himi_business_chat.outgoing_attachment_$item.id",
|
|
86957
87007
|
"body": {
|
|
@@ -86972,6 +87022,22 @@ const SCREENS = runtimeSetting({
|
|
|
86972
87022
|
"hiddenPath": "$item.noImage"
|
|
86973
87023
|
}
|
|
86974
87024
|
},
|
|
87025
|
+
{
|
|
87026
|
+
"id": "himi_business_chat.outgoing_text_$item.id",
|
|
87027
|
+
"body": {
|
|
87028
|
+
"package": "himalaya",
|
|
87029
|
+
"type": "Text",
|
|
87030
|
+
"props": {
|
|
87031
|
+
"text": "$item.bodyText",
|
|
87032
|
+
"style": "body",
|
|
87033
|
+
"color": "textOnAccent",
|
|
87034
|
+
"collapseWhenEmpty": true,
|
|
87035
|
+
"textDirection": "auto",
|
|
87036
|
+
"maxLines": 1000
|
|
87037
|
+
}
|
|
87038
|
+
},
|
|
87039
|
+
"modifiers": {}
|
|
87040
|
+
},
|
|
86975
87041
|
{
|
|
86976
87042
|
"id": "himi_business_chat.outgoing_time_$item.id",
|
|
86977
87043
|
"body": {
|
|
@@ -87331,51 +87397,66 @@ const SCREENS = runtimeSetting({
|
|
|
87331
87397
|
}
|
|
87332
87398
|
},
|
|
87333
87399
|
{
|
|
87334
|
-
"id": "himi_business_chat.
|
|
87400
|
+
"id": "himi_business_chat.send_slot",
|
|
87335
87401
|
"body": {
|
|
87336
87402
|
"package": "himalaya",
|
|
87337
|
-
"type": "
|
|
87338
|
-
"props": {
|
|
87339
|
-
"name": "send",
|
|
87340
|
-
"size": 20,
|
|
87341
|
-
"tint": "textOnAccent"
|
|
87342
|
-
}
|
|
87343
|
-
},
|
|
87344
|
-
"modifiers": {
|
|
87345
|
-
"accessibilityRole": "button",
|
|
87346
|
-
"width": 40,
|
|
87347
|
-
"height": 40,
|
|
87348
|
-
"cornerRadius": 20,
|
|
87349
|
-
"background": {
|
|
87350
|
-
"token": "accentPrimary"
|
|
87351
|
-
},
|
|
87352
|
-
"accessibilityLabel": "Send",
|
|
87353
|
-
"onTap": {
|
|
87354
|
-
"actionId": "tier5.businessChat.send"
|
|
87355
|
-
},
|
|
87356
|
-
"hiddenPath": "$service:businessChat.sendDisabled"
|
|
87357
|
-
}
|
|
87358
|
-
},
|
|
87359
|
-
{
|
|
87360
|
-
"id": "himi_business_chat.send_icon_off",
|
|
87361
|
-
"body": {
|
|
87362
|
-
"package": "himalaya",
|
|
87363
|
-
"type": "Icon",
|
|
87403
|
+
"type": "ZStack",
|
|
87364
87404
|
"props": {
|
|
87365
|
-
"
|
|
87366
|
-
"
|
|
87367
|
-
|
|
87405
|
+
"alignment": "center",
|
|
87406
|
+
"children": [
|
|
87407
|
+
{
|
|
87408
|
+
"id": "himi_business_chat.send_icon_off",
|
|
87409
|
+
"body": {
|
|
87410
|
+
"package": "himalaya",
|
|
87411
|
+
"type": "Icon",
|
|
87412
|
+
"props": {
|
|
87413
|
+
"name": "send",
|
|
87414
|
+
"size": 20,
|
|
87415
|
+
"tint": "textTertiary"
|
|
87416
|
+
}
|
|
87417
|
+
},
|
|
87418
|
+
"modifiers": {
|
|
87419
|
+
"accessibilityRole": "decorative",
|
|
87420
|
+
"width": 40,
|
|
87421
|
+
"height": 40,
|
|
87422
|
+
"cornerRadius": 20,
|
|
87423
|
+
"background": {
|
|
87424
|
+
"token": "surfaceSecondary"
|
|
87425
|
+
}
|
|
87426
|
+
}
|
|
87427
|
+
},
|
|
87428
|
+
{
|
|
87429
|
+
"id": "himi_business_chat.send_icon",
|
|
87430
|
+
"body": {
|
|
87431
|
+
"package": "himalaya",
|
|
87432
|
+
"type": "Icon",
|
|
87433
|
+
"props": {
|
|
87434
|
+
"name": "send",
|
|
87435
|
+
"size": 20,
|
|
87436
|
+
"tint": "textOnAccent"
|
|
87437
|
+
}
|
|
87438
|
+
},
|
|
87439
|
+
"modifiers": {
|
|
87440
|
+
"accessibilityRole": "button",
|
|
87441
|
+
"width": 40,
|
|
87442
|
+
"height": 40,
|
|
87443
|
+
"cornerRadius": 20,
|
|
87444
|
+
"background": {
|
|
87445
|
+
"token": "accentPrimary"
|
|
87446
|
+
},
|
|
87447
|
+
"accessibilityLabel": "Send",
|
|
87448
|
+
"onTap": {
|
|
87449
|
+
"actionId": "tier5.businessChat.send"
|
|
87450
|
+
},
|
|
87451
|
+
"hiddenPath": "$service:businessChat.sendDisabled"
|
|
87452
|
+
}
|
|
87453
|
+
}
|
|
87454
|
+
]
|
|
87368
87455
|
}
|
|
87369
87456
|
},
|
|
87370
87457
|
"modifiers": {
|
|
87371
|
-
"accessibilityRole": "decorative",
|
|
87372
87458
|
"width": 40,
|
|
87373
|
-
"height": 40
|
|
87374
|
-
"cornerRadius": 20,
|
|
87375
|
-
"background": {
|
|
87376
|
-
"token": "surfaceSecondary"
|
|
87377
|
-
},
|
|
87378
|
-
"hiddenPath": "$service:businessChat.sendEnabled"
|
|
87459
|
+
"height": 40
|
|
87379
87460
|
}
|
|
87380
87461
|
}
|
|
87381
87462
|
],
|
|
@@ -87408,7 +87489,6 @@ const SCREENS = runtimeSetting({
|
|
|
87408
87489
|
},
|
|
87409
87490
|
"transcriptScroll": {
|
|
87410
87491
|
"sendPath": "$service:businessChat.scrollSend",
|
|
87411
|
-
"anchorPath": "$service:businessChat.scrollAnchor",
|
|
87412
87492
|
"jumpLabel": "Jump to latest"
|
|
87413
87493
|
}
|
|
87414
87494
|
},
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { formatClock } from './engine-intl.js';
|
|
2
2
|
import { mergeMessages, normalizeMessages, type MessageRow } from './business-chat-message-model.js';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** `delivery` is what the composer's own row shows while the server catches up: sending, accepted (shown with its
|
|
5
|
+
* time) or unconfirmed (the request failed without a definite answer). */
|
|
6
|
+
export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string }
|
|
5
7
|
|
|
6
8
|
export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
|
|
7
|
-
return {id:'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{const r=Math.floor(random()*16);return(c==='x'?r:(r&3)|8).toString(16);}),text,roomId,principal};
|
|
9
|
+
return {id:'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{const r=Math.floor(random()*16);return(c==='x'?r:(r&3)|8).toString(16);}),text,roomId,principal,createdDate:new Date().toISOString(),delivery:'sending'};
|
|
8
10
|
}
|
|
9
11
|
export function businessChatSendRejected(error:unknown):boolean{
|
|
10
12
|
return /\b(400|401|403|404|409|413|422|429)\b|approved first-party|webwix_owner_only|sign_in_required|owner_preview_read_only/i.test(String((error as Error)?.message||error));
|
|
11
13
|
}
|
|
12
14
|
export function draftAfterAcceptedSend(currentDraft:string,sentDraft:string):string{return currentDraft===sentDraft?'':currentDraft;}
|
|
13
|
-
export function pendingIntentWasObserved(messages:
|
|
15
|
+
export function pendingIntentWasObserved(messages:{id:string}[],intent:BusinessChatIntent|null):boolean{
|
|
14
16
|
return !!intent&&messages.some(message=>message.id===intent.id);
|
|
15
17
|
}
|
|
16
18
|
export function messageTimeLabel(value:string):string{
|
|
@@ -14,7 +14,7 @@ export type BusinessMessagePart = {
|
|
|
14
14
|
|
|
15
15
|
export type BusinessTextRun = { text: string; marks?: string[] };
|
|
16
16
|
export type BusinessSuggestion = { id: string; messageId: string; label: string; kind: "postback" | "visitor_option"; appId?: string; interactionId?: string; enabled: boolean; disabled: boolean };
|
|
17
|
-
export type BusinessArticleAction = { id: string; label: string; url: string };
|
|
17
|
+
export type BusinessArticleAction = { id: string; label: string; url: string; style: "primary" | "secondary" };
|
|
18
18
|
export type BusinessVisitorOptions = { answerTo: string; labels: string[] };
|
|
19
19
|
|
|
20
20
|
export type BusinessMessage = {
|
|
@@ -266,8 +266,8 @@ function articleActions(message: RecordValue, messageId: string): BusinessArticl
|
|
|
266
266
|
const rawUrl = typeof button.cta === "string" ? button.cta.trim() : "";
|
|
267
267
|
if (!label || !rawUrl || button.payload != null) return [];
|
|
268
268
|
// No `URL` global in a worker: accept only a plain https URL whose authority carries no credentials.
|
|
269
|
-
if (
|
|
270
|
-
return [{ id: `${messageId}:article-${index}`, label, url: rawUrl }];
|
|
269
|
+
if (!isSafeLink(rawUrl)) return [];
|
|
270
|
+
return [{ id: `${messageId}:article-${index}`, label, url: rawUrl, style: "secondary" as const }];
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
273
|
|
|
@@ -301,6 +301,59 @@ function hiddenInternalNotice(message: RecordValue): boolean {
|
|
|
301
301
|
Object.keys(content).length === 0 && !hasActionsOrMedia;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
const SAFE_LINK = /^https:\/\/[^\s\\@/?#]+(?:[/?#]\S*)?$/;
|
|
305
|
+
/** Sentence punctuation after a link ("…/page." or "(see …/page)") is not part of it. */
|
|
306
|
+
function cleanLinkUrl(raw: string): string {
|
|
307
|
+
let url = raw;
|
|
308
|
+
for (;;) {
|
|
309
|
+
const last = url[url.length - 1];
|
|
310
|
+
if (last && (".,;:!?".includes(last) || (last === ")" && !url.includes("(")))) url = url.slice(0, -1); else break;
|
|
311
|
+
}
|
|
312
|
+
return url;
|
|
313
|
+
}
|
|
314
|
+
/** A plain https link with no control characters (a null byte would pass `\S`). */
|
|
315
|
+
const isSafeLink = (url: string): boolean => SAFE_LINK.test(url) && !/[\u0000-\u001f\u007f-\u009f]/.test(url);
|
|
316
|
+
|
|
317
|
+
/** Owner tools (a product, a coupon, a scheduling link) reach a member as plain text: a title, detail
|
|
318
|
+
* lines and `Label: https://…` lines. On a phone that is a wall of text with a URL nobody can tap, so a
|
|
319
|
+
* link line becomes a button and a coupon code its own block. Only plain https links are lifted. */
|
|
320
|
+
export function extractBusinessMessageExtras(markdown: string): { text: string; links: { label: string; url: string }[]; couponCode: string } {
|
|
321
|
+
const kept: string[] = [];
|
|
322
|
+
const links: { label: string; url: string }[] = [];
|
|
323
|
+
let couponCode = "";
|
|
324
|
+
// True only when a button was really added: a repeated link, or one past the sixth, stays in the text.
|
|
325
|
+
const addLink = (label: string, url: string): boolean => {
|
|
326
|
+
if (links.length >= 6 || links.some((link) => link.url === url)) return false;
|
|
327
|
+
links.push({ label: label.replace(/[*_`]/g, "").trim().slice(0, 40) || "Open link", url });
|
|
328
|
+
return true;
|
|
329
|
+
};
|
|
330
|
+
for (const line of markdown.split("\n")) {
|
|
331
|
+
const trimmed = line.trim();
|
|
332
|
+
const labelled = /^(?:[-*•]\s+)?([^:\n]{1,40}):\s*(https:\/\/\S+)$/.exec(trimmed);
|
|
333
|
+
const labelledUrl = labelled ? cleanLinkUrl(labelled[2]!) : "";
|
|
334
|
+
if (labelled && isSafeLink(labelledUrl) && addLink(labelled[1]!, labelledUrl)) continue;
|
|
335
|
+
const bare = /^(https:\/\/\S+)$/.exec(trimmed);
|
|
336
|
+
const bareUrl = bare ? cleanLinkUrl(bare[1]!) : "";
|
|
337
|
+
if (bare && isSafeLink(bareUrl)) {
|
|
338
|
+
// A bare link is named by the list item above it ("- Settle Into Sleep"), else by its site.
|
|
339
|
+
const item = /^[-*•]\s+(.{1,60})$/.exec((kept[kept.length - 1] ?? "").trim());
|
|
340
|
+
if (addLink(item?.[1] ?? `Open ${bareUrl.slice(8).split(/[/?#]/)[0]!.replace(/^www\./, "")}`, bareUrl)) {
|
|
341
|
+
if (item) kept.pop();
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const code = /^(.*?)[,;]?\s*\b[Cc]ode:\s*([A-Z0-9_-]{3,32})$/.exec(trimmed);
|
|
346
|
+
if (code && !couponCode) {
|
|
347
|
+
couponCode = code[2]!;
|
|
348
|
+
if (code[1]!.trim()) kept.push(code[1]!.trim());
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
kept.push(line);
|
|
352
|
+
}
|
|
353
|
+
// Text with nothing lifted out is returned untouched, trailing newline included.
|
|
354
|
+
return { text: links.length || couponCode ? kept.join("\n").trim() : markdown, links, couponCode };
|
|
355
|
+
}
|
|
356
|
+
|
|
304
357
|
/** Adds only Business business-chat presentation fields to the canonical message rows. */
|
|
305
358
|
export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "sender" | "mine" | "createdDate" | "plainText" | "parts">>(
|
|
306
359
|
rows: T[],
|
|
@@ -326,6 +379,10 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
326
379
|
startsDay: boolean;
|
|
327
380
|
scrollAnchor: string;
|
|
328
381
|
notMine: boolean;
|
|
382
|
+
couponCode: string;
|
|
383
|
+
noCoupon: boolean;
|
|
384
|
+
productSlug: string;
|
|
385
|
+
productName: string;
|
|
329
386
|
})[] {
|
|
330
387
|
const metadata = new Map<string, RecordValue>();
|
|
331
388
|
for (const value of raw) {
|
|
@@ -377,7 +434,25 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
377
434
|
const bodyRuns = htmlMessageRuns(message) ?? plainTextRuns;
|
|
378
435
|
const bodyMarkdown = markdownFromRuns(bodyRuns);
|
|
379
436
|
const buttons = messageButtons(message, row.id);
|
|
380
|
-
const
|
|
437
|
+
const extras = mine ? { text: bodyMarkdown, links: [], couponCode: "" } : extractBusinessMessageExtras(bodyMarkdown);
|
|
438
|
+
// A card is a title and detail lines: Wix sends events as one, and an owner's product, coupon or scheduling link
|
|
439
|
+
// arrives as plain text of the same shape. Its title leads in bold, and a coupon's title is its code, which
|
|
440
|
+
// the coupon block already shows.
|
|
441
|
+
const lines = extras.text.split("\n");
|
|
442
|
+
const first = lines[0]!.trim();
|
|
443
|
+
// A title is short, is not a sentence, and carries no markup of its own: bolding a whole paragraph would flip the
|
|
444
|
+
// meaning of the `**` already inside it.
|
|
445
|
+
const titleLike = lines.length > 1 && first.length <= 60 && !/[.!?:]$/.test(first) && !first.includes("*");
|
|
446
|
+
const cardLike = titleLike && (!!standardEventText || extras.links.length > 0 || !!extras.couponCode);
|
|
447
|
+
const titled = cardLike && extras.couponCode && first === extras.couponCode ? lines.slice(1).join("\n")
|
|
448
|
+
: cardLike && lines.length > 1 && !first.startsWith("**") ? `**${first}**\n${lines.slice(1).join("\n")}` : extras.text;
|
|
449
|
+
// A product card links to its store page; the slug lets the worker find the product and show its picture.
|
|
450
|
+
const productPage = cardLike ? extras.links.map((link) => /^https:\/\/[^/?#]+(?:\/[^/?#]+)*?\/product-page\/([^/?#]+)/i.exec(link.url)?.[1]).find(Boolean) : undefined;
|
|
451
|
+
// A file, recording or video has no inline player here: it gets a button that opens it.
|
|
452
|
+
const attachmentLinks = row.parts.filter((part) => (part.kind === "file" || part.kind === "audio" || part.kind === "video") && !!part.url)
|
|
453
|
+
.map((part, at) => ({ id: `${row.id}:attachment-${at}`, label: (part.fileName || part.caption || `Open ${part.kind}`).slice(0, 40), url: part.url as string, style: "secondary" as const }));
|
|
454
|
+
const articles = [...articleActions(message, row.id), ...attachmentLinks, ...extras.links.map((link, at) => ({ id: `${row.id}:link-${at}`, label: link.label, url: link.url, style: /\b(view|buy|shop|book|pay|order|checkout|reserve|schedule)\b/i.test(link.label) ? "primary" as const : "secondary" as const }))]
|
|
455
|
+
.filter((action, at, all) => all.findIndex((other) => other.url === action.url) === at);
|
|
381
456
|
|
|
382
457
|
return {
|
|
383
458
|
...row,
|
|
@@ -392,7 +467,11 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
392
467
|
noImage: !imageUrl,
|
|
393
468
|
bodyText,
|
|
394
469
|
bodyRuns,
|
|
395
|
-
bodyMarkdown,
|
|
470
|
+
bodyMarkdown: titled,
|
|
471
|
+
couponCode: extras.couponCode,
|
|
472
|
+
noCoupon: !extras.couponCode,
|
|
473
|
+
productSlug: productPage ?? "",
|
|
474
|
+
productName: productPage ? first : "",
|
|
396
475
|
isAiGenerated,
|
|
397
476
|
suggestions: buttons,
|
|
398
477
|
articleActions: articles,
|
|
@@ -2,16 +2,21 @@ import { defineService } from '@himalaya/state';
|
|
|
2
2
|
import * as chat from '../_shared/business-chat-transport.js';
|
|
3
3
|
import type { ChatIdentity } from '../_shared/business-chat-transport.js';
|
|
4
4
|
import { type MessageRow } from '../_shared/business-chat-message-model.js';
|
|
5
|
-
import { businessChatSendRejected,
|
|
5
|
+
import { businessChatSendRejected, messageTimeLabel, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
|
|
6
6
|
import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
|
|
7
7
|
import { dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
8
8
|
import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
|
|
9
9
|
import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
|
|
10
|
+
import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
|
|
10
11
|
|
|
11
12
|
type PendingIntent = BusinessChatIntent;
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// Only what the screen reads. This state crosses into the worker on every keystroke, so a row carries no raw
|
|
14
|
+
// parts, plain text or text runs: they would be re-sent each time for nothing.
|
|
15
|
+
type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mine'> & {
|
|
16
|
+
senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; suggestions:BusinessSuggestion[]; articleActions:{id:string;label:string;url:string;style:'primary'|'secondary'}[]; couponCode:string; noCoupon:boolean; notMine:boolean;
|
|
14
17
|
dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
|
|
18
|
+
/** Only the member's own row that the server has not returned yet. */
|
|
19
|
+
pending?:boolean;
|
|
15
20
|
};
|
|
16
21
|
type State = {
|
|
17
22
|
phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
|
|
@@ -57,6 +62,14 @@ async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|und
|
|
|
57
62
|
return true;
|
|
58
63
|
}
|
|
59
64
|
function liveDescription(state:string,reason:string):string{return businessRealtimeStatusLabel(state,reason);}
|
|
65
|
+
/** The member's own message as it shows the instant they tap Send, before the server has answered. It carries the
|
|
66
|
+
* id sent to the server, so the real message replaces it once history returns it. */
|
|
67
|
+
function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
|
|
68
|
+
const when=intent.delivery==='sending'?'Sending…':intent.delivery==='unconfirmed'?'Not confirmed':messageTimeLabel(intent.createdDate);
|
|
69
|
+
return {id:intent.id,sender:'',when,createdDate:intent.createdDate,mine:true,
|
|
70
|
+
senderAvatar:'',senderName:intent.senderName??'',senderInitials:'',senderBadge:'',imageUrl:'',imageDescription:'',noImage:true,bodyText:intent.text,bodyMarkdown:intent.text,
|
|
71
|
+
suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true};
|
|
72
|
+
}
|
|
60
73
|
function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
|
|
61
74
|
notLoading:s.phase!=='loading',sendDisabled:!s.member||!s.roomId||s.busy||!s.draft.trim()||!!s.pendingIntent,sendEnabled:!!s.member&&!!s.roomId&&!s.busy&&!!s.draft.trim()&&!s.pendingIntent,noLive:!s.roomId||s.liveState==='connected'};}
|
|
62
75
|
function safeLoadFailure(error:unknown):string{
|
|
@@ -67,19 +80,77 @@ function safeLoadFailure(error:unknown):string{
|
|
|
67
80
|
}
|
|
68
81
|
function value(arg:unknown):string{if(typeof arg==='string')return arg;const a=arg as Record<string,unknown>|null;return typeof a?.value==='string'?a.value:'';}
|
|
69
82
|
function param(arg:unknown,key:string):string{const a=arg as Record<string,unknown>|null;const p=a?.params as Record<string,unknown>|undefined;return typeof a?.[key]==='string'?a[key] as string:typeof p?.[key]==='string'?p[key] as string:'';}
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
// A private attachment's download URL costs a round trip and stays valid for minutes, so a refresh reuses it.
|
|
84
|
+
const attachmentUrls=new Map<string,{url:string;at:number}>();
|
|
85
|
+
const ATTACHMENT_URL_TTL_MS=4*60*1000;
|
|
86
|
+
async function privateAttachmentUrl(part:{mediaId?:string;mediaProvider?:string;url?:string;fileName?:string}):Promise<string>{
|
|
87
|
+
const key=`${currentIdentity?.email??''}|${part.mediaId??''}`;
|
|
88
|
+
const hit=attachmentUrls.get(key);
|
|
89
|
+
if(hit&&Date.now()-hit.at<ATTACHMENT_URL_TTL_MS)return hit.url;
|
|
90
|
+
const url=await chat.attachmentUrl(me(),part);
|
|
91
|
+
if(url)attachmentUrls.set(key,{url,at:Date.now()});
|
|
92
|
+
return url;
|
|
93
|
+
}
|
|
94
|
+
// A product card's picture comes from the store: the message carries only the product page link. A found picture is
|
|
95
|
+
// reused for ten minutes and a miss for two, so a refresh never repeats the lookup.
|
|
96
|
+
const productImages=new Map<string,{url:string;at:number}>();
|
|
97
|
+
const productImageLookups=new Map<string,Promise<string>>();
|
|
98
|
+
const PRODUCT_IMAGE_TTL_MS=10*60*1000;
|
|
99
|
+
const PRODUCT_IMAGE_MISS_TTL_MS=2*60*1000;
|
|
100
|
+
function productImageFor(slug:string,name:string):Promise<string>{
|
|
101
|
+
// Keyed by both: the search runs on the name, so a miss for one name must not hide a hit for another.
|
|
102
|
+
const key=JSON.stringify([slug,name]);
|
|
103
|
+
const hit=productImages.get(key);
|
|
104
|
+
if(hit&&Date.now()-hit.at<(hit.url?PRODUCT_IMAGE_TTL_MS:PRODUCT_IMAGE_MISS_TTL_MS))return Promise.resolve(hit.url);
|
|
105
|
+
const running=productImageLookups.get(key);
|
|
106
|
+
if(running)return running;
|
|
107
|
+
const lookup=(async()=>{
|
|
108
|
+
let url='';
|
|
109
|
+
try{
|
|
110
|
+
// The name first; then the link's own words, for a product renamed since the message was written. The slug is the
|
|
111
|
+
// identity, so a same-named product with another slug is used only if neither search finds the slug.
|
|
112
|
+
let sameName:Parameters<typeof productImage>[0]|undefined;
|
|
113
|
+
let bySlug:Parameters<typeof productImage>[0]|undefined;
|
|
114
|
+
for(const query of [name,slug.replace(/-/g,' ')]){
|
|
115
|
+
if(!query.trim())continue;
|
|
116
|
+
const found=(await searchStoreProducts({query,limit:10})).products??[];
|
|
117
|
+
bySlug=found.find(product=>product.slug===slug);
|
|
118
|
+
if(bySlug)break;
|
|
119
|
+
if(query===name&&!sameName)sameName=found.find(product=>product.name===name);
|
|
120
|
+
}
|
|
121
|
+
const match=bySlug??sameName;
|
|
122
|
+
if(match)url=productImage(match);
|
|
123
|
+
}catch{url='';}
|
|
124
|
+
productImages.set(key,{url,at:Date.now()});
|
|
125
|
+
return url;
|
|
126
|
+
})().finally(()=>{productImageLookups.delete(key);});
|
|
127
|
+
productImageLookups.set(key,lookup);
|
|
128
|
+
return lookup;
|
|
129
|
+
}
|
|
130
|
+
function slimRow(row:Record<string,unknown>):BusinessMessageRow{
|
|
131
|
+
const pick=(...keys:string[])=>Object.fromEntries(keys.map(key=>[key,row[key]]));
|
|
132
|
+
return pick('id','sender','when','createdDate','mine','senderAvatar','senderName','senderInitials','senderBadge','imageUrl','imageDescription','noImage','bodyText','bodyMarkdown','suggestions','articleActions','couponCode','noCoupon','notMine','dayLabel','dayHidden','startsDay','scrollAnchor') as unknown as BusinessMessageRow;
|
|
133
|
+
}
|
|
134
|
+
async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
|
|
135
|
+
const rows=chronologicalMessages(existing,raw,memberId) as MessageRow[];
|
|
72
136
|
const prepared=await Promise.all(rows.map(async row=>{
|
|
73
137
|
const parts=await Promise.all(row.parts.map(async part=>{
|
|
74
|
-
if(part.kind!=='image')return part;
|
|
138
|
+
if(part.kind!=='image'&&part.kind!=='file'&&part.kind!=='audio'&&part.kind!=='video')return part;
|
|
75
139
|
if(part.mediaProvider==='WixMediaPrivate'&&part.mediaId){
|
|
76
|
-
try{return {...part,url:await
|
|
140
|
+
try{return {...part,url:await privateAttachmentUrl(part)};}catch{return {...part,url:''};}
|
|
77
141
|
}
|
|
78
142
|
return part;
|
|
79
143
|
}));
|
|
80
144
|
return {...row,parts};
|
|
81
145
|
}));
|
|
82
|
-
|
|
146
|
+
const presented=presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as Record<string,unknown>[];
|
|
147
|
+
await Promise.all(presented.map(async row=>{
|
|
148
|
+
const slug=typeof row.productSlug==='string'?row.productSlug:'';
|
|
149
|
+
if(!slug||row.imageUrl)return;
|
|
150
|
+
const url=await productImageFor(slug,String(row.productName||''));
|
|
151
|
+
if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:String(row.productName||'Product image')});
|
|
152
|
+
}));
|
|
153
|
+
return presented.map(slimRow);
|
|
83
154
|
}
|
|
84
155
|
function me():ChatIdentity{
|
|
85
156
|
const identity=currentIdentity;
|
|
@@ -104,7 +175,7 @@ function safeError(error:unknown,operation:'load'|'send'|'init'):string{
|
|
|
104
175
|
}
|
|
105
176
|
async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
|
|
106
177
|
function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
|
|
107
|
-
currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
|
|
178
|
+
attachmentUrls.clear();productImages.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
|
|
108
179
|
return shape({...initial,phase,error:phase==='guest'?'Sign in to send a message.':s.error});
|
|
109
180
|
}
|
|
110
181
|
let refreshTail:Promise<unknown>=Promise.resolve();
|
|
@@ -144,7 +215,9 @@ async function refreshHistory(s:State):Promise<State|undefined>{
|
|
|
144
215
|
const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
|
|
145
216
|
const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
|
|
146
217
|
const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
|
|
147
|
-
|
|
218
|
+
// A send the server has not returned yet stays on screen until history does.
|
|
219
|
+
const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
|
|
220
|
+
return shape({...base,messages:shown,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,pendingIntent:intentObserved?null:base.pendingIntent,phase:'ready',error:'',loadFailure:''});
|
|
148
221
|
}catch(error){
|
|
149
222
|
const wrapped=error instanceof Error?error:new Error('load_failed');
|
|
150
223
|
Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
|
|
@@ -160,6 +233,20 @@ function withLiveFields(st:State,latest:State,draft:string):State{
|
|
|
160
233
|
scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
|
|
161
234
|
}
|
|
162
235
|
function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
|
|
236
|
+
type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;error?:unknown};
|
|
237
|
+
/** Posts one message and reports how it ended; never throws. The caller already showed the message. */
|
|
238
|
+
async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
|
|
239
|
+
const sendEpoch=epoch;
|
|
240
|
+
let result:SendResult={id:pending.id,outcome:'sent',draft};
|
|
241
|
+
try{
|
|
242
|
+
await Promise.resolve();// the action that started this has returned its state by now
|
|
243
|
+
await chat.sendMessage(me(),pending.roomId,{id:pending.id,text:pending.text});
|
|
244
|
+
}catch(error){
|
|
245
|
+
const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
246
|
+
result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
|
|
247
|
+
}finally{sendFlight=false;}
|
|
248
|
+
if(sendEpoch===epoch)void svc.settleSend(result);
|
|
249
|
+
}
|
|
163
250
|
const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
|
|
164
251
|
state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
|
|
165
252
|
actions:{
|
|
@@ -198,33 +285,46 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
198
285
|
},
|
|
199
286
|
// Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
|
|
200
287
|
setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled=!!s.member&&!!s.roomId&&!s.busy&&!!draft.trim()&&!s.pendingIntent;return {...s,draft,sendEnabled,sendDisabled:!sendEnabled};},
|
|
201
|
-
|
|
288
|
+
// Shows the message and clears the box at once; the network round trip and the history reload that follow
|
|
289
|
+
// run in the background and settle through `settleSend`.
|
|
290
|
+
send:s=>{
|
|
202
291
|
const text=s.draft.trim();
|
|
203
292
|
if(!text||sendFlight||!s.roomId||!s.member||s.pendingIntent)return s;
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
}
|
|
293
|
+
// The member's name as their earlier messages show it, so the row keeps its size when the real message replaces it.
|
|
294
|
+
const ownName=[...s.messages].reverse().find(row=>row.mine&&row.senderName)?.senderName??'';
|
|
295
|
+
sendFlight=true;const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName};
|
|
296
|
+
draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
|
|
297
|
+
void deliver(pending,s.draft);
|
|
298
|
+
return shape({...s,draft:'',pendingIntent:pending,messages:[...s.messages,pendingRow(pending)],error:'',notice:'',scrollSend:s.scrollSend+1});
|
|
299
|
+
},
|
|
300
|
+
settleSend:async(s,a)=>{
|
|
301
|
+
const result=a as SendResult|undefined;const pending=s.pendingIntent;
|
|
302
|
+
if(!result||!pending||pending.id!==result.id)return undefined;
|
|
303
|
+
const replaceRow=(intent:BusinessChatIntent)=>s.messages.map(row=>row.id===intent.id?pendingRow(intent):row);
|
|
304
|
+
if(result.outcome==='signed_out')return clearPrivate(s,'guest');
|
|
305
|
+
if(result.outcome==='rejected'){
|
|
306
|
+
// The row goes, and the text comes back unless the member has already started something new.
|
|
307
|
+
return shape({...s,messages:s.messages.filter(row=>row.id!==pending.id),pendingIntent:null,draft:s.draft.trim()?s.draft:result.draft,error:safeError(result.error,'send'),notice:'The message was rejected. Your draft is retained.'});
|
|
308
|
+
}
|
|
309
|
+
if(result.outcome==='unconfirmed'){
|
|
310
|
+
const unconfirmed={...pending,delivery:'unconfirmed' as const};
|
|
311
|
+
return shape({...s,pendingIntent:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
|
|
312
|
+
}
|
|
313
|
+
const sent={...pending,delivery:'sent' as const};
|
|
314
|
+
const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
|
|
315
|
+
const sendEpoch=epoch;
|
|
316
|
+
// The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
|
|
317
|
+
// A refresh that ran while the committed intent still said sending leaves a 'Sending…' row: swap it for the accepted one.
|
|
318
|
+
const settled=(st:State):State=>shape({...st,pendingIntent:null,notice:'',messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):[...st.messages,pendingRow(sent)]});
|
|
319
|
+
// While live updates are connected the server's own event brings the message back through one refresh; a second
|
|
320
|
+
// one here would only queue behind it on the worker, delaying the member's next keystroke or send.
|
|
321
|
+
if(s.liveState==='connected')return settled(accepted);
|
|
322
|
+
try{const loaded=await loadLatest(accepted,true);return loaded?settled(loaded):undefined;}catch(error){
|
|
323
|
+
// A session lost by an older operation says nothing about the member who is signed in now.
|
|
220
324
|
if(sendEpoch!==epoch)return undefined;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const latest=readServiceState();
|
|
225
|
-
if(latest.roomId!==pending.roomId||latest.principal!==pending.principal)return undefined;
|
|
226
|
-
return shape({...latest,pendingIntent:definitive?null:pending,error:safeError(error,'send'),notice:definitive?'The message was rejected. Your draft is retained.':'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
|
|
227
|
-
}finally{sendFlight=false;}
|
|
325
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(accepted,'guest');
|
|
326
|
+
return settled(withLiveDraft(accepted));
|
|
327
|
+
}
|
|
228
328
|
},
|
|
229
329
|
sendSuggestion:async(s,a)=>{
|
|
230
330
|
if(suggestionFlight||!s.member||!s.roomId||!!s.pendingIntent)return s;
|
|
@@ -284,7 +384,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
284
384
|
}
|
|
285
385
|
catch(error){if(startEpoch!==epoch)return undefined;const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));return lost?clearPrivate(next,'guest'):shape({...withLiveDraft(next),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});}
|
|
286
386
|
},
|
|
287
|
-
dispose:async s=>{epoch++;currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
|
|
387
|
+
dispose:async s=>{epoch++;attachmentUrls.clear();productImages.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
|
|
288
388
|
}
|
|
289
389
|
});
|
|
290
390
|
readServiceState=()=>svc.getState();
|