@wix/himalaya-cli 0.1445.0 → 0.1447.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/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +2 -1
- package/dist/cli.mjs +37 -27
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +20 -0
- package/guides/sdk-reference.json +1 -1
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/beauty-salon/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/beauty-salon/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/beauty-salon-owner/dev/index.ts +3 -14
- package/templates/beauty-salon-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/branded-lite/config.ts +7 -0
- package/templates/branded-lite/dev/index.ts +1120 -0
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +22 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +427 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-transport.ts +102 -0
- package/templates/branded-lite/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/branded-lite/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/branded-lite/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/branded-lite/tier5-src/business-chat/index.ts +304 -0
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/community-club/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/community-club/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/community-club/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/community-club-owner/dev/index.ts +3 -14
- package/templates/community-club-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/events-meetups/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/events-meetups/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/events-meetups/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/events-meetups-owner/dev/index.ts +3 -14
- package/templates/events-meetups-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/gym-fitness/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/gym-fitness-owner/dev/index.ts +3 -14
- package/templates/gym-fitness-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/nonprofit-cause/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/nonprofit-cause/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +3 -14
- package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/owner-lite/dev/index.ts +3 -14
- package/templates/owner-lite/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/owner-lite/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/owner-lite/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/owner-lite/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/podcast-creator/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/podcast-creator/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/podcast-creator-owner/dev/index.ts +3 -14
- package/templates/podcast-creator-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/portfolio-creative/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/portfolio-creative-owner/dev/index.ts +3 -14
- package/templates/portfolio-creative-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/restaurant/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/restaurant/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/restaurant/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/restaurant-owner/dev/index.ts +3 -14
- package/templates/restaurant-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/retail-store/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/retail-store/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/retail-store-owner/dev/index.ts +3 -14
- package/templates/retail-store-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
- package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/yoga-studio/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/yoga-studio/tier5-src/_shared/iap-presentation.ts +3 -1
- package/templates/yoga-studio-owner/dev/index.ts +3 -14
- package/templates/yoga-studio-owner/tier5-src/_shared/collection-query.ts +43 -9
- package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +15 -3
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/index.ts +17 -12
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/logic.ts +18 -5
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The business chat's Wix calls: the member-role chat-web SDK for rooms, history and sends, plus the two
|
|
2
|
+
// calls the SDK has no helper for (a tapped button, a private attachment's download URL).
|
|
3
|
+
import { getChatToken, getMessagesPage, listChatroomsPage, sendChatMessage } from "@himalaya/wix-chat-web";
|
|
4
|
+
import { assertMemberAccess, memberAccess } from "./member-access.js";
|
|
5
|
+
|
|
6
|
+
const MANAGE = "https://manage.wix.com";
|
|
7
|
+
const MEMBER = { role: "member" } as const;
|
|
8
|
+
type Raw = Record<string, unknown>;
|
|
9
|
+
type Bridge = { call(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<unknown> };
|
|
10
|
+
|
|
11
|
+
/** Who is signed in. `userId` is empty until the host reports it; the chat then tells the business
|
|
12
|
+
* side apart by the room's participant id instead. */
|
|
13
|
+
export interface ChatIdentity { authenticated: boolean; userId: string; email: string; scope: string }
|
|
14
|
+
export async function chatIdentity(): Promise<ChatIdentity> {
|
|
15
|
+
const member = await memberAccess();
|
|
16
|
+
return { authenticated: !!member.email, userId: "", email: member.email, scope: member.scope };
|
|
17
|
+
}
|
|
18
|
+
export const assertChatIdentity = (identity: ChatIdentity): Promise<void> => assertMemberAccess(identity);
|
|
19
|
+
|
|
20
|
+
/** Every request is fenced: the member is re-checked before it goes out and again before its response is
|
|
21
|
+
* used, so a sign-out or account switch mid-request never leaks one member's data to the next. */
|
|
22
|
+
async function fenced<T>(identity: ChatIdentity, run: () => Promise<T>): Promise<T> {
|
|
23
|
+
await assertChatIdentity(identity);
|
|
24
|
+
const result = await run();
|
|
25
|
+
await assertChatIdentity(identity);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Any other member-scoped call (the AI chat's) goes through the same fence. */
|
|
30
|
+
export const fence = fenced;
|
|
31
|
+
|
|
32
|
+
// Minting a chat token is a network round trip, and every chat call used to mint its own. One token
|
|
33
|
+
// per member is reused until the member changes or a request using it is refused.
|
|
34
|
+
let cachedToken: { key: string; token: string } | null = null;
|
|
35
|
+
const tokenKey = (identity: ChatIdentity): string => `${identity.email}|${identity.scope}`;
|
|
36
|
+
async function chatTokenFor(identity: ChatIdentity): Promise<string> {
|
|
37
|
+
const key = tokenKey(identity);
|
|
38
|
+
if (cachedToken?.key === key) return cachedToken.token;
|
|
39
|
+
const { chatToken } = await getChatToken(MEMBER);
|
|
40
|
+
// The host mints for whoever is signed in NOW: a token minted while the member switched belongs to the
|
|
41
|
+
// new member and must not be cached under the old one.
|
|
42
|
+
await assertChatIdentity(identity);
|
|
43
|
+
cachedToken = { key, token: chatToken };
|
|
44
|
+
return chatToken;
|
|
45
|
+
}
|
|
46
|
+
/** Runs `run` with the member's cached token; a refused request mints a fresh token and tries once more. */
|
|
47
|
+
async function withToken<T>(identity: ChatIdentity, run: (chatToken: string) => Promise<T>): Promise<T> {
|
|
48
|
+
const reused = cachedToken?.key === tokenKey(identity);
|
|
49
|
+
try {
|
|
50
|
+
return await run(await chatTokenFor(identity));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
cachedToken = null;
|
|
53
|
+
if (!reused || !/\b(?:401|403)\b|token/i.test(String((error as Error)?.message ?? error))) throw error;
|
|
54
|
+
return run(await chatTokenFor(identity));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MAX_ROOM_PAGES = 20;
|
|
59
|
+
/** Every chatroom the member can see: the cursor is followed so the business room is found on any page. */
|
|
60
|
+
export const listRooms = (identity: ChatIdentity): Promise<{ items: Raw[]; more: boolean }> => fenced(identity, async () => {
|
|
61
|
+
const items: Raw[] = [];
|
|
62
|
+
let nextPointer: number | string | undefined;
|
|
63
|
+
for (let page = 0; page < MAX_ROOM_PAGES; page++) {
|
|
64
|
+
const result = await withToken(identity, (chatToken) => listChatroomsPage({ ...MEMBER, chatToken, pageSize: 50, ...(nextPointer !== undefined ? { nextPointer } : {}) }));
|
|
65
|
+
items.push(...(result.items as unknown as Raw[]));
|
|
66
|
+
nextPointer = result.nextPointer;
|
|
67
|
+
if (nextPointer === undefined) return { items, more: false };
|
|
68
|
+
}
|
|
69
|
+
return { items, more: true };
|
|
70
|
+
});
|
|
71
|
+
export const messagesPage = (identity: ChatIdentity, roomId: string): Promise<Raw[]> => fenced(identity, async () =>
|
|
72
|
+
(await withToken(identity, (chatToken) => getMessagesPage(roomId, { ...MEMBER, chatToken, pageSize: 50, ordering: "Desc" }))).items as unknown as Raw[]);
|
|
73
|
+
export const sendMessage = (identity: ChatIdentity, roomId: string, intent: { id: string; text: string }): Promise<void> =>
|
|
74
|
+
fenced(identity, () => withToken(identity, (chatToken) => sendChatMessage(roomId, intent.text, { ...MEMBER, chatToken, messageId: intent.id })));
|
|
75
|
+
|
|
76
|
+
function bridge(): Bridge {
|
|
77
|
+
const api = (globalThis as unknown as { himalaya?: { wixApi?: Bridge } }).himalaya?.wixApi;
|
|
78
|
+
if (!api) throw new Error("wix_api_bridge_unavailable");
|
|
79
|
+
return api;
|
|
80
|
+
}
|
|
81
|
+
/** A tapped button is a silent, ephemeral message carrying the button's chat action. */
|
|
82
|
+
export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
|
|
83
|
+
await withToken(identity, (chatToken) => bridge().call("POST", `${MANAGE}/_api/chat-web/v2/chatrooms/${encodeURIComponent(roomId)}/messages`, {
|
|
84
|
+
sendMessageBody: { id: input.id, messageType: "message", messageTypeVersion: 1, summary: input.text, isEphemeral: true, isSilent: true, data: [{ mimeType: "text/plain", data: { text: input.text } }] },
|
|
85
|
+
sendMessageOptions: { chatAction: { actionType: "BUTTON_INTERACTION", appId: input.appId, interactionId: input.interactionId } },
|
|
86
|
+
}, { ChatToken: chatToken }));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const https = (value: unknown): string => (typeof value === "string" && /^https:\/\/[^\s\\]+$/.test(value) ? value : "");
|
|
90
|
+
/** A fresh, authorized URL for an attachment. A private file is always fetched by its id. */
|
|
91
|
+
export const attachmentUrl = (identity: ChatIdentity, media: { mediaId?: string; mediaProvider?: string; url?: string; fileName?: string }): Promise<string> => fenced(identity, async () => {
|
|
92
|
+
if (media.mediaProvider === "WixMediaPrivate") {
|
|
93
|
+
if (!media.mediaId) throw new Error("attachment_has_no_media_id");
|
|
94
|
+
const response = await bridge().call("POST", "/communication-channels-media/v1/download-url", {
|
|
95
|
+
fileId: media.mediaId, ...(media.fileName ? { downloadFileName: media.fileName } : {}),
|
|
96
|
+
}) as Raw | null;
|
|
97
|
+
const url = https(response?.downloadUrl);
|
|
98
|
+
if (!url) throw new Error("attachment_download_url_invalid");
|
|
99
|
+
return url;
|
|
100
|
+
}
|
|
101
|
+
return https(media.url);
|
|
102
|
+
});
|
|
@@ -20,6 +20,13 @@ export interface CollectionQueryState<Row> extends CollectionPage<Row> {
|
|
|
20
20
|
pageError: boolean;
|
|
21
21
|
complete: boolean;
|
|
22
22
|
error: string;
|
|
23
|
+
/** The rows are the source's `cached` page, shown while the live read is still out. */
|
|
24
|
+
provisional?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** What a collection showed, as `shownPage` hands it out and `seed` takes it back on a restored boot. */
|
|
27
|
+
export interface CollectionShownPage<Row> {
|
|
28
|
+
state: CollectionQueryState<Row>;
|
|
29
|
+
loadedPages: number;
|
|
23
30
|
}
|
|
24
31
|
export interface CollectionQuerySource<Row> {
|
|
25
32
|
fetch(criteria: CollectionQuery, cursor: string): Promise<CollectionPage<Row>>;
|
|
@@ -50,13 +57,19 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
|
|
|
50
57
|
let state: CollectionQueryState<Row> = { criteria: normalize({}), items: [], nextCursor: '', status: 'idle', loadingMore: false, pageError: false, complete: false, error: '' };
|
|
51
58
|
/** The query a revalidation is running under: its snapshots are held back until it is done. */
|
|
52
59
|
let revalidating = -1;
|
|
53
|
-
/**
|
|
54
|
-
|
|
60
|
+
/** The last page the screen showed whole (rows ready, nothing paging, failing or standing in): what a revalidation
|
|
61
|
+
* keeps on screen and restores when it fails, and what a restored boot shows. A next page loading or failing after
|
|
62
|
+
* it leaves those rows on screen, so it is still this. */
|
|
63
|
+
let lastShown: { state: CollectionQueryState<Row>; loadedPages: number } | undefined;
|
|
64
|
+
const sameCriteria = (a: CollectionQuery, b: CollectionQuery) => JSON.stringify(a) === JSON.stringify(b);
|
|
55
65
|
let clearedByError = false;
|
|
56
66
|
const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
|
|
57
67
|
const emit = () => {
|
|
58
68
|
if (disposed || revalidating === generation) return;
|
|
59
|
-
|
|
69
|
+
// A settled answer replaces it, an empty one included: rows the live query proved gone are never kept.
|
|
70
|
+
if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
|
|
71
|
+
lastShown = state.items.length > 0 ? { state: snapshot(), loadedPages } : undefined;
|
|
72
|
+
}
|
|
60
73
|
publish(snapshot());
|
|
61
74
|
};
|
|
62
75
|
const merge = (old: Row[], page: Row[]): Row[] => {
|
|
@@ -77,14 +90,14 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
|
|
|
77
90
|
const criteria = copyQuery(state.criteria);
|
|
78
91
|
if (!append) loadedPages = 0;
|
|
79
92
|
state = { ...state, status: append ? 'ready' : 'loading', loadingMore: append, pageError: false, error: '',
|
|
80
|
-
...(append ? {} : { items: [], nextCursor: '', total: undefined, metadata: undefined, complete: false }) };
|
|
93
|
+
...(append ? {} : { items: [], nextCursor: '', total: undefined, metadata: undefined, complete: false, provisional: false }) };
|
|
81
94
|
emit();
|
|
82
95
|
try {
|
|
83
96
|
if (!append && source.cached) {
|
|
84
97
|
const cached = await source.cached(criteria).catch(() => undefined);
|
|
85
98
|
if (disposed || generation !== revision) return;
|
|
86
99
|
if (cached?.items.length) {
|
|
87
|
-
state = { ...state, items: merge([], cached.items), metadata: cached.metadata, status: 'ready', complete: false };
|
|
100
|
+
state = { ...state, items: merge([], cached.items), metadata: cached.metadata, status: 'ready', complete: false, provisional: true };
|
|
88
101
|
emit();
|
|
89
102
|
}
|
|
90
103
|
}
|
|
@@ -94,17 +107,18 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
|
|
|
94
107
|
state = { ...state, items: merge(append ? state.items : [], page.items), nextCursor: page.nextCursor,
|
|
95
108
|
total: typeof page.total === 'number' && Number.isFinite(page.total) && page.total >= 0 ? page.total : (append ? state.total : undefined),
|
|
96
109
|
metadata: page.metadata ?? (append ? state.metadata : undefined),
|
|
97
|
-
status: 'ready', loadingMore: false, pageError: false, complete: page.complete ?? !page.nextCursor };
|
|
110
|
+
status: 'ready', loadingMore: false, pageError: false, complete: page.complete ?? !page.nextCursor, provisional: false };
|
|
98
111
|
loadedPages++;
|
|
99
112
|
} catch (error) {
|
|
100
113
|
if (disposed || generation !== revision) return;
|
|
101
114
|
const clear = source.clearOnError?.(error) === true;
|
|
102
115
|
// Revoked access always ends what a revalidation showed, whether or not the source clears its rows.
|
|
103
116
|
clearedByError = clear || /HTTP 40[13]\b/.test(String(error));
|
|
117
|
+
if (clearedByError) lastShown = undefined;
|
|
104
118
|
if (clear) loadedPages = 0;
|
|
105
119
|
state = { ...state, ...(clear ? { items: [], nextCursor: '', total: undefined, metadata: undefined } : {}),
|
|
106
120
|
status: append && !clear ? 'ready' : 'error', loadingMore: false, pageError: append && !clear, complete: false,
|
|
107
|
-
error: source.errorMessage?.(error) ?? '' };
|
|
121
|
+
provisional: false, error: source.errorMessage?.(error) ?? '' };
|
|
108
122
|
}
|
|
109
123
|
emit();
|
|
110
124
|
};
|
|
@@ -117,8 +131,7 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
|
|
|
117
131
|
const next = normalize(criteria);
|
|
118
132
|
const same = JSON.stringify(next) === JSON.stringify(state.criteria);
|
|
119
133
|
const count = restorePages && same ? Math.max(loadedPages, restoringPages, 1) : 1;
|
|
120
|
-
const showing =
|
|
121
|
-
&& !shown.state.loadingMore && JSON.stringify(shown.state.criteria) === JSON.stringify(next) ? shown : undefined;
|
|
134
|
+
const showing = lastShown && sameCriteria(lastShown.state.criteria, next) ? lastShown : undefined;
|
|
122
135
|
restoringPages = count;
|
|
123
136
|
state = { ...state, criteria: next };
|
|
124
137
|
if (revalidate && showing) { revalidating = generation + 1; clearedByError = false; }
|
|
@@ -140,6 +153,27 @@ export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, p
|
|
|
140
153
|
emit();
|
|
141
154
|
}
|
|
142
155
|
},
|
|
156
|
+
/** The page the screen shows (rows ready, nothing failing or still paging), for the service to keep with its
|
|
157
|
+
* state; undefined when it shows none. */
|
|
158
|
+
shownPage(): CollectionShownPage<Row> | undefined {
|
|
159
|
+
if (!lastShown || !sameCriteria(lastShown.state.criteria, state.criteria)) return undefined;
|
|
160
|
+
const page = lastShown.state;
|
|
161
|
+
return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
|
|
162
|
+
},
|
|
163
|
+
/** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
|
|
164
|
+
* revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page. */
|
|
165
|
+
seed(page: unknown): boolean {
|
|
166
|
+
const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
|
|
167
|
+
const restored = candidate?.state;
|
|
168
|
+
if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
|
|
169
|
+
|| !Array.isArray(restored.items) || restored.items.length === 0 || typeof restored.nextCursor !== 'string') return false;
|
|
170
|
+
try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
|
|
171
|
+
state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
|
|
172
|
+
loadingMore: false, pageError: false, provisional: false, error: '' };
|
|
173
|
+
loadedPages = Math.max(1, Math.floor(Number(candidate?.loadedPages) || 1));
|
|
174
|
+
lastShown = { state: snapshot(), loadedPages };
|
|
175
|
+
return true;
|
|
176
|
+
},
|
|
143
177
|
refresh: () => { restoringPages = 0; return fetch(false); },
|
|
144
178
|
loadMore: () => fetch(true),
|
|
145
179
|
retry: () => fetch(state.pageError),
|
|
@@ -142,6 +142,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
|
|
|
142
142
|
// the query controller carries a recoverable failure. Treat each shape as
|
|
143
143
|
// one collection state so the shared UI never renders a blank catalog.
|
|
144
144
|
const backendState = backend?.controller.getState();
|
|
145
|
+
const provisional = backendState?.provisional === true;
|
|
145
146
|
const errored = s.status === 'error' || s.isErrored === true || s.errored === true || backendState?.status === 'error';
|
|
146
147
|
const domainError = typeof s.error === 'string' ? s.error.trim() : '';
|
|
147
148
|
const queryError = typeof backendState?.error === 'string' ? backendState.error.trim() : '';
|
|
@@ -177,13 +178,17 @@ export function decorateCollection(name: string, def: { state: State; actions?:
|
|
|
177
178
|
// un-narrowed failure. A re-query while typing keeps it (and the search field's focus).
|
|
178
179
|
const hideChrome = (!narrowed && !s.nextCursor && projected.loaded < COLLECTION_CHROME_MIN_ITEMS)
|
|
179
180
|
|| sourceEmpty || ((loading || errored) && rows.length === 0 && !narrowed);
|
|
180
|
-
|
|
181
|
+
// Kept with the state, so a cold boot restored from disk shows its last page and revalidates behind it.
|
|
182
|
+
const collectionShown = backend?.controller.shownPage();
|
|
183
|
+
return { ...s, ...(backend ? { collectionShown: collectionShown ?? null } : {}), collection: { ...prefs, ...projected,
|
|
181
184
|
hideChrome, noActiveCriteria: !narrowed,
|
|
182
185
|
viewName, viewMessage, saveMessage, noSavedViews: prefs.savedViews.length === 0,
|
|
183
186
|
cursor: s.pageError === true || s.loadingMore === true ? '' : s.nextCursor,
|
|
184
187
|
noMore: !s.nextCursor || s.pageError === true || s.loadingMore === true,
|
|
185
188
|
noPageError: s.pageError !== true,
|
|
186
|
-
|
|
189
|
+
// Rows read from the device ahead of the live read are shown as they are: "saved offline" is what stays on
|
|
190
|
+
// screen when the live read failed, not a first visit that is still loading.
|
|
191
|
+
noOffline: s.fromCache !== true || provisional,
|
|
187
192
|
noError: !errored,
|
|
188
193
|
noRetry: !canRetry || signInRequired,
|
|
189
194
|
noSignIn: !signInRequired,
|
|
@@ -202,7 +207,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
|
|
|
202
207
|
filtersPresented, previewPresented: !!previewId, preview: projected.items.filter(row => row.collectionId === previewId),
|
|
203
208
|
persistenceLabel: persisted.available ? copyParam('collection.onDevice', 'Saved on this device') : copyParam('collection.sessionOnly', 'Choices last for this session'),
|
|
204
209
|
noEmpty: loading || errored || sourceEmpty || domainNarrowed || projected.items.length > 0,
|
|
205
|
-
summary: backend && config.backendQuery && s.fromCache
|
|
210
|
+
summary: backend && config.backendQuery && s.fromCache && !provisional
|
|
206
211
|
? copyParam('collection.cached', 'Saved offline · may be outdated')
|
|
207
212
|
: collectionSummary({ shown: projected.items.length, loaded: projected.loaded, backend: !!(backend && config.backendQuery),
|
|
208
213
|
total: typeof s.total === 'number' ? s.total : undefined, noun: config.countNoun
|
|
@@ -345,6 +350,13 @@ export function defineBackendCollection<S extends object, Row extends object>(na
|
|
|
345
350
|
apply: (state, page) => options.apply(state as S, page as unknown as CollectionQueryState<Row>) as State,
|
|
346
351
|
});
|
|
347
352
|
const handle = (options.register ?? register)(name, definition);
|
|
353
|
+
// Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
|
|
354
|
+
// when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
|
|
355
|
+
// starts its first load as before, never from rows it chose not to restore.
|
|
356
|
+
const restored = (handle as { getState?: () => State }).getState?.();
|
|
357
|
+
const rowsKey = COLLECTIONS[name]?.rows;
|
|
358
|
+
const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
|
|
359
|
+
if (Array.isArray(restoredRows) && restoredRows.length > 0) controller.seed(restored?.collectionShown);
|
|
348
360
|
publish = () => handle.collectionDataChanged();
|
|
349
361
|
return handle;
|
|
350
362
|
}
|
|
@@ -45,7 +45,9 @@ export function withIapPresentation<S extends object>(options: {
|
|
|
45
45
|
return project(next);
|
|
46
46
|
}]));
|
|
47
47
|
const service = (options.register ?? defineService)(name, {
|
|
48
|
-
|
|
48
|
+
// The collection's shown page goes with the rows and chrome it copies: kept out of the restore with them.
|
|
49
|
+
...definition, notHydrated: [...(definition.notHydrated ?? []), ...options.transientKeys,
|
|
50
|
+
...((options.transientKeys as string[]).includes('collection') ? ['collectionShown' as keyof S & string] : [])],
|
|
49
51
|
actions: { ...actions, iapPublish: project, iapPoll: async () => { await commerce.poll(); return undefined; },
|
|
50
52
|
iapRefresh: async () => { await commerce.loadPrices(items); return undefined; } },
|
|
51
53
|
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { defineService } from '@himalaya/state';
|
|
2
|
+
import * as chat from '../_shared/business-chat-transport.js';
|
|
3
|
+
import type { ChatIdentity } from '../_shared/business-chat-transport.js';
|
|
4
|
+
import { type MessageRow } from '../_shared/business-chat-message-model.js';
|
|
5
|
+
import { businessChatSendRejected, draftAfterAcceptedSend, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
|
|
6
|
+
import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
|
|
7
|
+
import { dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
8
|
+
import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
|
|
9
|
+
import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
|
|
10
|
+
|
|
11
|
+
type PendingIntent = BusinessChatIntent;
|
|
12
|
+
type BusinessMessageRow = MessageRow & {
|
|
13
|
+
senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; bodyRuns:{text:string;marks?:string[]}[]; isAiGenerated:boolean; suggestions:BusinessSuggestion[]; visitorOptions:BusinessVisitorOptions|null; notMine:boolean;
|
|
14
|
+
dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
|
|
15
|
+
};
|
|
16
|
+
type State = {
|
|
17
|
+
phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
|
|
18
|
+
roomId:string; principal:string; scope:string; messages:BusinessMessageRow[]; draft:string; busy:boolean;
|
|
19
|
+
liveState:string; liveReason:string; realtimeEventCount:number; lastRealtimeEventType:string; pendingIntent:PendingIntent|null; scrollAnchor:string; scrollSend:number; usedSuggestionIds:string[]; visitorOptions:BusinessVisitorOptions|null; quickReplies:BusinessSuggestion[]; hasQuickReplies:boolean; noQuickReplies:boolean;
|
|
20
|
+
liveLabel:string; hasActiveRoom:boolean; noActiveRoom:boolean; isGuest:boolean; notGuest:boolean; realtimeDiagnostic:string; noError:boolean; noNotice:boolean; noMessages:boolean; hasMessages:boolean; noEmpty:boolean; notLoading:boolean;
|
|
21
|
+
hostedName:string; hasHostedName:boolean; noHostedName:boolean;
|
|
22
|
+
sendDisabled:boolean; sendEnabled:boolean; noLive:boolean; loadingHidden:boolean; errorText:string; loadFailure:string;
|
|
23
|
+
};
|
|
24
|
+
// A hosted build serves many businesses: the title is the site the host opened, not the app's own name.
|
|
25
|
+
const hostedName=hostSiteFromInput((globalThis as {himalaya?:{input?:unknown}}).himalaya?.input)?.name??'';
|
|
26
|
+
const initial:State={hostedName,hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,
|
|
27
|
+
liveState:'unavailable',liveReason:'',realtimeEventCount:0,lastRealtimeEventType:'',liveLabel:'',hasActiveRoom:false,noActiveRoom:true,isGuest:false,notGuest:true,realtimeDiagnostic:'',pendingIntent:null,scrollAnchor:'',scrollSend:0,usedSuggestionIds:[],visitorOptions:null,quickReplies:[],hasQuickReplies:false,noQuickReplies:true,noError:true,noNotice:true,noMessages:true,hasMessages:false,noEmpty:true,notLoading:false,sendDisabled:true,sendEnabled:false,noLive:true,loadingHidden:false,errorText:'',loadFailure:''};
|
|
28
|
+
let currentIdentity:ChatIdentity|null=null;
|
|
29
|
+
let subscriptionId='';
|
|
30
|
+
let initFlight=false;
|
|
31
|
+
let sendFlight=false;
|
|
32
|
+
let suggestionFlight=false;
|
|
33
|
+
let refreshFlight=false;
|
|
34
|
+
let refreshQueued=false;
|
|
35
|
+
let epoch=0;
|
|
36
|
+
let statusSeq=0;
|
|
37
|
+
let eventDuringInit=false;
|
|
38
|
+
let statusDuringInit:{state:string;reason:string}|null=null;
|
|
39
|
+
// Read through functions: a plain read after init's own assignment is narrowed by the compiler to its initial value.
|
|
40
|
+
function takeEventDuringInit():boolean{const seen=eventDuringInit;eventDuringInit=false;return seen;}
|
|
41
|
+
function takeStatusDuringInit():{state:string;reason:string}|null{const seen=statusDuringInit;statusDuringInit=null;return seen;}
|
|
42
|
+
let draftRevision=0;
|
|
43
|
+
let businessParticipantId='';
|
|
44
|
+
let loadStage:'room_selection'|'history_request'|'history_normalization'|'ai_enrichment'='room_selection';
|
|
45
|
+
let readServiceState:()=>State=()=>shape(initial);
|
|
46
|
+
|
|
47
|
+
/** The member-scoped AI conversation's current unanswered quick replies. */
|
|
48
|
+
async function loadBusinessVisitorOptions():Promise<BusinessVisitorOptions|null>{
|
|
49
|
+
const response=await chat.fence(me(),()=>listMessages({cursorPaging:{limit:20}}));
|
|
50
|
+
return businessVisitorOptionsFromAiMessages(response.messages??[]);
|
|
51
|
+
}
|
|
52
|
+
/** Submit a displayed quick reply through the AI endpoint; a label counts only while it is still a current option. */
|
|
53
|
+
async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|undefined,label:string):Promise<boolean>{
|
|
54
|
+
const request=businessVisitorOptionRequest(options,label,newBusinessSelectionId());
|
|
55
|
+
if(!request)return false;
|
|
56
|
+
await chat.fence(me(),()=>streamMessages(request));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function liveDescription(state:string,reason:string):string{return businessRealtimeStatusLabel(state,reason);}
|
|
60
|
+
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
|
+
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
|
+
function safeLoadFailure(error:unknown):string{
|
|
63
|
+
const name=error instanceof Error?error.name:'UnknownError';
|
|
64
|
+
const code=(error as {code?:unknown}|null)?.code;
|
|
65
|
+
const safeCode=typeof code==='string'&&/^[a-z0-9_]{1,48}$/i.test(code)?`:${code}`:'';
|
|
66
|
+
return `${loadStage}:${/^(TypeError|SyntaxError|ProtocolError|Unsupported|Error)$/.test(name)?name:'OtherError'}${safeCode}`;
|
|
67
|
+
}
|
|
68
|
+
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
|
+
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
|
+
async function presentMessages(existing:BusinessMessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
|
|
71
|
+
const rows=chronologicalMessages(existing,raw,memberId) as BusinessMessageRow[];
|
|
72
|
+
const prepared=await Promise.all(rows.map(async row=>{
|
|
73
|
+
const parts=await Promise.all(row.parts.map(async part=>{
|
|
74
|
+
if(part.kind!=='image')return part;
|
|
75
|
+
if(part.mediaProvider==='WixMediaPrivate'&&part.mediaId){
|
|
76
|
+
try{return {...part,url:await chat.attachmentUrl(me(),part)};}catch{return {...part,url:''};}
|
|
77
|
+
}
|
|
78
|
+
return part;
|
|
79
|
+
}));
|
|
80
|
+
return {...row,parts};
|
|
81
|
+
}));
|
|
82
|
+
return presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as BusinessMessageRow[];
|
|
83
|
+
}
|
|
84
|
+
function me():ChatIdentity{
|
|
85
|
+
const identity=currentIdentity;
|
|
86
|
+
if(!identity?.authenticated)throw new Error('member_sign_in_required');
|
|
87
|
+
return identity;
|
|
88
|
+
}
|
|
89
|
+
async function getCurrentMember():Promise<ChatIdentity>{return chat.chatIdentity();}
|
|
90
|
+
async function resolveExistingBusinessRoom(startEpoch:number):Promise<{id:string;participantId:string}>{
|
|
91
|
+
businessParticipantId='';
|
|
92
|
+
const page=await chat.listRooms(me());
|
|
93
|
+
if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
|
|
94
|
+
const room=selectExistingBusinessRoom(page.items);
|
|
95
|
+
if(!room)throw new Error('existing_business_conversation_unavailable');
|
|
96
|
+
return room;
|
|
97
|
+
}
|
|
98
|
+
function safeError(error:unknown,operation:'load'|'send'|'init'):string{
|
|
99
|
+
const text=String((error as Error)?.message||error);
|
|
100
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(text))return 'Your member session changed. Sign in again to continue.';
|
|
101
|
+
if(/approved first-party|webwix_owner_only|chat_setup_scope_unavailable/i.test(text))return 'This app host cannot reach the inbox yet.';
|
|
102
|
+
return operation==='send'?'Message delivery could not be confirmed. Check the conversation before trying again.':
|
|
103
|
+
operation==='init'?'Couldn’t open the conversation. Try again.':'Couldn’t load the conversation. Try again.';
|
|
104
|
+
}
|
|
105
|
+
async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
|
|
106
|
+
function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
|
|
107
|
+
currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
|
|
108
|
+
return shape({...initial,phase,error:phase==='guest'?'Sign in to send a message.':s.error});
|
|
109
|
+
}
|
|
110
|
+
let refreshTail:Promise<unknown>=Promise.resolve();
|
|
111
|
+
/** One refresh runs at a time. A caller that needs the data (init, a send) waits for the one in flight and
|
|
112
|
+
* then runs its own; a realtime event only asks the running one to go again, and gets nothing back. */
|
|
113
|
+
async function loadHistory(s:State,wait=false):Promise<State|undefined>{
|
|
114
|
+
if(!s.roomId)return s;
|
|
115
|
+
while(refreshFlight){
|
|
116
|
+
if(!wait){refreshQueued=true;return withLiveDraft(s);}
|
|
117
|
+
await refreshTail.catch(()=>{});
|
|
118
|
+
}
|
|
119
|
+
const run=refreshHistory(s);
|
|
120
|
+
refreshTail=run;
|
|
121
|
+
return run;
|
|
122
|
+
}
|
|
123
|
+
async function refreshHistory(s:State):Promise<State|undefined>{
|
|
124
|
+
refreshFlight=true;const startEpoch=epoch;const roomId=s.roomId;const principal=s.principal;const startDraftRevision=draftRevision;
|
|
125
|
+
// A refresh that outlived its member or session says nothing: returning a state would overwrite the new one.
|
|
126
|
+
const stale=()=>startEpoch!==epoch||(currentIdentity?.userId||currentIdentity?.email)!==principal;
|
|
127
|
+
try{
|
|
128
|
+
loadStage='history_request';
|
|
129
|
+
// The room history and the AI's open options are independent reads: fetch them together.
|
|
130
|
+
const optionsRead:Promise<BusinessVisitorOptions|null>=currentIdentity?.authenticated?loadBusinessVisitorOptions().catch(()=>null/* Optional AI list failure must not hide Inbox history. */):Promise.resolve(null);
|
|
131
|
+
const page=await chat.messagesPage(me(),roomId);
|
|
132
|
+
const visitorOptions=await optionsRead;
|
|
133
|
+
if(stale())return undefined;
|
|
134
|
+
loadStage='history_normalization';
|
|
135
|
+
// Presented from the page alone: a row kept from an earlier page would be re-presented without the
|
|
136
|
+
// metadata that only the page carries (sender, avatar, AI badge, article buttons).
|
|
137
|
+
const messages=await presentMessages([],page,currentIdentity?.userId||undefined);
|
|
138
|
+
if(stale())return undefined;
|
|
139
|
+
// Read the live state last: what other actions changed while this one awaited is theirs to keep.
|
|
140
|
+
const latest=readServiceState();
|
|
141
|
+
const same=latest.roomId===roomId&&latest.principal===principal;
|
|
142
|
+
// What was typed while this ran is kept even on the first load, when the live state is still empty.
|
|
143
|
+
const liveDraft=draftRevision!==startDraftRevision?latest.draft:s.draft;
|
|
144
|
+
const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
|
|
145
|
+
const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
|
|
146
|
+
const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
|
|
147
|
+
return shape({...base,messages,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,pendingIntent:intentObserved?null:base.pendingIntent,phase:'ready',error:'',loadFailure:''});
|
|
148
|
+
}catch(error){
|
|
149
|
+
const wrapped=error instanceof Error?error:new Error('load_failed');
|
|
150
|
+
Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
|
|
151
|
+
throw wrapped;
|
|
152
|
+
}finally{refreshFlight=false;}
|
|
153
|
+
}
|
|
154
|
+
/** What other actions own and may have changed while this one awaited, taken from the live state. A state
|
|
155
|
+
* built from a snapshot taken before an await must carry these forward or it rolls them back. */
|
|
156
|
+
function withLiveFields(st:State,latest:State,draft:string):State{
|
|
157
|
+
return {...st,draft,pendingIntent:latest.pendingIntent||st.pendingIntent,
|
|
158
|
+
usedSuggestionIds:[...new Set([...st.usedSuggestionIds,...latest.usedSuggestionIds])],
|
|
159
|
+
liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
|
|
160
|
+
scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
|
|
161
|
+
}
|
|
162
|
+
function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
|
|
163
|
+
const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
|
|
164
|
+
state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
|
|
165
|
+
actions:{
|
|
166
|
+
init:async s=>{
|
|
167
|
+
if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
|
|
168
|
+
const oldPrincipal=s.principal;
|
|
169
|
+
const initRevision=draftRevision;
|
|
170
|
+
// Text typed at any point of the load, subscribe step included, outlives the snapshot this started from.
|
|
171
|
+
const keepTyped=(st:State):State=>draftRevision!==initRevision?{...st,draft:readServiceState().draft}:st;
|
|
172
|
+
let myEpoch=epoch;
|
|
173
|
+
try{
|
|
174
|
+
loadStage='room_selection';
|
|
175
|
+
const identity=await getCurrentMember();
|
|
176
|
+
if(myEpoch!==epoch)return undefined;
|
|
177
|
+
if(!identity.authenticated)throw new Error('member_sign_in_required');
|
|
178
|
+
if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=clearPrivate(s,'guest');myEpoch=epoch;}
|
|
179
|
+
currentIdentity=identity;
|
|
180
|
+
s=shape({...s,phase:'loading',member:true,principal:identity.userId||identity.email,scope:identity.scope,error:'',notice:''});
|
|
181
|
+
const existingRoom=await resolveExistingBusinessRoom(myEpoch);
|
|
182
|
+
if(myEpoch!==epoch)return undefined;
|
|
183
|
+
businessParticipantId=existingRoom.participantId;
|
|
184
|
+
s=shape({...s,roomId:existingRoom.id});
|
|
185
|
+
// Subscribe before reading history, so anything that lands after the read arrives as a realtime event.
|
|
186
|
+
if(!subscriptionId){try{const result=await subscribeRealtime({onEvent:'businessChat.onEvent',onStatus:'businessChat.onStatus'});if(myEpoch!==epoch){if(result.subscriptionId)await unsubscribeRealtime({subscriptionId:result.subscriptionId});return undefined;}subscriptionId=result.subscriptionId;const status=subscriptionId?await getRealtimeStatus({subscriptionId}):result;s=shape({...s,liveState:status.state,liveReason:status.reason||''});}catch{s=shape({...s,liveState:'unavailable',liveReason:'runtime_unavailable'});}}
|
|
187
|
+
let loaded=await loadLatest(s,true);
|
|
188
|
+
if(!loaded||myEpoch!==epoch)return undefined;
|
|
189
|
+
// Until this returns the stored state has no room, so events are ignored; one that arrived meanwhile asks for another pass.
|
|
190
|
+
for(let pass=0;pass<3&&takeEventDuringInit();pass++){const again=await loadLatest(loaded,true);if(!again||myEpoch!==epoch)return undefined;loaded=again;}
|
|
191
|
+
const opened=takeStatusDuringInit();
|
|
192
|
+
return shape(keepTyped(opened?{...loaded,liveState:opened.state,liveReason:opened.reason}:loaded));
|
|
193
|
+
}catch(error){
|
|
194
|
+
const sessionLost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
195
|
+
const loadFailure=typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error);
|
|
196
|
+
return sessionLost?clearPrivate(s,'guest'):shape(keepTyped({...s,phase:'error',member:!!currentIdentity?.authenticated,error:safeError(error,'init'),loadFailure}));
|
|
197
|
+
}finally{initFlight=false;}
|
|
198
|
+
},
|
|
199
|
+
// Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
|
|
200
|
+
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
|
+
send:async s=>{
|
|
202
|
+
const text=s.draft.trim();
|
|
203
|
+
if(!text||sendFlight||!s.roomId||!s.member||s.pendingIntent)return s;
|
|
204
|
+
sendFlight=true;const pending=newBusinessChatIntent(text,s.roomId,s.principal);
|
|
205
|
+
const draftAtSend=s.draft;const sendEpoch=epoch;
|
|
206
|
+
try{
|
|
207
|
+
await chat.sendMessage(me(),pending.roomId,{id:pending.id,text:pending.text});
|
|
208
|
+
if(sendEpoch!==epoch)return undefined;
|
|
209
|
+
const latest=readServiceState();
|
|
210
|
+
if(latest.roomId!==pending.roomId||latest.principal!==pending.principal)return undefined;
|
|
211
|
+
draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
|
|
212
|
+
s=shape({...latest,draft:draftAfterAcceptedSend(latest.draft,draftAtSend),pendingIntent:null,error:'',notice:'Message sent.',scrollSend:latest.scrollSend+1});
|
|
213
|
+
try{const loaded=await loadLatest(s,true);return loaded?shape({...loaded,notice:'Message sent.'}):undefined;}catch(error){
|
|
214
|
+
// A session lost by an older operation says nothing about the member who is signed in now.
|
|
215
|
+
if(sendEpoch!==epoch)return undefined;
|
|
216
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
|
|
217
|
+
return withLiveDraft(s);
|
|
218
|
+
}
|
|
219
|
+
}catch(error){
|
|
220
|
+
if(sendEpoch!==epoch)return undefined;
|
|
221
|
+
const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
222
|
+
if(lost)return clearPrivate(s,'guest');
|
|
223
|
+
const definitive=businessChatSendRejected(error);
|
|
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;}
|
|
228
|
+
},
|
|
229
|
+
sendSuggestion:async(s,a)=>{
|
|
230
|
+
if(suggestionFlight||!s.member||!s.roomId||!!s.pendingIntent)return s;
|
|
231
|
+
const id=param(a,'id');
|
|
232
|
+
if(!id||s.usedSuggestionIds.includes(id))return s;
|
|
233
|
+
const rows=[{suggestions:s.quickReplies} as {suggestions?:BusinessSuggestion[]},...s.messages];
|
|
234
|
+
const options=s.visitorOptions;
|
|
235
|
+
const intent=newBusinessChatIntent('suggestion',s.roomId,s.principal);
|
|
236
|
+
const sendEpoch=epoch;
|
|
237
|
+
suggestionFlight=true;
|
|
238
|
+
s=shape({...s,usedSuggestionIds:[...s.usedSuggestionIds,id],error:'',notice:''});
|
|
239
|
+
try{
|
|
240
|
+
const sent=await dispatchBusinessSuggestion(rows,id,{member:s.member,roomId:s.roomId,principal:s.principal,currentPrincipal:currentIdentity?.userId||currentIdentity?.email||'',scope:s.scope,currentScope:currentIdentity?.scope||''},suggestion=>suggestion.kind==='visitor_option'
|
|
241
|
+
? sendBusinessVisitorOption(options,suggestion.label)
|
|
242
|
+
: chat.sendButton(me(),s.roomId,{id:intent.id,text:suggestion.label,appId:suggestion.appId!,interactionId:suggestion.interactionId!}));
|
|
243
|
+
if(sendEpoch!==epoch)return undefined;
|
|
244
|
+
if(!sent)return withLiveDraft(s);
|
|
245
|
+
const latest=readServiceState();
|
|
246
|
+
if(latest.roomId!==s.roomId||latest.principal!==s.principal)return undefined;
|
|
247
|
+
try{const loaded=await loadLatest(withLiveDraft(shape({...s,scrollSend:s.scrollSend+1,error:'',notice:'Suggestion sent.'})),true);return loaded?shape({...loaded,notice:'Suggestion sent.'}):undefined;}
|
|
248
|
+
catch{return withLiveDraft(shape({...s,scrollSend:s.scrollSend+1,error:'',notice:'Suggestion sent.'}));}
|
|
249
|
+
}catch(error){
|
|
250
|
+
if(sendEpoch!==epoch)return undefined;
|
|
251
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
|
|
252
|
+
const definitive=businessChatSendRejected(error);
|
|
253
|
+
return shape({...withLiveDraft(s),usedSuggestionIds:definitive?s.usedSuggestionIds.filter(value=>value!==id):s.usedSuggestionIds,error:safeError(error,'send'),notice:definitive?'The suggestion was rejected.':'Suggestion delivery is unconfirmed; it will not be sent again automatically.'});
|
|
254
|
+
}finally{suggestionFlight=false;}
|
|
255
|
+
},
|
|
256
|
+
onEvent:async(s,a)=>{
|
|
257
|
+
if(initFlight){eventDuringInit=true;return undefined;}
|
|
258
|
+
const startEpoch=epoch;
|
|
259
|
+
const envelope=a as RealtimeEventEnvelope|undefined;
|
|
260
|
+
const eventType=typeof envelope?.event?.type==='string'?envelope.event.type:'';
|
|
261
|
+
s=shape({...s,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
|
|
262
|
+
if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope}))return s;
|
|
263
|
+
if(refreshFlight){refreshQueued=true;return withLiveDraft(s);}
|
|
264
|
+
try{return await loadLatest(s);}catch(error){
|
|
265
|
+
if(startEpoch!==epoch)return undefined;
|
|
266
|
+
return /member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error))?clearPrivate(s,'guest'):shape({...withLiveDraft(s),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
onStatus:async(s,a)=>{
|
|
270
|
+
const startEpoch=epoch;const mySeq=++statusSeq;
|
|
271
|
+
const status=a as RealtimeStatus|undefined;
|
|
272
|
+
const liveState=status?.state||'unavailable';
|
|
273
|
+
// While init is opening, the stored state has no room yet: remember the status and let init apply it.
|
|
274
|
+
if(initFlight){statusDuringInit={state:liveState,reason:liveState==='connected'?'':status?.reason||''};return undefined;}
|
|
275
|
+
let next=shape({...s,liveState,liveReason:liveState==='connected'?'':status?.reason||''});
|
|
276
|
+
if(liveState!=='connected'||s.liveState==='connected'||!s.member||!s.roomId||!currentIdentity||s.principal!==(currentIdentity.userId||currentIdentity.email)||s.scope!==currentIdentity.scope)return next;
|
|
277
|
+
try{
|
|
278
|
+
const loaded=await loadLatest(next);
|
|
279
|
+
if(!loaded)return undefined;
|
|
280
|
+
// A newer status applied while the refresh ran wins over the one this handler started with.
|
|
281
|
+
const live=readServiceState();
|
|
282
|
+
const newer=statusSeq!==mySeq;
|
|
283
|
+
return shape({...loaded,liveState:newer?live.liveState:liveState,liveReason:newer?live.liveReason:(liveState==='connected'?'':status?.reason||''),error:''});
|
|
284
|
+
}
|
|
285
|
+
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
|
+
},
|
|
287
|
+
dispose:async s=>{epoch++;currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
readServiceState=()=>svc.getState();
|
|
291
|
+
async function loadLatest(s:State,wait=false):Promise<State|undefined>{
|
|
292
|
+
// A caller that does not need the data asks the running refresh to go again and keeps its own change
|
|
293
|
+
// (a status, an event count): returning nothing would drop it.
|
|
294
|
+
if(!wait&&refreshFlight){refreshQueued=true;return withLiveDraft(s);}
|
|
295
|
+
let next=await loadHistory(s,wait);
|
|
296
|
+
// Events that arrived during a refresh ask for another pass, a few at most.
|
|
297
|
+
for(let pass=0;next&&refreshQueued&&pass<3;pass++){
|
|
298
|
+
refreshQueued=false;
|
|
299
|
+
next=await loadHistory(next,true);
|
|
300
|
+
}
|
|
301
|
+
if(!next)refreshQueued=false;
|
|
302
|
+
return next;
|
|
303
|
+
}
|
|
304
|
+
export { svc };
|
|
@@ -72,6 +72,7 @@ export const HOSTED_SCREEN_VERTICALS: Readonly<Record<string, readonly BrandedLi
|
|
|
72
72
|
himi_blog: ["blog"],
|
|
73
73
|
himi_bookings: ["bookings"],
|
|
74
74
|
himi_cart: ["store"],
|
|
75
|
+
himi_business_chat: ["inbox"],
|
|
75
76
|
himi_chat_thread: ["inbox", "members"],
|
|
76
77
|
himi_cms: ["cms"],
|
|
77
78
|
himi_collection: ["store"],
|