@wix/himalaya-cli 0.1613.0 → 0.1615.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/ui/src/index.d.ts +53 -13
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
- package/dist/cli.mjs +1638 -1330
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +4 -0
- package/guides/component-reference.json +1 -1
- package/guides/component-reference.md +3 -0
- package/guides/designing-himi-screens.md +4 -1
- package/guides/hhp.md +2 -2
- package/guides/sdk-reference.json +28 -4
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/branded-lite/dev/index.ts +192 -0
- package/templates/branded-lite/l10n/en.json +1 -1
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/dev/index.ts +192 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Read-only presentation model. HTML stays text; remote actions require a separate dispatcher. */
|
|
2
|
+
export type PartKind = 'text' | 'image' | 'file' | 'audio' | 'video' | 'card' | 'form' | 'system' | 'unsupported';
|
|
3
|
+
export interface MessageButton { id: string; label: string; url: string; interactionId?: string; unavailableReason?: string }
|
|
4
|
+
export interface PartRow {
|
|
5
|
+
id: string; kind: PartKind; text: string; url: string; fileName: string; caption: string;
|
|
6
|
+
buttons?: MessageButton[]; fields?: { name: string; value: string }[];
|
|
7
|
+
mimeType?: string; iconUrl?: string; mediaId?: string; urlExpiration?: string; unavailableReason?: string;
|
|
8
|
+
mediaProvider?: string; thumbnailUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface MessageRow {
|
|
11
|
+
id: string; sequence: string; sender: string; senderId: string; when: string; createdDate: string;
|
|
12
|
+
mine: boolean; status: string; direction: string; parts: PartRow[]; plainText: string;
|
|
13
|
+
}
|
|
14
|
+
type Obj = Record<string, unknown>;
|
|
15
|
+
function obj(value: unknown): Obj { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Obj : {}; }
|
|
16
|
+
function str(value: unknown): string { return typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : ''; }
|
|
17
|
+
function first(...values: unknown[]): string { for (const value of values) { const s = str(value); if (s) return s; } return ''; }
|
|
18
|
+
function stable(value: unknown, ancestors: unknown[] = []): string {
|
|
19
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value);
|
|
20
|
+
if (ancestors.includes(value)) return '[circular]';
|
|
21
|
+
const next = [...ancestors, value];
|
|
22
|
+
return Array.isArray(value) ? '[' + value.map(v => stable(v, next)).join(',') + ']' : '{' + Object.keys(value as Obj).sort().map(k => JSON.stringify(k) + ':' + stable((value as Obj)[k], next)).join(',') + '}';
|
|
23
|
+
}
|
|
24
|
+
function hash(value: unknown): string {
|
|
25
|
+
const text = stable(value); let a = 2166136261; let b = 5381;
|
|
26
|
+
for (let i = 0; i < text.length; i++) { a = Math.imul(a ^ text.charCodeAt(i), 16777619); b = Math.imul(b, 33) ^ text.charCodeAt(i); }
|
|
27
|
+
return (a >>> 0).toString(16).padStart(8, '0') + (b >>> 0).toString(16).padStart(8, '0');
|
|
28
|
+
}
|
|
29
|
+
/** HTTPS only, no credentials/control characters/backslashes. Does not depend on worker URL globals. */
|
|
30
|
+
function safeUrl(value: unknown): string {
|
|
31
|
+
if (typeof value !== 'string' || /[\s\\\u0000-\u001f\u007f-\u009f]/u.test(value)) return '';
|
|
32
|
+
const match = /^https:\/\/([^/?#]+)(?:[/?#].*)?$/iu.exec(value);
|
|
33
|
+
if (!match) return '';
|
|
34
|
+
const authority = match[1];
|
|
35
|
+
if (authority.includes('@') || !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:]+\])(?::[0-9]{1,5})?$/iu.test(authority)) return '';
|
|
36
|
+
const port = /:([0-9]+)$/.exec(authority);
|
|
37
|
+
if (port && Number(port[1]) > 65535) return '';
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function htmlText(value: string): string {
|
|
41
|
+
// Output is plain text, never markup. No href/src extraction or HTML insertion.
|
|
42
|
+
return value.replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
|
|
43
|
+
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/(p|div|li|h[1-6])\s*>/gi, '\n').replace(/<[^>]*>/g, '')
|
|
44
|
+
.replace(/&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos|nbsp);/gi, (whole, entity: string) => {
|
|
45
|
+
const names: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
46
|
+
if (entity[0] !== '#') return names[entity.toLowerCase()] ?? whole;
|
|
47
|
+
const n = entity[1]?.toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
|
|
48
|
+
return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : whole;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function base(id: string, kind: PartKind, text = ''): PartRow { return { id, kind, text, url: '', fileName: '', caption: '' }; }
|
|
52
|
+
function unsupported(id: string, value: unknown, reason = 'Unsupported message content'): PartRow {
|
|
53
|
+
return { ...base(id, 'unsupported', typeof value === 'string' ? value : stable(value)), caption: reason, unavailableReason: reason };
|
|
54
|
+
}
|
|
55
|
+
function media(id: string, kind: 'image' | 'file' | 'audio' | 'video', input: unknown): PartRow {
|
|
56
|
+
const m = obj(input); const candidate = first(m.url, m.uri, m.downloadUrl, m.src, typeof input === 'string' ? input : '');
|
|
57
|
+
const url = safeUrl(candidate); const mime = first(m.mimeType, m.contentType);
|
|
58
|
+
const resolvedKind = kind === 'file' && /^audio\//i.test(mime) ? 'audio' : kind === 'file' && /^video\//i.test(mime) ? 'video' : kind;
|
|
59
|
+
const missing = url ? '' : candidate ? 'Attachment URL is unavailable: only safe HTTPS links are supported.' : 'Attachment requires a download URL.';
|
|
60
|
+
return { ...base(id, resolvedKind, first(m.text, m.title)), url, fileName: first(m.filename, m.fileName, m.name), caption: first(m.caption, m.alt, missing), mimeType: mime, mediaId: first(m.mediaFileId, m.id, m._id, m.fileId), mediaProvider: str(m.mediaProvider), thumbnailUrl: safeUrl(m.thumbnailUrl), urlExpiration: str(m.urlExpiration), ...(missing ? { unavailableReason: missing } : {}) };
|
|
61
|
+
}
|
|
62
|
+
function buttons(value: unknown, prefix: string): MessageButton[] {
|
|
63
|
+
if (!Array.isArray(value)) return [];
|
|
64
|
+
return value.map((value, i) => {
|
|
65
|
+
const b = obj(value); const raw = first(b.launchUrl, b.url, b.href, obj(b.action).url); const url = safeUrl(raw);
|
|
66
|
+
const interactionId = first(obj(b.postbackProperties).interactionId, b.interactionId);
|
|
67
|
+
return { id: prefix + '-button-' + i, label: first(b.label, b.title, b.text, 'Action'), url,
|
|
68
|
+
...(interactionId ? { interactionId } : {}),
|
|
69
|
+
...(!url ? { unavailableReason: raw ? 'Only safe HTTPS links are supported.' : interactionId ? 'This interaction requires a supported server action.' : 'Action has no supported link.' } : {}) };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function card(id: string, input: unknown, kind: 'card' | 'form' | 'system' = 'card'): PartRow[] {
|
|
73
|
+
const c = obj(input); const lines = Array.isArray(c.textLines) ? c.textLines.map(str) : [];
|
|
74
|
+
const text = [first(c.title, c.name), first(c.text, c.description, c.body), ...lines].filter(Boolean).join('\n');
|
|
75
|
+
const fields = Array.isArray(c.fields) ? c.fields.map(f => ({ name: first(obj(f).name, obj(f).label), value: typeof obj(f).value === 'string' ? obj(f).value as string : stable(obj(f).value) })) : [];
|
|
76
|
+
const result: PartRow = { ...base(id, kind, text), buttons: buttons(c.buttons, id), ...(fields.length ? { fields } : {}) };
|
|
77
|
+
const image = first(c.imageUrl, c.iconUrl, obj(c.image).url, obj(c.image).uri);
|
|
78
|
+
if (image) { result.url = safeUrl(image); if (!result.url) result.unavailableReason = 'Card image requires a safe HTTPS URL.'; }
|
|
79
|
+
const parts = [result];
|
|
80
|
+
if (Array.isArray(c.media)) c.media.forEach((v, i) => parts.push(...basicItem(`${id}-media-${i}`, v)));
|
|
81
|
+
if (!text && !fields.length && !result.buttons?.length && !image && parts.length === 1) return [unsupported(id, input)];
|
|
82
|
+
return parts;
|
|
83
|
+
}
|
|
84
|
+
function basicItem(id: string, value: unknown): PartRow[] {
|
|
85
|
+
if (typeof value === 'string') return [base(id, 'text', value)];
|
|
86
|
+
const v = obj(value); const parts: PartRow[] = [];
|
|
87
|
+
if (typeof v.text === 'string') parts.push(base(id + '-text', 'text', v.text));
|
|
88
|
+
for (const kind of ['image', 'file', 'audio', 'video'] as const) if (v[kind] !== undefined && v[kind] !== null) parts.push(media(id + '-' + kind, kind, v[kind]));
|
|
89
|
+
for (const key of Object.keys(v).filter(k => !['text', 'image', 'file', 'audio', 'video', 'id', '_id', 'type'].includes(k))) parts.push(unsupported(id + '-extra-' + key, { [key]: v[key] }));
|
|
90
|
+
return parts.length ? parts : [unsupported(id, value)];
|
|
91
|
+
}
|
|
92
|
+
function publicParts(content: Obj, id: string): PartRow[] {
|
|
93
|
+
const parts: PartRow[] = [];
|
|
94
|
+
if (content.basic !== undefined) {
|
|
95
|
+
const items = obj(content.basic).items;
|
|
96
|
+
if (Array.isArray(items) && items.length) items.forEach((v, i) => parts.push(...basicItem(`${id}-basic-${i}`, v)));
|
|
97
|
+
else parts.push(unsupported(id + '-basic', content.basic));
|
|
98
|
+
}
|
|
99
|
+
for (const key of ['template', 'form', 'system'] as const) if (content[key] !== undefined) parts.push(...card(id + '-' + key, content[key], key === 'template' ? 'card' : key));
|
|
100
|
+
if (content.minimal !== undefined) {
|
|
101
|
+
const m = obj(content.minimal); const part = base(id + '-minimal', 'system', str(m.text)); const rawUrl = str(m.url);
|
|
102
|
+
if (rawUrl) { part.url = safeUrl(rawUrl); if (!part.url) part.unavailableReason = 'Only safe HTTPS links are supported.'; }
|
|
103
|
+
const icon = str(m.iconUrl);
|
|
104
|
+
if (icon) { part.iconUrl = safeUrl(icon); if (!part.iconUrl) part.caption = 'Activity icon requires a safe HTTPS URL.'; }
|
|
105
|
+
parts.push(part.text || part.url ? part : unsupported(part.id, content.minimal));
|
|
106
|
+
}
|
|
107
|
+
const unknown = Object.keys(content).filter(k => !['basic', 'template', 'form', 'system', 'minimal', 'title', 'previewText'].includes(k));
|
|
108
|
+
for (const key of unknown) parts.push(unsupported(id + '-content-' + key, { [key]: content[key] }));
|
|
109
|
+
return parts;
|
|
110
|
+
}
|
|
111
|
+
function chatPart(id: string, value: unknown): PartRow[] {
|
|
112
|
+
const v = obj(value); const mime = first(v.mimeType, v.contentType).toLowerCase().split(';')[0].trim(); const d = v.data;
|
|
113
|
+
const data = obj(d);
|
|
114
|
+
if (Array.isArray(data.cards)) return data.cards.length ? data.cards.flatMap((item, i) => card(`${id}-card-${i}`, item)) : [unsupported(id, value)];
|
|
115
|
+
if (mime === 'text/plain') return [base(id, 'text', typeof d === 'string' ? d : first(data.text, v.text))];
|
|
116
|
+
if (mime === 'text/html') return [{ ...base(id, 'text', htmlText(typeof d === 'string' ? d : first(data.html, data.text, v.text))), caption: 'HTML message shown as plain text' }];
|
|
117
|
+
// Branded chat separates the outer message-part kind from the file's MIME.
|
|
118
|
+
// mobile-apps-inbox MIME_TYPE: uri/image, uri/file; actual MIME lives in data.type.
|
|
119
|
+
if (mime === 'uri/image' || mime === 'uri/file') {
|
|
120
|
+
return [media(id, mime === 'uri/image' ? 'image' : 'file', typeof d === 'string' ? d : {
|
|
121
|
+
...data, mimeType: first(data.type, data.mimeType, data.contentType),
|
|
122
|
+
})];
|
|
123
|
+
}
|
|
124
|
+
if (/^(image|audio|video)\//.test(mime)) {
|
|
125
|
+
const kind = mime.split('/')[0] as 'image' | 'audio' | 'video';
|
|
126
|
+
return [media(id, kind, typeof d === 'string' ? d : { ...data, mimeType: mime })];
|
|
127
|
+
}
|
|
128
|
+
if (/card|template|form|system/.test(mime)) {
|
|
129
|
+
let payload: unknown = d;
|
|
130
|
+
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { return [unsupported(id, payload)]; } }
|
|
131
|
+
return card(id, payload, /form/.test(mime) ? 'form' : /system/.test(mime) ? 'system' : 'card');
|
|
132
|
+
}
|
|
133
|
+
if (typeof d === 'string' && /^application\//.test(mime) && safeUrl(d)) return [media(id, 'file', { url: d, mimeType: mime })];
|
|
134
|
+
if (first(data.uri, data.url, data.downloadUrl) && (first(data.fileName, data.filename, data.name) || /^(application|text)\//.test(mime))) return [media(id, 'file', { ...data, mimeType: mime })];
|
|
135
|
+
return [unsupported(id, value, mime ? `Unsupported message part (${mime})` : 'Unsupported message part')];
|
|
136
|
+
}
|
|
137
|
+
function decimal(value: string): { negative: boolean; digits: string } | null {
|
|
138
|
+
if (!/^-?\d+$/.test(value)) return null;
|
|
139
|
+
const digits = value.replace(/^-/, '').replace(/^0+(?=\d)/, ''); return { negative: value[0] === '-' && digits !== '0', digits };
|
|
140
|
+
}
|
|
141
|
+
/** Exact decimal ordering, including int64 values beyond JavaScript's safe integer range. */
|
|
142
|
+
export function compareSequence(a: string, b: string): number {
|
|
143
|
+
const x = decimal(a); const y = decimal(b);
|
|
144
|
+
if (x && y) { if (x.negative !== y.negative) return x.negative ? -1 : 1; const n = x.digits.length === y.digits.length ? x.digits < y.digits ? -1 : x.digits > y.digits ? 1 : 0 : x.digits.length < y.digits.length ? -1 : 1; return x.negative ? -n : n; }
|
|
145
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
146
|
+
}
|
|
147
|
+
function dateText(raw: unknown): string {
|
|
148
|
+
if (typeof raw === 'string') return raw;
|
|
149
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) { const date = new Date(raw); return Number.isFinite(date.getTime()) ? date.toISOString() : ''; }
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
function normalize(value: unknown, myMemberId?: string): MessageRow {
|
|
153
|
+
const m = obj(value); const sender = obj(m.sender); const display = obj(m.senderDisplayData);
|
|
154
|
+
const id = typeof m.id === 'string' && m.id ? m.id : typeof m._id === 'string' && m._id ? m._id : typeof m.id === 'number' && Number.isSafeInteger(m.id) ? String(m.id) : 'local-' + hash(value);
|
|
155
|
+
const senderId = first(sender.memberId, sender.userId, sender.wixUserId, sender.contactId, sender.anonymousVisitorId, m.senderId);
|
|
156
|
+
const direction = str(m.direction); const senderName = first(display.name, obj(m.senderDisplayData).displayName, sender.name, typeof m.sender === 'string' ? m.sender : '', sender.type);
|
|
157
|
+
let parts: PartRow[] = publicParts(obj(m.content), id);
|
|
158
|
+
if (Array.isArray(m.data)) m.data.forEach((v, i) => parts.push(...chatPart(`${id}-chat-${i}`, v)));
|
|
159
|
+
if (!parts.length) { // A chat-web template message (a card) keeps its visible text in the top-level templateData.
|
|
160
|
+
const body = first(m.body, m.text, obj(m.templateData).text, m.summary, obj(m.content).previewText); parts = body ? [base(id + '-text', 'text', body)] : [unsupported(id + '-unknown', m.content ?? m.data ?? { messageType: m.messageType ?? 'unknown' })]; }
|
|
161
|
+
const sequence = typeof m.sequence === 'string' ? m.sequence : typeof m.sequence === 'number' && Number.isSafeInteger(m.sequence) ? String(m.sequence) : '';
|
|
162
|
+
const createdDate = dateText(m.createdDate ?? m._createdDate ?? m.createdAt);
|
|
163
|
+
const mine = !!myMemberId && [sender.memberId, sender.userId, sender.wixUserId, sender.contactId, sender.anonymousVisitorId, m.senderId].includes(myMemberId);
|
|
164
|
+
const status = first(m.status, m.deliveryStatus, obj(m.transit).status, obj(m.transit).state, 'stored');
|
|
165
|
+
return { id, sequence, sender: senderName || (senderId ? senderId : 'Unknown sender'), senderId, when: createdDate, createdDate, mine, status, direction, parts, plainText: parts.map(p => [p.text, ...(p.fields ?? []).map(f => `${f.name}: ${f.value}`), p.fileName || p.caption].filter(Boolean).join('\n')).filter(Boolean).join('\n') };
|
|
166
|
+
}
|
|
167
|
+
/** Newest representation replaces the same message ID; deterministic tie ordering never uses wall clock. */
|
|
168
|
+
export function mergeMessages(oldRows: MessageRow[], newRows: MessageRow[]): MessageRow[] {
|
|
169
|
+
const byId = new Map<string, MessageRow>(); for (const row of [...oldRows, ...newRows]) byId.set(row.id, row);
|
|
170
|
+
return [...byId.values()].sort((a, b) => {
|
|
171
|
+
if (!!a.sequence !== !!b.sequence) return a.sequence ? 1 : -1;
|
|
172
|
+
if (a.sequence && b.sequence) { const order = compareSequence(a.sequence, b.sequence); if (order) return order; }
|
|
173
|
+
if (a.createdDate !== b.createdDate) return a.createdDate < b.createdDate ? -1 : 1;
|
|
174
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
export function normalizeMessages(raw: unknown[], myMemberId?: string): MessageRow[] { return mergeMessages([], raw.map(value => normalize(value, myMemberId))); }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { formatClock } from './engine-intl.js';
|
|
2
|
+
import { mergeMessages, normalizeMessages, type MessageRow } from './business-chat-message-model.js';
|
|
3
|
+
|
|
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). The server names the stored message, so the
|
|
6
|
+
* new one is recognised by its text, among the messages after `afterId` (the newest stored message on screen when it
|
|
7
|
+
* was sent), or among all of them when the room was loaded and empty (`fromStart`); `priorCount` is how many earlier
|
|
8
|
+
* sends with the same text were still waiting for their stored copy. `smartChatFromStart` records a successful empty
|
|
9
|
+
* Smart Chat read before sending from Members history; it selects that anchor only for accepted delivery viewed in Smart Chat history. */
|
|
10
|
+
export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string; storedId?:string; afterId?:string; fromStart?:boolean; priorCount?:number; smartChatFromStart?:boolean }
|
|
11
|
+
|
|
12
|
+
export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
|
|
13
|
+
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'};
|
|
14
|
+
}
|
|
15
|
+
export function businessChatSendRejected(error:unknown):boolean{
|
|
16
|
+
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));
|
|
17
|
+
}
|
|
18
|
+
export function draftAfterAcceptedSend(currentDraft:string,sentDraft:string):string{return currentDraft===sentDraft?'':currentDraft;}
|
|
19
|
+
type Row={id:string;mine?:boolean;bodyText?:string;pending?:boolean};
|
|
20
|
+
const sameOwnText=(message:Row,text:string)=>!!message.mine&&(message.bodyText??'').trim()===text.trim();
|
|
21
|
+
/** What a send records about the screen it was sent from: the newest stored message (history is one page in time
|
|
22
|
+
* order, so counting only after it ignores old messages, even one that drops off the page), and how many sends with
|
|
23
|
+
* the same words are still waiting for their stored copies. No clock is involved: the phone's and the server's differ. */
|
|
24
|
+
export function sendAnchor(messages:Row[],text:string,roomLoaded:boolean):{afterId?:string;fromStart?:boolean;priorCount:number}{
|
|
25
|
+
const stored=messages.filter(message=>!message.pending);
|
|
26
|
+
const priorCount=messages.filter(message=>message.pending&&sameOwnText(message,text)).length;
|
|
27
|
+
// A room that loaded with nothing in it: every stored message that appears later is newer than this send's screen.
|
|
28
|
+
// One that failed to load has no anchor at all, as it may hold older messages with the same words.
|
|
29
|
+
if(!stored.length)return roomLoaded?{fromStart:true,priorCount}:{priorCount};
|
|
30
|
+
return {afterId:stored[stored.length-1]!.id,priorCount};
|
|
31
|
+
}
|
|
32
|
+
/** Whether history already holds the member's sent message: by its own id, or as one more message of theirs with the
|
|
33
|
+
* same text than there were when it was sent (the Smart Chat send does not let the app choose the stored id). This
|
|
34
|
+
* only places the message on screen: the send itself is settled by its own request, never by what history shows, so
|
|
35
|
+
* a coincidence (the same words sent from the website meanwhile) cannot hide a failure. */
|
|
36
|
+
export function pendingIntentWasObserved(messages:Row[],intent:BusinessChatIntent|null):boolean{
|
|
37
|
+
if(!intent)return false;
|
|
38
|
+
const stored=messages.filter(message=>!message.pending);
|
|
39
|
+
if(stored.some(message=>message.id===(intent.storedId||intent.id)))return true;
|
|
40
|
+
let newer:Row[];
|
|
41
|
+
if(intent.fromStart)newer=stored;
|
|
42
|
+
else{
|
|
43
|
+
// With no anchor (the room had not loaded) or the anchor gone, an older message with these words cannot be told apart.
|
|
44
|
+
const after=intent.afterId?stored.findIndex(message=>message.id===intent.afterId):-1;
|
|
45
|
+
if(after<0)return false;
|
|
46
|
+
newer=stored.slice(after+1);
|
|
47
|
+
}
|
|
48
|
+
return newer.filter(message=>sameOwnText(message,intent.text)).length>(intent.priorCount??0);
|
|
49
|
+
}
|
|
50
|
+
export function messageTimeLabel(value:string):string{
|
|
51
|
+
const date=new Date(value);
|
|
52
|
+
return Number.isFinite(date.getTime())?formatClock(date):'';
|
|
53
|
+
}
|
|
54
|
+
export function chronologicalMessages(existing:MessageRow[],raw:unknown[],memberId?:string):MessageRow[]{
|
|
55
|
+
return mergeMessages(existing,normalizeMessages(raw,memberId)).map(row=>({...row,when:messageTimeLabel(row.createdDate),side:row.mine?'mine':'other'}));
|
|
56
|
+
}
|