@wix/himalaya-cli 0.1411.0 → 0.1413.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.
Files changed (86) hide show
  1. package/dist/cli.mjs +1199 -816
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/guides/release-notes.md +12 -0
  4. package/guides/wix-business-context.md +32 -0
  5. package/guides/wix-site-to-himi-app.md +21 -3
  6. package/package.json +2 -2
  7. package/templates/beauty-salon-owner/config.ts +29 -0
  8. package/templates/beauty-salon-owner/dev/index.ts +1121 -0
  9. package/templates/beauty-salon-owner/himalaya.content.json +6 -0
  10. package/templates/beauty-salon-owner/tier5-src/_shared/chat-live.ts +242 -0
  11. package/templates/beauty-salon-owner/tier5-src/_shared/detail-presence.ts +51 -0
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  13. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  14. package/templates/branded-lite/dev/index.ts +1 -0
  15. package/templates/branded-lite/tier5-src/_shared/chat-live.ts +242 -0
  16. package/templates/branded-lite/tier5-src/wix-spaces-chat-thread/index.ts +131 -21
  17. package/templates/community-club-owner/config.ts +29 -0
  18. package/templates/community-club-owner/dev/index.ts +1121 -0
  19. package/templates/community-club-owner/himalaya.content.json +6 -0
  20. package/templates/community-club-owner/tier5-src/_shared/chat-live.ts +242 -0
  21. package/templates/community-club-owner/tier5-src/_shared/detail-presence.ts +51 -0
  22. package/templates/community-club-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  23. package/templates/community-club-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  24. package/templates/events-meetups-owner/config.ts +29 -0
  25. package/templates/events-meetups-owner/dev/index.ts +1121 -0
  26. package/templates/events-meetups-owner/himalaya.content.json +6 -0
  27. package/templates/events-meetups-owner/tier5-src/_shared/chat-live.ts +242 -0
  28. package/templates/events-meetups-owner/tier5-src/_shared/detail-presence.ts +51 -0
  29. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  30. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  31. package/templates/gym-fitness-owner/config.ts +29 -0
  32. package/templates/gym-fitness-owner/dev/index.ts +1121 -0
  33. package/templates/gym-fitness-owner/himalaya.content.json +6 -0
  34. package/templates/gym-fitness-owner/tier5-src/_shared/chat-live.ts +242 -0
  35. package/templates/gym-fitness-owner/tier5-src/_shared/detail-presence.ts +51 -0
  36. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  37. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  38. package/templates/nonprofit-cause-owner/config.ts +29 -0
  39. package/templates/nonprofit-cause-owner/dev/index.ts +1121 -0
  40. package/templates/nonprofit-cause-owner/himalaya.content.json +6 -0
  41. package/templates/nonprofit-cause-owner/tier5-src/_shared/chat-live.ts +242 -0
  42. package/templates/nonprofit-cause-owner/tier5-src/_shared/detail-presence.ts +51 -0
  43. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  44. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  45. package/templates/owner-lite/config.ts +29 -0
  46. package/templates/owner-lite/dev/index.ts +1121 -0
  47. package/templates/owner-lite/himalaya.content.json +6 -0
  48. package/templates/owner-lite/tier5-src/_shared/chat-live.ts +242 -0
  49. package/templates/owner-lite/tier5-src/_shared/detail-presence.ts +51 -0
  50. package/templates/owner-lite/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  51. package/templates/owner-lite/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  52. package/templates/podcast-creator-owner/config.ts +29 -0
  53. package/templates/podcast-creator-owner/dev/index.ts +1121 -0
  54. package/templates/podcast-creator-owner/himalaya.content.json +6 -0
  55. package/templates/podcast-creator-owner/tier5-src/_shared/chat-live.ts +242 -0
  56. package/templates/podcast-creator-owner/tier5-src/_shared/detail-presence.ts +51 -0
  57. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  58. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  59. package/templates/portfolio-creative-owner/config.ts +29 -0
  60. package/templates/portfolio-creative-owner/dev/index.ts +1121 -0
  61. package/templates/portfolio-creative-owner/himalaya.content.json +6 -0
  62. package/templates/portfolio-creative-owner/tier5-src/_shared/chat-live.ts +242 -0
  63. package/templates/portfolio-creative-owner/tier5-src/_shared/detail-presence.ts +51 -0
  64. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  65. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  66. package/templates/restaurant-owner/config.ts +29 -0
  67. package/templates/restaurant-owner/dev/index.ts +1121 -0
  68. package/templates/restaurant-owner/himalaya.content.json +6 -0
  69. package/templates/restaurant-owner/tier5-src/_shared/chat-live.ts +242 -0
  70. package/templates/restaurant-owner/tier5-src/_shared/detail-presence.ts +51 -0
  71. package/templates/restaurant-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  72. package/templates/restaurant-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  73. package/templates/retail-store-owner/config.ts +29 -0
  74. package/templates/retail-store-owner/dev/index.ts +1121 -0
  75. package/templates/retail-store-owner/himalaya.content.json +6 -0
  76. package/templates/retail-store-owner/tier5-src/_shared/chat-live.ts +242 -0
  77. package/templates/retail-store-owner/tier5-src/_shared/detail-presence.ts +51 -0
  78. package/templates/retail-store-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  79. package/templates/retail-store-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
  80. package/templates/yoga-studio-owner/config.ts +29 -0
  81. package/templates/yoga-studio-owner/dev/index.ts +1121 -0
  82. package/templates/yoga-studio-owner/himalaya.content.json +6 -0
  83. package/templates/yoga-studio-owner/tier5-src/_shared/chat-live.ts +242 -0
  84. package/templates/yoga-studio-owner/tier5-src/_shared/detail-presence.ts +51 -0
  85. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-inbox/index.ts +308 -0
  86. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-inbox-thread/index.ts +307 -0
@@ -0,0 +1,307 @@
1
+ // Tier-5 bundle: __HIMI_APP_NAME__ — Inbox thread + reply.
2
+ //
3
+ // `himalaya.input` carries `id` and `kind`:
4
+ // kind "chatroom" — a chat-web chatroom id, from an Inbox row; used as-is.
5
+ // kind "contact" — a Wix contact id, from Ping's `inbox/conversation/:contactId`
6
+ // notification. Resolved to the conversation whose participant is that
7
+ // contact; when none is found the screen goes back to the Inbox list it
8
+ // is stacked on, which is a better place to land than "not found" for a message the
9
+ // owner was just told about.
10
+ //
11
+ // Reads and the composer are chat-web (`getMessages`, `sendChatMessage`), lifted from the
12
+ // wix-owner-clone app's `wix-owner-inbox-thread`. Keeping the thread live while it is open
13
+ // is the shared `_shared/chat-live.ts`: a `wix-realtime://` stream when the screen is given
14
+ // a channel (`chatRealtime` appear param), else a 4 s foreground re-read. Either way a
15
+ // change is answered by re-reading the thread, never by trusting an event's payload.
16
+ //
17
+ // Honest states: a missing id is the not-found state with no composer; a failed read is the
18
+ // load-error state — never an empty conversation whose Send can only fail. A failed send
19
+ // keeps the draft and says why.
20
+
21
+ import { defineService, navigate, type NavDirective, type PollDirective, type StreamDirective } from "@himalaya/state";
22
+ import { getMessages, sendChatMessage, listChatrooms, markChatroomRead, getChatToken, type ChatMessage } from "@himalaya/wix-chat-web";
23
+ import { formatMonthDayTime } from "../_shared/engine-intl.js";
24
+ import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
25
+ import { ownerLoadError, ownerWriteError, writeMayHaveLanded } from "../_shared/owner-load-error.js";
26
+ import { stashCopy, copyParam } from "../_shared/himi-copy.js";
27
+ import { chatRealtimeConfig, createReloadGate, createSendIntents, createVisitToken, liveDirectives, postWithVisitToken, liveFallback, type ChatLiveMode, type ChatRealtimeConfig } from "../_shared/chat-live.js";
28
+
29
+ const SERVICE = "ownerLiteInboxThread";
30
+ const PAGE_SIZE = 50;
31
+ /** How far back a contact → conversation lookup reads. */
32
+ const LOOKUP_SIZE = 100;
33
+
34
+ export interface Bubble {
35
+ id: string;
36
+ text: string;
37
+ when: string;
38
+ /** " " for the owner's own messages (a gated flexible spacer pushes the bubble right),
39
+ * "" for the customer's (the spacer collapses, the bubble stays left). */
40
+ leadFill: string;
41
+ mine: boolean;
42
+ theirs: boolean;
43
+ spokenLabel: string;
44
+ }
45
+
46
+ export interface ThreadState extends PresenceFlags {
47
+ status: "loading" | "ready" | "error";
48
+ phase: "loading" | "ready";
49
+ chatroomId: string;
50
+ headerName: string;
51
+ messages: Bubble[];
52
+ hideEmpty: boolean;
53
+ error: string;
54
+ draft: string;
55
+ sendHint: string;
56
+ sending: boolean;
57
+ live: ChatLiveMode;
58
+ _stream?: StreamDirective;
59
+ _poll?: PollDirective;
60
+ _nav?: NavDirective;
61
+ }
62
+
63
+ function input(): { id: string; kind: string } {
64
+ const i = (globalThis as { himalaya?: { input?: { kind?: unknown } } }).himalaya?.input;
65
+ return { id: detailInputId(), kind: typeof i?.kind === "string" ? i.kind : "" };
66
+ }
67
+
68
+ /** chat-web senders: business / businessUser / user are the owner's side. */
69
+ export function isBusiness(m: ChatMessage): boolean {
70
+ const t = m.sender?.type || "";
71
+ return t === "business" || t === "businessUser" || t === "user";
72
+ }
73
+
74
+ function textOf(m: ChatMessage): string {
75
+ const part = (m.data ?? []).find((p) => (p.mimeType || "").startsWith("text"));
76
+ return (part?.data?.text || m.summary || "").trim();
77
+ }
78
+
79
+ export function bubbles(msgs: ChatMessage[]): Bubble[] {
80
+ // chat-web answers newest-first by default; a thread reads oldest at the top.
81
+ const ordered = [...msgs].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
82
+ const you = copyParam("ownerInbox.you", "You");
83
+ return ordered
84
+ .map((m, i) => {
85
+ const mine = isBusiness(m);
86
+ const text = textOf(m);
87
+ const when = m.createdAt ? formatMonthDayTime(new Date(m.createdAt)) : "";
88
+ const who = mine ? you : (m.senderDisplayData?.name || "").trim();
89
+ return { id: m.id || `m${i}`, text, when, leadFill: mine ? " " : "", mine, theirs: !mine, spokenLabel: [who, text, when].filter(Boolean).join(", ") };
90
+ })
91
+ .filter((b) => b.text);
92
+ }
93
+
94
+ /** The newest message's sequence — what "read up to here" names. */
95
+ function newestSequence(msgs: ChatMessage[]): number | undefined {
96
+ let max: number | undefined;
97
+ for (const m of msgs) if (typeof m.sequence === "number" && Number.isSafeInteger(m.sequence) && (max === undefined || m.sequence > max)) max = m.sequence;
98
+ return max;
99
+ }
100
+
101
+ function headerOf(msgs: ChatMessage[], fallback: string): string {
102
+ const name = msgs.find((m) => !isBusiness(m))?.senderDisplayData?.name?.trim();
103
+ return name || fallback || copyParam("ownerInbox.untitled", "Conversation");
104
+ }
105
+
106
+ /** The conversation a contact is in, or "" when the owner's recent conversations have none. */
107
+ async function chatroomForContact(contactId: string): Promise<{ id: string; name: string }> {
108
+ const rooms = await listChatrooms(LOOKUP_SIZE);
109
+ const room = rooms.find((r) => r?.id && r.participantId === contactId);
110
+ return room ? { id: room.id, name: (room.displayData?.name || "").trim() } : { id: "", name: "" };
111
+ }
112
+
113
+ // The conversation the gate re-reads. Module state, not worker state: a re-read must use
114
+ // the conversation this screen opened even if an older state object is in flight.
115
+ let current = "";
116
+ let realtime: ChatRealtimeConfig | null = null;
117
+
118
+ // ONE chatToken per visit (`_shared/chat-live.ts`): a 4 s re-read would otherwise mint a
119
+ // token every 4 s on top of its GET. Reset on every open.
120
+ const visitToken = createVisitToken(() => getChatToken().then((t) => t.chatToken));
121
+ async function readThread(): Promise<ChatMessage[]> {
122
+ const token = visitToken.get();
123
+ try {
124
+ return await getMessages(current, PAGE_SIZE, { chatToken: await token });
125
+ } catch (e) {
126
+ visitToken.drop(token);
127
+ throw e;
128
+ }
129
+ }
130
+
131
+ /** Send ids that belong to the intent (conversation + text), not the tap. */
132
+ const intents = createSendIntents();
133
+
134
+ /** Post once per intent: a retried tap of the same text reuses its message id. */
135
+ async function postOnce(chatroomId: string, text: string): Promise<void> {
136
+ const messageId = intents.idFor(chatroomId, text);
137
+ await postWithVisitToken(visitToken, (chatToken) => sendChatMessage(chatroomId, text, { chatToken, messageId }));
138
+ intents.settle(chatroomId, text);
139
+ }
140
+
141
+ const gate = createReloadGate(readThread);
142
+
143
+ /** The newest sequence already marked read, per conversation. A re-read every 4 s must
144
+ * not become a write every 4 s: only a message newer than this is marked. Reset on every
145
+ * open — the owner may have marked the conversation unread from the list in between. */
146
+ const markedUpTo = new Map<string, number>();
147
+
148
+ /** Tell chat-web the owner has read the thread, so the Inbox badge clears on return.
149
+ * Best effort: a failure here costs a stale badge, never the thread — and forgets the
150
+ * mark, so the next read tries again. */
151
+ function markRead(chatroomId: string, msgs: ChatMessage[]): void {
152
+ const seq = newestSequence(msgs);
153
+ if (seq === undefined || seq <= (markedUpTo.get(chatroomId) ?? -1)) return;
154
+ markedUpTo.set(chatroomId, seq);
155
+ void visitToken.get()
156
+ .then((t) => markChatroomRead(chatroomId, seq, { chatToken: t }))
157
+ .catch(() => {
158
+ if (markedUpTo.get(chatroomId) === seq) markedUpTo.delete(chatroomId);
159
+ });
160
+ }
161
+
162
+ function withMessages(s: ThreadState, msgs: ChatMessage[]): ThreadState {
163
+ const messages = bubbles(msgs);
164
+ return { ...s, messages, hideEmpty: messages.length > 0, headerName: headerOf(msgs, s.headerName), error: "" };
165
+ }
166
+
167
+ /**
168
+ * The state to put a finished read onto. NOT the state the action started with: the
169
+ * runtime replaces the whole service state with what an action returns, so a read that
170
+ * resolves after the owner typed or sent would otherwise put back the old draft and the
171
+ * old messages. Reading the live state after the `await` keeps everything that changed
172
+ * meanwhile and replaces only what the read is the answer for.
173
+ */
174
+ function live(): ThreadState {
175
+ return svc.getState();
176
+ }
177
+
178
+ /** A tick or an event: re-read, keep the screen as it is when the read fails (the next
179
+ * tick tries again), and do nothing before the conversation is open. */
180
+ async function reread(s: ThreadState): Promise<ThreadState> {
181
+ if (!s.chatroomId || !s.found || s.chatroomId !== current) return s;
182
+ try {
183
+ const msgs = await gate.request();
184
+ const now = live();
185
+ if (now.chatroomId !== s.chatroomId) return now;
186
+ markRead(s.chatroomId, msgs);
187
+ return withMessages(now, msgs);
188
+ } catch {
189
+ return live();
190
+ }
191
+ }
192
+
193
+ /** A send in flight. Set synchronously, before the first `await`, so a second tap cannot
194
+ * start a second send: an async action publishes no state until it returns, so a state
195
+ * flag could not guard this. */
196
+ let sendingNow = false;
197
+
198
+ const INITIAL: ThreadState = {
199
+ ...presence("loading"),
200
+ status: "loading",
201
+ phase: "loading",
202
+ chatroomId: "",
203
+ headerName: "",
204
+ messages: [],
205
+ hideEmpty: true,
206
+ error: "",
207
+ draft: "",
208
+ sendHint: "",
209
+ sending: false,
210
+ live: "off",
211
+ };
212
+
213
+ const svc = defineService<ThreadState, {
214
+ load: (s: ThreadState, arg?: unknown) => Promise<ThreadState>;
215
+ refresh: (s: ThreadState) => Promise<ThreadState>;
216
+ onStreamEvent: (s: ThreadState) => Promise<ThreadState>;
217
+ onStreamError: (s: ThreadState) => ThreadState;
218
+ setDraft: (s: ThreadState, arg?: unknown) => ThreadState;
219
+ send: (s: ThreadState) => Promise<ThreadState>;
220
+ back: (s: ThreadState) => ThreadState;
221
+ }>(SERVICE, {
222
+ state: INITIAL,
223
+ actions: {
224
+ load: async (s, arg) => {
225
+ stashCopy(arg);
226
+ const param = (arg as { chatRealtime?: unknown } | null | undefined)?.chatRealtime;
227
+ if (param !== undefined) realtime = chatRealtimeConfig(param);
228
+ const { id, kind } = input();
229
+ if (!id) return { ...s, ...presence("missing"), status: "ready", phase: "ready", messages: [], hideEmpty: true, live: "off" };
230
+ visitToken.reset();
231
+
232
+ let chatroomId = id;
233
+ let headerName = s.headerName;
234
+ if (kind === "contact") {
235
+ try {
236
+ const room = await chatroomForContact(id);
237
+ // No conversation for that contact among the recent ones: go BACK to the Inbox the
238
+ // thread is stacked on (a push synthesizes Inbox → thread). A `replace` would leave
239
+ // Inbox → Inbox, with a Back to an identical list.
240
+ if (!room.id) return navigate({ ...live(), status: "ready", phase: "ready" }, { presentation: "back" });
241
+ chatroomId = room.id;
242
+ headerName = room.name || headerName;
243
+ } catch (e) {
244
+ return { ...live(), ...presence("failed"), status: "error", phase: "ready", error: ownerLoadError(e), live: "off" };
245
+ }
246
+ }
247
+
248
+ current = chatroomId;
249
+ markedUpTo.delete(chatroomId);
250
+ try {
251
+ const msgs = await readThread();
252
+ markRead(chatroomId, msgs);
253
+ const next = withMessages({ ...live(), ...presence("found"), status: "ready", phase: "ready", chatroomId, headerName }, msgs);
254
+ return { ...next, ...liveDirectives(realtime, chatroomId, SERVICE) };
255
+ } catch (e) {
256
+ return { ...live(), ...presenceAfterError(e), status: "error", phase: "ready", chatroomId, headerName, messages: [], hideEmpty: true, error: ownerLoadError(e), live: "off" };
257
+ }
258
+ },
259
+ // The fallback poll's tick.
260
+ refresh: async (s) => reread(s),
261
+ // "The thread changed" — the payload is deliberately not read.
262
+ onStreamEvent: async (s) => reread(s),
263
+ // The channel is gone (or this host refuses it, as the web host does): poll instead.
264
+ onStreamError: (s) => (s.chatroomId ? { ...s, ...liveFallback(SERVICE) } : s),
265
+ setDraft: (s, arg) => {
266
+ const v = typeof arg === "string" ? arg : (arg as { value?: unknown } | null | undefined)?.value;
267
+ return { ...s, draft: typeof v === "string" ? v : "", sendHint: "" };
268
+ },
269
+ // "Back to Inbox" from the not-found state: pop to the Inbox the thread is stacked on.
270
+ // `himi.open` would push a second Inbox, and Back would then return to the dead thread.
271
+ back: (s) => navigate(s, { presentation: "back" }),
272
+ send: async (s) => {
273
+ const text = s.draft.trim();
274
+ if (!text) return { ...s, sendHint: copyParam("ownerInbox.typeFirst", "Type a message first.") };
275
+ if (!s.chatroomId || !s.found || sendingNow) return s;
276
+ sendingNow = true;
277
+ try {
278
+ try {
279
+ await postOnce(s.chatroomId, text);
280
+ } catch (e) {
281
+ // The draft stays: the owner should not have to type it again. A send that may
282
+ // have landed says so, so a retry does not post it twice unknowingly.
283
+ return { ...live(), sendHint: writeMayHaveLanded(e) ? ownerWriteError(e) : `${copyParam("ownerInbox.sendFailed", "Couldn’t send")} — ${ownerWriteError(e)}` };
284
+ }
285
+ // Clear the composer only if it still holds what was sent — the owner may already be
286
+ // typing the next message — judged on the state AFTER the re-read's await.
287
+ const cleared = (now: ThreadState): string => (now.draft.trim() === text ? "" : now.draft);
288
+ try {
289
+ const msgs = await gate.request();
290
+ const now = live();
291
+ return withMessages({ ...now, draft: cleared(now), sendHint: "" }, msgs);
292
+ } catch {
293
+ // Sent, but the re-read failed: show it locally until the next tick replaces it.
294
+ const you = copyParam("ownerInbox.you", "You");
295
+ const now = live();
296
+ const mine: Bubble = { id: `local-${now.messages.length}`, text, when: "", leadFill: " ", mine: true, theirs: false, spokenLabel: `${you}, ${text}` };
297
+ return { ...now, draft: cleared(now), sendHint: "", messages: [...now.messages, mine], hideEmpty: true };
298
+ }
299
+ } finally {
300
+ sendingNow = false;
301
+ }
302
+ },
303
+ },
304
+ });
305
+
306
+ svc.load();
307
+ export { svc };
@@ -34678,6 +34678,7 @@ const SCREENS = runtimeSetting({
34678
34678
  "token": "surfaceElevated"
34679
34679
  },
34680
34680
  "cornerRadius": 12,
34681
+ "maxWidth": 270,
34681
34682
  "padding": {
34682
34683
  "leading": 12,
34683
34684
  "trailing": 12,
@@ -0,0 +1,242 @@
1
+ // How an open chat thread stays live — one helper for owner-lite's Inbox thread and
2
+ // branded-lite's member chat.
3
+ //
4
+ // Two paths, and the worker never has to choose between them by platform:
5
+ //
6
+ // 1. REALTIME. With a Wix Chat channel configured, `load` returns a `_stream` directive
7
+ // whose url is `wix-realtime://<appDefId>/<channel>?slug=<slug>&resourceId=<chatroom>`.
8
+ // The iOS and Android hosts open a Wix Duplexer channel for it and
9
+ // dispatch `onEvent` for each matching message.
10
+ // 2. FALLBACK. With no channel, or once the stream fails (`onError` — which is also what
11
+ // the web host answers, since it refuses `wix-realtime://`), a `_poll` directive asks
12
+ // the host to re-read the thread every 4 s.
13
+ //
14
+ // The fallback is a HOST timer, not a worker one: the worker sandbox has no
15
+ // `setInterval`. That is also what keeps it to the
16
+ // foreground — every host suspends a poll when the app is backgrounded or the screen is
17
+ // pooled off-view, and cancels it when the screen is torn down — so a hidden thread never
18
+ // polls. A poll has no stop verb; a thread that has its stream simply never asks for one.
19
+ //
20
+ // An event is only ever "the thread changed". Its payload is not read: its shape is
21
+ // unverified, and the host deliberately does not interpret it either. The worker re-reads
22
+ // the messages instead — one cheap call that cannot desync — and `createReloadGate`
23
+ // folds a burst of events into one read.
24
+ //
25
+ // This file is copied verbatim into every generated starter app, so it explains itself
26
+ // without pointing at anything only this repo has.
27
+
28
+ import type { PollDirective, StreamDirective } from "@himalaya/state";
29
+
30
+ /** A Wix Chat Duplexer channel: the `ns` (an app def id), the bare channel name, and the
31
+ * event slug a new message arrives under. */
32
+ export interface ChatRealtimeConfig {
33
+ appDefId: string;
34
+ channel: string;
35
+ slug: string;
36
+ }
37
+
38
+ /** The stream's id. One per screen: a new directive under the same id replaces the old
39
+ * channel instead of opening a second one. */
40
+ export const CHAT_LIVE_STREAM_ID = "chat-live";
41
+ /** The fallback poll's id. Re-arming it replaces the timer, never stacks a second one. */
42
+ export const CHAT_LIVE_POLL_ID = "chat-live-poll";
43
+ /** "A message from the other side appears within 5 s." */
44
+ export const CHAT_LIVE_POLL_MS = 4000;
45
+
46
+ /** Which path is keeping the thread live: `off` before a conversation is open, `stream`
47
+ * when a realtime channel was asked for, `poll` on the fallback. Diagnostic. */
48
+ export type ChatLiveMode = "off" | "stream" | "poll";
49
+
50
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
51
+
52
+ /** A complete, well-formed channel, or null. A half-filled one would subscribe to
53
+ * nothing and never fire, which looks exactly like a quiet conversation. */
54
+ export function chatRealtimeConfig(value: unknown): ChatRealtimeConfig | null {
55
+ let v = value;
56
+ if (typeof v === "string") {
57
+ if (!v.trim()) return null;
58
+ try { v = JSON.parse(v); } catch { return null; }
59
+ }
60
+ if (!v || typeof v !== "object") return null;
61
+ const { appDefId, channel, slug } = v as Record<string, unknown>;
62
+ if (typeof appDefId !== "string" || !GUID.test(appDefId)) return null;
63
+ if (typeof channel !== "string" || !channel.trim()) return null;
64
+ if (typeof slug !== "string" || !slug.trim()) return null;
65
+ return { appDefId, channel, slug };
66
+ }
67
+
68
+ /** The `wix-realtime://` url for one conversation. */
69
+ export function chatRealtimeUrl(cfg: ChatRealtimeConfig, chatroomId: string): string {
70
+ return `wix-realtime://${cfg.appDefId}/${encodeURIComponent(cfg.channel)}?slug=${encodeURIComponent(cfg.slug)}&resourceId=${encodeURIComponent(chatroomId)}`;
71
+ }
72
+
73
+ /** The stream directive that keeps `chatroomId` live, or null when there is no channel
74
+ * (or no conversation) to open. `onEvent` / `onError` are action names of the calling
75
+ * service. */
76
+ export function liveThreadDirective(
77
+ cfg: ChatRealtimeConfig | null,
78
+ chatroomId: string,
79
+ actions: { onEvent: string; onError: string },
80
+ ): StreamDirective | null {
81
+ if (!cfg || !chatroomId) return null;
82
+ return {
83
+ id: CHAT_LIVE_STREAM_ID,
84
+ request: { method: "GET", url: chatRealtimeUrl(cfg, chatroomId) },
85
+ onEvent: actions.onEvent,
86
+ onError: actions.onError,
87
+ };
88
+ }
89
+
90
+ /** The 4 s foreground re-read. `action` is fully qualified (`"<service>.<action>"`). */
91
+ export function livePollDirective(action: string, intervalMs = CHAT_LIVE_POLL_MS): PollDirective {
92
+ return { id: CHAT_LIVE_POLL_ID, intervalMs, action, immediate: false };
93
+ }
94
+
95
+ /** What `load` returns beside its state to keep the conversation live: the stream when a
96
+ * channel is configured, else the poll. Nothing when there is no conversation. The
97
+ * service's actions must be named `onStreamEvent`, `onStreamError` and `refresh`. */
98
+ export function liveDirectives(
99
+ cfg: ChatRealtimeConfig | null,
100
+ chatroomId: string,
101
+ service: string,
102
+ ): { live: ChatLiveMode; _stream?: StreamDirective; _poll?: PollDirective } {
103
+ if (!chatroomId) return { live: "off" };
104
+ const stream = liveThreadDirective(cfg, chatroomId, { onEvent: "onStreamEvent", onError: "onStreamError" });
105
+ if (stream) return { live: "stream", _stream: stream };
106
+ return { live: "poll", _poll: livePollDirective(`${service}.refresh`) };
107
+ }
108
+
109
+ /** What `onStreamError` returns beside its state: the stream is gone, so poll. */
110
+ export function liveFallback(service: string): { live: ChatLiveMode; _poll: PollDirective } {
111
+ return { live: "poll", _poll: livePollDirective(`${service}.refresh`) };
112
+ }
113
+
114
+ /**
115
+ * Fold re-read requests so a burst costs one read.
116
+ *
117
+ * Requests made in the same tick share one read. A request made while a read is in
118
+ * flight gets ONE trailing read after it — never more than one, however many arrive —
119
+ * because the in-flight read may have left the server before the change that prompted
120
+ * the request, and returning its answer would drop that message.
121
+ */
122
+ export function createReloadGate<T>(read: () => Promise<T>): { request(): Promise<T> } {
123
+ let queued: Promise<T> | null = null;
124
+ let running: Promise<void> | null = null;
125
+ return {
126
+ request() {
127
+ if (queued) return queued;
128
+ const prior = running ?? Promise.resolve();
129
+ const next = prior.then(() => {
130
+ queued = null;
131
+ return read();
132
+ });
133
+ queued = next;
134
+ const settled = next.then(() => undefined, () => undefined);
135
+ running = settled;
136
+ void settled.then(() => {
137
+ if (running === settled) running = null;
138
+ });
139
+ return next;
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * One chat token per thread VISIT.
146
+ *
147
+ * Every chat-web call needs a token, and minting one is a request of its own — so a thread
148
+ * that re-reads every 4 s would otherwise mint every 4 s. `get()` mints on first use and
149
+ * shares that token with every read and send; `reset()` starts a new visit; `drop(p)`
150
+ * forgets a token after a call that used it failed (an expired token costs one failed call,
151
+ * and the next one mints again). `drop` only forgets THAT token: a late failure from an
152
+ * earlier visit must not throw away the token the current visit just minted.
153
+ */
154
+ export function createVisitToken(mint: () => Promise<string>): {
155
+ get(): Promise<string>;
156
+ drop(token: Promise<string> | null): void;
157
+ reset(): void;
158
+ } {
159
+ let current: Promise<string> | null = null;
160
+ const drop = (token: Promise<string> | null): void => {
161
+ if (token && current === token) current = null;
162
+ };
163
+ return {
164
+ get() {
165
+ if (!current) {
166
+ const minted = mint();
167
+ current = minted;
168
+ minted.catch(() => drop(minted));
169
+ }
170
+ return current;
171
+ },
172
+ drop,
173
+ reset() {
174
+ current = null;
175
+ },
176
+ };
177
+ }
178
+
179
+ /** Did a chat-web call fail because its token was refused? The status chat-web uses for an
180
+ * expired chatToken is NOT verified against a live session: 401 and 403 are both treated as
181
+ * "refused", and either way a retry with the same message id is the same send intent, so a
182
+ * wrong guess costs one extra request, never a second message. Any other failure may have
183
+ * posted the message, so it is not retried. */
184
+ export function isTokenRejected(e: unknown): boolean {
185
+ return /HTTP 40[13]\b/.test(String((e as Error)?.message ?? e ?? ""));
186
+ }
187
+
188
+ /** UUID v4 for a send's idempotency id (the worker realm has no crypto.randomUUID). */
189
+ export function uuidV4(): string {
190
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
191
+ const r = Math.floor(Math.random() * 16);
192
+ return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Send ids that belong to the INTENT, not the tap.
198
+ *
199
+ * The same id on a retry is the same send to chat-web, so the server keeps one message. A
200
+ * send that failed ambiguously (a gateway timeout after the backend stored it) and is tapped
201
+ * again must reuse its id, or the business receives the message twice. An intent is the
202
+ * conversation plus the exact text; it is settled once the send succeeds, and any other text
203
+ * is a new intent.
204
+ */
205
+ export function createSendIntents(): { idFor(chatroomId: string, text: string): string; settle(chatroomId: string, text: string): void } {
206
+ const ids = new Map<string, string>();
207
+ const keyOf = (chatroomId: string, text: string) => `${chatroomId}\u0000${text}`;
208
+ return {
209
+ idFor(chatroomId, text) {
210
+ const key = keyOf(chatroomId, text);
211
+ let id = ids.get(key);
212
+ if (!id) { id = uuidV4(); ids.set(key, id); }
213
+ return id;
214
+ },
215
+ settle(chatroomId, text) {
216
+ ids.delete(keyOf(chatroomId, text));
217
+ },
218
+ };
219
+ }
220
+
221
+ /** Post with the visit's token, re-minting once if chat-web refused the token. Whichever
222
+ * token a failed attempt used is dropped, so a rejected token is never reused. */
223
+ export async function postWithVisitToken(
224
+ token: ReturnType<typeof createVisitToken>,
225
+ send: (chatToken: string) => Promise<void>,
226
+ ): Promise<void> {
227
+ const first = token.get();
228
+ try {
229
+ await send(await first);
230
+ return;
231
+ } catch (e) {
232
+ token.drop(first);
233
+ if (!isTokenRejected(e)) throw e;
234
+ }
235
+ const retry = token.get();
236
+ try {
237
+ await send(await retry);
238
+ } catch (e) {
239
+ token.drop(retry);
240
+ throw e;
241
+ }
242
+ }