@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
@@ -31,6 +31,12 @@
31
31
  "himi_owner_lite_home": {
32
32
  "displayName": "Business Dashboard"
33
33
  },
34
+ "himi_owner_lite_inbox": {
35
+ "displayName": "Inbox"
36
+ },
37
+ "himi_owner_lite_inbox_thread": {
38
+ "displayName": "Conversation"
39
+ },
34
40
  "himi_owner_lite_manage": {
35
41
  "displayName": "Shop"
36
42
  },
@@ -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
+ }
@@ -0,0 +1,51 @@
1
+ // Whether a detail screen's entity is there — the worker half of the shared not-found
2
+ // state (the `detailNotFound` descriptor builder).
3
+ //
4
+ // A detail screen's descriptor asks two questions, both as `hiddenWhen` flags:
5
+ // • `noEntity` → hide the entity's content AND its actions (Like, Follow, Send…)
6
+ // • `hideNotFound` → hide the not-found state
7
+ // Four answers exist, and only one of them shows the not-found state:
8
+ // loading — a loading line (`hideLoading` false); no content, no actions, no verdict
9
+ // found — content + actions shown
10
+ // missing — not-found shown, content + actions hidden
11
+ // failed — a load-error line (`hideLoadError` false); no content, no actions
12
+ // A load that FAILS for any reason other than "not found" is `failed`, never `missing`:
13
+ // an offline device must not tell the user their invoice was deleted.
14
+
15
+ export type Presence = "loading" | "found" | "missing" | "failed";
16
+
17
+ export interface PresenceFlags {
18
+ found: boolean;
19
+ notFound: boolean;
20
+ noEntity: boolean;
21
+ hideNotFound: boolean;
22
+ hideLoading: boolean;
23
+ hideLoadError: boolean;
24
+ }
25
+
26
+ export function presence(p: Presence): PresenceFlags {
27
+ return {
28
+ found: p === "found", notFound: p === "missing", noEntity: p !== "found", hideNotFound: p !== "missing",
29
+ hideLoading: p !== "loading", hideLoadError: p !== "failed",
30
+ };
31
+ }
32
+
33
+ /** The entity id the host baked into this detail screen (`himi.open` params). */
34
+ export function detailInputId(): string {
35
+ const v = (globalThis as { himalaya?: { input?: { id?: unknown } } }).himalaya?.input?.id;
36
+ return typeof v === "string" ? v.trim() : "";
37
+ }
38
+
39
+ /** True when a Wix read failed because the record does not exist: the SDK rejects
40
+ * with "HTTP <code>: <body>", so an HTTP 404, or Wix's `NOT_FOUND` error code. A bare
41
+ * "not found" in a message is NOT enough — "capability wix_api not found" or "Method
42
+ * not found" is a broken integration, and must not tell the user the record is gone. */
43
+ export function isNotFoundError(error: unknown): boolean {
44
+ const msg = String((error as { message?: unknown } | null)?.message ?? error ?? "");
45
+ return /\bHTTP\s*404\b/.test(msg) || /\bNOT_FOUND\b/.test(msg);
46
+ }
47
+
48
+ /** `missing` for a not-found failure, `failed` (claim nothing) otherwise. */
49
+ export function presenceAfterError(error: unknown): PresenceFlags {
50
+ return presence(isNotFoundError(error) ? "missing" : "failed");
51
+ }
@@ -0,0 +1,308 @@
1
+ // Tier-5 bundle: __HIMI_APP_NAME__ — Inbox (the site's customer conversations).
2
+ //
3
+ // Transport is chat-web, the one the real Owner inbox uses: the public inbox/v2 REST has
4
+ // no list endpoint. `listChatrooms` mints an owner (unified-list) chatToken and reads the
5
+ // v2 list, site-scoped by the owner bridge's per-site instance.
6
+ //
7
+ // The list logic (rows, All/Unread, search, archive, mark unread) is lifted from the
8
+ // wix-owner-clone app's `wix-owner-inbox` worker; that app keeps its own copy. What is
9
+ // different here is honesty: a failed read is the shared owner error state with Retry —
10
+ // never an empty "No messages yet", and never sample rows. An owner
11
+ // who sees "no messages" must be able to trust that there are none.
12
+ //
13
+ // FRESHNESS. The thread is where a conversation is read, and chat-web marks it read
14
+ // there; this list re-loads every time it appears, so the unread badge reflects the read
15
+ // state the server applied while the owner was in the thread. While the list is visible
16
+ // it also re-reads on a slow foreground `_poll`, so a conversation that arrives while the
17
+ // owner is looking at the list shows up without a tap. The host suspends that poll in the
18
+ // background and when the screen is pooled off-view — but NOT while another tab of a
19
+ // keep-alive tab bar is showing, which is why the cadence is slow.
20
+ //
21
+ // A push does not refresh this list: `push.observerAction` lands in the app-level nav worker
22
+ // only.
23
+
24
+ import { defineService, type PollDirective } from "@himalaya/state";
25
+ import { listChatrooms, archiveChatroom, markChatroomUnread, type Chatroom } from "@himalaya/wix-chat-web";
26
+ import { formatMonthDay } from "../_shared/engine-intl.js";
27
+ import { ownerLoadError, ownerWriteError } from "../_shared/owner-load-error.js";
28
+ import { stashCopy, copyParam } from "../_shared/himi-copy.js";
29
+
30
+ const SERVICE = "ownerLiteInbox";
31
+ /** How many conversations one read asks for. */
32
+ const PAGE_SIZE = 50;
33
+ /** The list's own re-read. Slow: a new conversation is worth seeing soon, not within the
34
+ * second, and a keep-alive tab keeps polling while the owner is on another tab. */
35
+ const LIST_POLL_MS = 30000;
36
+
37
+ export interface InboxRow {
38
+ id: string;
39
+ /** chat-web chatroom id — the thread screen uses it as-is. */
40
+ kind: "chatroom";
41
+ display: string;
42
+ /** Last-message preview. The time is its own field. */
43
+ subtitle: string;
44
+ initial: string;
45
+ image: string;
46
+ hasImage: boolean;
47
+ noImage: boolean;
48
+ unread: number;
49
+ /** "3", "99+", or "" when read. */
50
+ unreadText: string;
51
+ unreadHidden: boolean;
52
+ /** "now" / "5m" / "2h" / "3d" / "Jun 5". */
53
+ timeText: string;
54
+ /** The row's whole spoken name: who, how many unread, the preview. */
55
+ spokenLabel: string;
56
+ }
57
+
58
+ export interface InboxState {
59
+ status: "loading" | "ready" | "error";
60
+ /** FragmentSlot case: "loading" until the first answer, then "ready" — for good. A
61
+ * re-load keeps the rows on screen instead of flashing the skeleton. */
62
+ phase: "loading" | "ready";
63
+ all: InboxRow[];
64
+ items: InboxRow[];
65
+ filter: "all" | "unread";
66
+ query: string;
67
+ error: string;
68
+ /** Why the last archive / mark-unread did not happen, or "". */
69
+ actionError: string;
70
+ unreadCount: number;
71
+ summary: string;
72
+ notErrored: boolean;
73
+ hideList: boolean;
74
+ /** One empty state each — "No messages yet", "You're all caught up", "No matches".
75
+ * At most one is ever shown, and none while the read failed. */
76
+ hideEmptyAll: boolean;
77
+ hideEmptyUnread: boolean;
78
+ hideEmptyNoMatch: boolean;
79
+ _poll?: PollDirective;
80
+ }
81
+
82
+ function relTime(ms?: number): string {
83
+ if (!ms) return "";
84
+ const d = new Date(ms);
85
+ if (isNaN(d.getTime())) return "";
86
+ const min = Math.floor((Date.now() - ms) / 60000);
87
+ if (min < 1) return copyParam("ownerInbox.now", "now");
88
+ if (min < 60) return `${min}m`;
89
+ const hr = Math.floor(min / 60);
90
+ if (hr < 24) return `${hr}h`;
91
+ const day = Math.floor(hr / 24);
92
+ if (day < 7) return `${day}d`;
93
+ return formatMonthDay(d);
94
+ }
95
+
96
+ export function rowOf(c: Chatroom): InboxRow {
97
+ const fallback = copyParam("ownerInbox.untitled", "Conversation");
98
+ const display = (c.displayData?.name || c.lastMessage?.senderDisplayData?.name || "").trim() || fallback;
99
+ const image = c.displayData?.image?.url || c.lastMessage?.senderDisplayData?.image?.url || "";
100
+ const unread = Math.max(0, c.unreadCount ?? 0);
101
+ const unreadText = unread > 0 ? (unread > 99 ? "99+" : String(unread)) : "";
102
+ const subtitle = (c.lastMessage?.summary || "").trim().slice(0, 120);
103
+ const unreadLabel = unread === 0 ? "" : unread === 1
104
+ ? copyParam("ownerInbox.oneUnread", "1 unread message")
105
+ : `${unreadText} ${copyParam("ownerInbox.manyUnread", "unread messages")}`;
106
+ return {
107
+ id: c.id,
108
+ kind: "chatroom",
109
+ display,
110
+ subtitle,
111
+ initial: (display[0] || "#").toUpperCase(),
112
+ image,
113
+ hasImage: !!image,
114
+ noImage: !image,
115
+ unread,
116
+ unreadText,
117
+ unreadHidden: unread === 0,
118
+ timeText: relTime(c.lastMessage?.createdAt),
119
+ spokenLabel: [display, unreadLabel, subtitle].filter(Boolean).join(", "),
120
+ };
121
+ }
122
+
123
+ function narrow(all: InboxRow[], filter: InboxState["filter"], query: string): InboxRow[] {
124
+ const rows = filter === "unread" ? all.filter((r) => r.unread > 0) : all;
125
+ const q = query.trim().toLowerCase();
126
+ return q ? rows.filter((r) => r.display.toLowerCase().includes(q) || r.subtitle.toLowerCase().includes(q)) : rows;
127
+ }
128
+
129
+ /** Every derived field, from the real inputs only — never inherited from a spread. */
130
+ export function shape(input: {
131
+ status: InboxState["status"];
132
+ all?: InboxRow[];
133
+ filter?: InboxState["filter"];
134
+ query?: string;
135
+ error?: string;
136
+ actionError?: string;
137
+ phase?: InboxState["phase"];
138
+ }): InboxState {
139
+ const { status } = input;
140
+ const all = input.all ?? [];
141
+ const filter = input.filter ?? "all";
142
+ const query = input.query ?? "";
143
+ const items = narrow(all, filter, query);
144
+ const ready = status === "ready";
145
+ const empty = ready && items.length === 0;
146
+ const searching = query.trim() !== "";
147
+ const unreadCount = all.filter((r) => r.unread > 0).length;
148
+ return {
149
+ status,
150
+ phase: input.phase ?? (status === "loading" ? "loading" : "ready"),
151
+ all,
152
+ items,
153
+ filter,
154
+ query,
155
+ error: input.error ?? "",
156
+ actionError: input.actionError ?? "",
157
+ unreadCount,
158
+ summary: !ready || all.length === 0 ? "" : unreadCount === 0
159
+ ? copyParam("ownerInbox.allRead", "All read")
160
+ : unreadCount === 1
161
+ ? copyParam("ownerInbox.oneUnreadConversation", "1 unread conversation")
162
+ : `${unreadCount} ${copyParam("ownerInbox.manyUnreadConversations", "unread conversations")}`,
163
+ notErrored: status !== "error",
164
+ hideList: !ready || items.length === 0,
165
+ hideEmptyNoMatch: !(empty && searching),
166
+ hideEmptyUnread: !(empty && !searching && filter === "unread"),
167
+ hideEmptyAll: !(empty && !searching && filter === "all"),
168
+ };
169
+ }
170
+
171
+ const INITIAL = shape({ status: "loading" });
172
+
173
+ function idOf(arg: unknown): string {
174
+ if (typeof arg === "string") return arg;
175
+ const id = (arg as { id?: unknown } | null | undefined)?.id;
176
+ return typeof id === "string" ? id : "";
177
+ }
178
+
179
+ function textOf(arg: unknown): string {
180
+ if (typeof arg === "string") return arg;
181
+ const v = (arg as { value?: unknown; text?: unknown } | null | undefined);
182
+ return typeof v?.value === "string" ? v.value : typeof v?.text === "string" ? v.text : "";
183
+ }
184
+
185
+ /** The filter a SegmentedControl picked: every renderer injects `selectedId`
186
+ * (the SDUI action-payload contract); a bare value is accepted too. */
187
+ function filterOf(arg: unknown): InboxState["filter"] {
188
+ const picked = (arg as { selectedId?: unknown } | null | undefined)?.selectedId;
189
+ const v = (typeof picked === "string" ? picked : textOf(arg)).toLowerCase();
190
+ return v.includes("unread") ? "unread" : "all";
191
+ }
192
+
193
+ /** The live service state — what an action that awaited must build on (see `read`). A
194
+ * typed function rather than `svc.getState()` inline, so `svc`'s own initializer does not
195
+ * reference it. */
196
+ function liveState(): InboxState {
197
+ return svc.getState();
198
+ }
199
+
200
+ /** Archives Wix has confirmed, with the read generation each was confirmed at. A read
201
+ * that LEFT before the archive landed may still list the room; only a read that started
202
+ * after it can be trusted to say whether the room is gone. */
203
+ const archivedAt = new Map<string, number>();
204
+ /** Mark-unreads Wix has confirmed, fenced the same way: a read that left before the write
205
+ * landed may still report the room read, and must not clear the badge the owner just set.
206
+ * A read that started after the write may still lag it, so the fence also holds for a
207
+ * short grace after confirmation — time-bound, not read-counted, so a conversation the
208
+ * owner then opens and reads loses its badge on the next read after the grace. */
209
+ const unreadAt = new Map<string, { at: number; until: number }>();
210
+ const UNREAD_GRACE_MS = 3000;
211
+ let readGeneration = 0;
212
+ /** The newest read whose answer reached the screen. Reads are not ordered — a `load` on
213
+ * return can start a read while a poll tick's is still in flight — so an OLDER read that
214
+ * finishes after a newer one is dropped instead of painting the past over it. */
215
+ let appliedGeneration = 0;
216
+
217
+ /**
218
+ * Read the list and put it onto the LIVE state. The runtime replaces the whole service
219
+ * state with what an action returns, so a read that resolves after the owner searched,
220
+ * filtered or archived must take the filter, query and archives from now — not from the
221
+ * state it started with — or it would quietly undo them.
222
+ */
223
+ async function read(): Promise<InboxState> {
224
+ const generation = ++readGeneration;
225
+ try {
226
+ const rooms = await listChatrooms(PAGE_SIZE);
227
+ // Stale first: a read older than one already painted changes nothing — not the screen,
228
+ // and not the fences either.
229
+ if (generation < appliedGeneration) return liveState();
230
+ appliedGeneration = generation;
231
+ for (const [id, at] of archivedAt) if (at < generation && !rooms.some((c) => c?.id === id)) archivedAt.delete(id);
232
+ const clock = Date.now();
233
+ for (const [id, fence] of unreadAt) if (fence.at < generation && clock >= fence.until) unreadAt.delete(id);
234
+ const all = rooms
235
+ .filter((c) => c?.id && !c.isArchived && !archivedAt.has(c.id))
236
+ .map((c) => rowOf(unreadAt.has(c.id) ? { ...c, unreadCount: Math.max(1, c.unreadCount ?? 0) } : c));
237
+ const now = liveState();
238
+ return shape({ status: "ready", all, filter: now.filter, query: now.query });
239
+ } catch (e) {
240
+ // A failed mint or list is not an empty Inbox. Rows already on screen are dropped
241
+ // too: they would claim a state the owner can no longer confirm. An older read failing
242
+ // after a newer one succeeded says nothing about now, so it is dropped.
243
+ if (generation < appliedGeneration) return liveState();
244
+ appliedGeneration = generation;
245
+ const now = liveState();
246
+ return shape({ status: "error", filter: now.filter, query: now.query, error: ownerLoadError(e), phase: "ready" });
247
+ }
248
+ }
249
+
250
+ const poll: PollDirective = { id: "inbox-list", intervalMs: LIST_POLL_MS, action: `${SERVICE}.refresh`, immediate: false };
251
+
252
+ const svc = defineService<InboxState, {
253
+ load: (s: InboxState, arg?: unknown) => Promise<InboxState>;
254
+ refresh: (s: InboxState) => Promise<InboxState>;
255
+ filter: (s: InboxState, arg?: unknown) => InboxState;
256
+ search: (s: InboxState, arg?: unknown) => InboxState;
257
+ archive: (s: InboxState, arg?: unknown) => Promise<InboxState>;
258
+ markUnread: (s: InboxState, arg?: unknown) => Promise<InboxState>;
259
+ }>(SERVICE, {
260
+ state: INITIAL,
261
+ actions: {
262
+ // onAppear — every appearance, so returning from a thread re-reads the unread state.
263
+ load: async (_s, arg) => {
264
+ stashCopy(arg);
265
+ return { ...(await read()), _poll: poll };
266
+ },
267
+ // The poll tick, and Retry's re-read. A transient failure during a tick still shows
268
+ // the error: a list that silently stops updating is the worse lie.
269
+ refresh: async () => read(),
270
+ filter: (s, arg) => shape({ status: s.status, all: s.all, filter: filterOf(arg), query: s.query, error: s.error, phase: s.phase }),
271
+ search: (s, arg) => shape({ status: s.status, all: s.all, filter: s.filter, query: textOf(arg), error: s.error, phase: s.phase }),
272
+ // Swipe → Archive (reversible). The row leaves only once Wix confirms it.
273
+ archive: async (s, arg) => {
274
+ const id = idOf(arg);
275
+ if (!id) return s;
276
+ try {
277
+ await archiveChatroom(id, true);
278
+ } catch (e) {
279
+ return { ...liveState(), actionError: ownerWriteError(e) };
280
+ }
281
+ archivedAt.set(id, readGeneration);
282
+ const now = liveState();
283
+ return shape({ status: now.status, all: now.all.filter((r) => r.id !== id), filter: now.filter, query: now.query, error: now.error, phase: now.phase });
284
+ },
285
+ // Swipe → Mark as unread. The badge shows at least 1 once Wix confirms it.
286
+ markUnread: async (s, arg) => {
287
+ const id = idOf(arg);
288
+ if (!id) return s;
289
+ try {
290
+ await markChatroomUnread(id);
291
+ } catch (e) {
292
+ return { ...liveState(), actionError: ownerWriteError(e) };
293
+ }
294
+ unreadAt.set(id, { at: readGeneration, until: Date.now() + UNREAD_GRACE_MS });
295
+ s = liveState();
296
+ const all = s.all.map((r) => (r.id === id ? rowOf({
297
+ id: r.id,
298
+ unreadCount: Math.max(1, r.unread),
299
+ displayData: { name: r.display, image: { url: r.image } },
300
+ lastMessage: { summary: r.subtitle },
301
+ }) : r)).map((r, i) => (r.id === id ? { ...r, timeText: s.all[i].timeText } : r));
302
+ return shape({ status: s.status, all, filter: s.filter, query: s.query, error: s.error, phase: s.phase });
303
+ },
304
+ },
305
+ });
306
+
307
+ svc.load();
308
+ export { svc };