@wix/himalaya-cli 0.1570.0 → 0.1571.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/ts/src/define-app.d.ts +8 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/auth-providers/wix/src/index.d.ts +7 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/blog-v3/src/extensions.d.ts +6 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/auth.mjs +17 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core/validation.mjs +17 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +17 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +17 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/state.mjs +17 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-auth.mjs +7 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-blog.mjs +1 -0
- package/dist/cli.mjs +1672 -1206
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/base44-app-to-himi-app.md +1 -1
- package/guides/capabilities.md +7 -0
- package/guides/himi-json-output.md +11 -0
- package/guides/one-app-to-himi-app.md +122 -4
- package/guides/sdk-reference.json +2 -2
- package/guides/splash-screens.md +10 -1
- package/guides/trim-a-template.md +5 -0
- package/guides/wix-business-context.md +11 -2
- package/package.json +2 -2
- package/templates/beauty-salon/config.ts +1 -1
- package/templates/beauty-salon/dev/index.ts +31 -1
- package/templates/beauty-salon/tier5-src/_shared/member-read.ts +52 -0
- package/templates/beauty-salon/tier5-src/_shared/program-description.ts +11 -0
- package/templates/beauty-salon/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +6 -9
- package/templates/beauty-salon-owner/config.ts +1 -1
- package/templates/branded-lite/config.ts +1 -1
- package/templates/branded-lite/dev/index.ts +163 -2
- package/templates/branded-lite/tier5-src/_shared/member-read.ts +52 -0
- package/templates/branded-lite/tier5-src/_shared/program-description.ts +11 -0
- package/templates/branded-lite/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +16 -14
- package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +3 -7
- package/templates/branded-lite/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +6 -9
- package/templates/branded-lite/tier5-src/wix-spaces-chat-thread/index.ts +101 -34
- package/templates/branded-lite/tier5-src/wix-spaces-forum-post/index.ts +23 -6
- package/templates/branded-lite/tier5-src/wix-spaces-member-detail/index.ts +26 -11
- package/templates/community-club/config.ts +1 -1
- package/templates/community-club/dev/index.ts +65 -2
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/community-club/tier5-src/_shared/member-read.ts +52 -0
- package/templates/community-club/tier5-src/_shared/program-description.ts +11 -0
- package/templates/community-club/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/community-club/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/community-club-owner/config.ts +1 -1
- package/templates/events-meetups/config.ts +1 -1
- package/templates/events-meetups/dev/index.ts +65 -2
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/events-meetups/tier5-src/_shared/member-read.ts +52 -0
- package/templates/events-meetups/tier5-src/_shared/program-description.ts +11 -0
- package/templates/events-meetups/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/events-meetups/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/events-meetups-owner/config.ts +1 -1
- package/templates/gym-fitness/config.ts +1 -1
- package/templates/gym-fitness/dev/index.ts +65 -2
- package/templates/gym-fitness/tier5-src/_shared/member-read.ts +52 -0
- package/templates/gym-fitness/tier5-src/_shared/program-description.ts +11 -0
- package/templates/gym-fitness/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/gym-fitness/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +6 -9
- package/templates/gym-fitness-owner/config.ts +1 -1
- package/templates/nonprofit-cause/config.ts +1 -1
- package/templates/nonprofit-cause/dev/index.ts +65 -2
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/nonprofit-cause/tier5-src/_shared/member-read.ts +52 -0
- package/templates/nonprofit-cause/tier5-src/_shared/program-description.ts +11 -0
- package/templates/nonprofit-cause/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/nonprofit-cause/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/nonprofit-cause-owner/config.ts +1 -1
- package/templates/owner-lite/config.ts +1 -1
- package/templates/podcast-creator/config.ts +1 -1
- package/templates/podcast-creator/dev/index.ts +65 -2
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/podcast-creator/tier5-src/_shared/member-read.ts +52 -0
- package/templates/podcast-creator/tier5-src/_shared/program-description.ts +11 -0
- package/templates/podcast-creator/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/podcast-creator/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/podcast-creator-owner/config.ts +1 -1
- package/templates/portfolio-creative/config.ts +1 -1
- package/templates/portfolio-creative/dev/index.ts +65 -2
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/portfolio-creative/tier5-src/_shared/member-read.ts +52 -0
- package/templates/portfolio-creative/tier5-src/_shared/program-description.ts +11 -0
- package/templates/portfolio-creative/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/portfolio-creative/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +6 -9
- package/templates/portfolio-creative-owner/config.ts +1 -1
- package/templates/restaurant/config.ts +1 -1
- package/templates/restaurant/dev/index.ts +65 -2
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/restaurant/tier5-src/_shared/member-read.ts +52 -0
- package/templates/restaurant/tier5-src/_shared/program-description.ts +11 -0
- package/templates/restaurant/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/restaurant/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/restaurant-owner/config.ts +1 -1
- package/templates/retail-store/config.ts +1 -1
- package/templates/retail-store/dev/index.ts +31 -1
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/retail-store/tier5-src/_shared/member-read.ts +52 -0
- package/templates/retail-store/tier5-src/_shared/program-description.ts +11 -0
- package/templates/retail-store/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +6 -9
- package/templates/retail-store-owner/config.ts +1 -1
- package/templates/yoga-studio/config.ts +1 -1
- package/templates/yoga-studio/dev/index.ts +93 -2
- package/templates/yoga-studio/tier5-src/_shared/member-read.ts +52 -0
- package/templates/yoga-studio/tier5-src/_shared/program-description.ts +11 -0
- package/templates/yoga-studio/tier5-src/himi-blog-post/index.ts +35 -12
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +3 -3
- package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +16 -14
- package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +3 -7
- package/templates/yoga-studio/tier5-src/himi-events-my-events/index.ts +76 -42
- package/templates/yoga-studio-owner/config.ts +1 -1
|
@@ -5,6 +5,8 @@ import { queryEvents, type WixEvent } from "@himalaya/wix-events";
|
|
|
5
5
|
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
6
6
|
import { confirmedRsvpIds, eventStartMillis, normalizeEvent, type EventRow } from "../_shared/himi-events";
|
|
7
7
|
import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
8
|
+
import { memberAccess } from "../_shared/member-access.js";
|
|
9
|
+
import { readAccess, assertReadAccess, readAccessDenied, readSignInFlags, NO_READ_SIGN_IN } from "../_shared/member-read.js";
|
|
8
10
|
|
|
9
11
|
// Offline-first (data-offline): My Events is a read-through over the same `events`
|
|
10
12
|
// catalog the list caches — paint the last-synced rows (filtered to this device's
|
|
@@ -27,6 +29,7 @@ async function writeCached(rows: CachedEventRow[]): Promise<void> {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
interface S {
|
|
32
|
+
signInRequired: boolean; noSignIn: boolean; signInScreenId: string;
|
|
30
33
|
status: "loading" | "ready";
|
|
31
34
|
events: EventRow[];
|
|
32
35
|
emptyText: string;
|
|
@@ -98,11 +101,10 @@ function unverifiedEvent(event: CachedEventRow): CachedEventRow {
|
|
|
98
101
|
};
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
async function confirmRsvps(candidates: CachedEventRow[]): Promise<{ events: CachedEventRow[]; verificationUnavailable: boolean }> {
|
|
104
|
+
/** Verify device-local candidates against the current member. Member-only mode
|
|
105
|
+
* requires successful verification; standalone mode retains failed probes as
|
|
106
|
+
* explicitly unverified device-local history. */
|
|
107
|
+
async function confirmRsvps(candidates: CachedEventRow[], memberOnly = false): Promise<{ events: CachedEventRow[]; verificationUnavailable: boolean }> {
|
|
106
108
|
// Raw wixApi chokepoint rather than a generated client: the member RSVP read lives in
|
|
107
109
|
// events **v2**, and `@himalaya/wix-events` is the v1 domain module. Adding a
|
|
108
110
|
// `wix-events-v2` specifier is not an option — the umbrella package deliberately
|
|
@@ -122,7 +124,9 @@ async function confirmRsvps(candidates: CachedEventRow[]): Promise<{ events: Cac
|
|
|
122
124
|
event: held ? { ...event, status: [event.status, copyParam("myEvents.rsvpConfirmed", "RSVP confirmed")].filter(Boolean).join(" · ") } : null,
|
|
123
125
|
verificationUnavailable: false,
|
|
124
126
|
};
|
|
125
|
-
} catch {
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// Device candidates carry no member ownership proof until this read succeeds.
|
|
129
|
+
if (memberOnly) throw error;
|
|
126
130
|
// Fail-open, but never claim backend confirmation while its member-readable
|
|
127
131
|
// probe is unavailable. The device id remains a candidate, explicitly labeled.
|
|
128
132
|
return { event: unverifiedEvent(event), verificationUnavailable: true };
|
|
@@ -134,8 +138,8 @@ async function confirmRsvps(candidates: CachedEventRow[]): Promise<{ events: Cac
|
|
|
134
138
|
};
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
const
|
|
138
|
-
|
|
141
|
+
const INITIAL: S = derive({
|
|
142
|
+
...NO_READ_SIGN_IN, signInScreenId: "himi_account",
|
|
139
143
|
status: "loading",
|
|
140
144
|
events: [],
|
|
141
145
|
emptyText: "",
|
|
@@ -147,45 +151,75 @@ const svc = defineService<S, { load: (s: S, arg?: unknown) => Promise<S> }>("him
|
|
|
147
151
|
isLoading: true,
|
|
148
152
|
notLoading: false,
|
|
149
153
|
noEmpty: true,
|
|
150
|
-
})
|
|
154
|
+
});
|
|
155
|
+
let memberOnlyRequested = false;
|
|
156
|
+
let loadVersion = 0;
|
|
157
|
+
const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S | undefined> }>("himiMyEvents", {
|
|
158
|
+
state: INITIAL,
|
|
151
159
|
actions: {
|
|
152
|
-
|
|
160
|
+
prepare: (s) => ({ ...s, ...INITIAL }),
|
|
161
|
+
load: async (s, arg): Promise<S | undefined> => {
|
|
153
162
|
stashCopy(arg);
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
? derive({ ...s, status: "ready", errored: false, events: cachedEvents, identityLine: identity, verificationUnavailable: cachedEvents.length > 0 })
|
|
161
|
-
: s;
|
|
162
|
-
// 2) Revalidate from Wix and refresh the cache.
|
|
163
|
+
const version = ++loadVersion;
|
|
164
|
+
const options = arg as { memberOnly?: unknown; signInScreenId?: unknown } | undefined;
|
|
165
|
+
if (options?.memberOnly !== undefined) memberOnlyRequested = options.memberOnly === "1";
|
|
166
|
+
const memberOnly = memberOnlyRequested;
|
|
167
|
+
const clean = { ...INITIAL, signInScreenId: options?.signInScreenId === "himi_sign_in" ? "himi_sign_in" : s.signInScreenId };
|
|
168
|
+
await svc.prepare();
|
|
163
169
|
try {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
170
|
+
const member = memberOnly ? await readAccess() : null;
|
|
171
|
+
if (memberOnly && !member?.member.email) throw new Error("Unauthenticated: sign in to see your events");
|
|
172
|
+
const confirmed = new Set(confirmedRsvpIds());
|
|
173
|
+
const identity = await identityLine();
|
|
174
|
+
// 1) Instant paint from the on-device cache (offline-safe; empty on first-ever run).
|
|
175
|
+
// Device-local candidates have no member ownership proof. Branded must
|
|
176
|
+
// verify them online rather than show a previous member's cached RSVP list.
|
|
177
|
+
const cached = memberOnly ? [] : await readCached();
|
|
178
|
+
const cachedEvents = cached.filter((event) => confirmed.has(event.id)).map(displayEventRow);
|
|
179
|
+
const base = cached.length
|
|
180
|
+
? derive({ ...clean, status: "ready", errored: false, events: cachedEvents, identityLine: identity, verificationUnavailable: cachedEvents.length > 0 })
|
|
181
|
+
: clean;
|
|
182
|
+
// 2) Revalidate from Wix and refresh the cache.
|
|
183
|
+
try {
|
|
184
|
+
const res = await queryEvents({ limit: 50 });
|
|
185
|
+
const all = ((res as unknown as { events?: WixEvent[] }).events ?? []).map(cachedEventRow);
|
|
186
|
+
if (member) await assertReadAccess(member);
|
|
187
|
+
if (version !== loadVersion) return undefined;
|
|
188
|
+
await writeCached(all);
|
|
189
|
+
// Phase 3c — make each row AUTHORITATIVE. The device list is only a candidate
|
|
190
|
+
// set: it never forgets, so an RSVP cancelled elsewhere (web, or by the
|
|
191
|
+
// business) would otherwise show here forever. Confirm each candidate against
|
|
192
|
+
// Wix and drop the ones the member no longer holds.
|
|
193
|
+
//
|
|
194
|
+
// Per-event by necessity, not by choice: a member CANNOT enumerate their own
|
|
195
|
+
// RSVPs. Verified live against a real member token — `queryRsvps` (cross-event)
|
|
196
|
+
// 403s "WIX_EVENTS.READ_RSVP permission required", `listOrders` 403s
|
|
197
|
+
// READ_ORDERS, and `/rsvps/current` 400s without an eventId. Only the
|
|
198
|
+
// single-event read is member-readable, so the fan-out below IS the ceiling
|
|
199
|
+
// until those member scopes exist server-side.
|
|
200
|
+
const candidates = all.filter((event) => confirmed.has(event.id)).map(displayEventRow);
|
|
201
|
+
const confirmation = await confirmRsvps(candidates, memberOnly);
|
|
202
|
+
if (member) await assertReadAccess(member);
|
|
203
|
+
if (version !== loadVersion) return undefined;
|
|
204
|
+
return derive({ ...base, status: "ready", errored: false, events: confirmation.events, identityLine: identity, verificationUnavailable: confirmation.verificationUnavailable });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (memberOnly && readAccessDenied(error)) throw error;
|
|
207
|
+
if (member) await assertReadAccess(member);
|
|
208
|
+
if (version !== loadVersion) return undefined;
|
|
209
|
+
// Offline / fetch failure: keep the cached rows instead of a blank error state.
|
|
210
|
+
return cached.length
|
|
211
|
+
? base
|
|
212
|
+
: derive({ ...clean, status: "ready", errored: true, events: [], identityLine: identity });
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const failure = memberOnly && !(await memberAccess()).email ? new Error("Unauthenticated: sign in to see your events") : error;
|
|
216
|
+
const flags = await readSignInFlags(failure);
|
|
217
|
+
return version === loadVersion ? derive({ ...clean, ...flags, status: "ready", errored: true, events: [] }) : undefined;
|
|
186
218
|
}
|
|
187
219
|
},
|
|
188
220
|
},
|
|
189
221
|
});
|
|
190
|
-
|
|
222
|
+
// Wait for the descriptor's load action to select member-only or device-local scope.
|
|
223
|
+
// An eager unconfigured read could briefly expose guest KV before Branded opts in.
|
|
224
|
+
svc.prepare();
|
|
191
225
|
export { svc };
|
|
@@ -19,7 +19,7 @@ import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
|
19
19
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
20
20
|
import { isProductSaved, toggleSavedProduct } from "../_shared/himi-store-saved.js";
|
|
21
21
|
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
22
|
-
import {
|
|
22
|
+
import { descriptionBody } from "../_shared/rich-body.js";
|
|
23
23
|
|
|
24
24
|
/** Live cart size (sum of line-item quantities) for the top-bar cart badge.
|
|
25
25
|
* Best-effort: a cart error just yields 0 (badge hidden), never fails the screen. */
|
|
@@ -74,9 +74,7 @@ function productPolicyBody(product: StoreProduct, kind: "returns" | "shipping"):
|
|
|
74
74
|
});
|
|
75
75
|
if (!section) return "";
|
|
76
76
|
const html = "plainDescription" in section ? section.plainDescription : undefined;
|
|
77
|
-
|
|
78
|
-
if (typeof section.description === "string") return plainText(section.description.replace(/ /gi, " "));
|
|
79
|
-
return richBody(section.description).bodyText;
|
|
77
|
+
return descriptionBody(html || section.description).bodyText;
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
/** The product id `lastProduct` was read for. A pooled screen re-appearing calls
|
|
@@ -140,13 +138,12 @@ function optionsHint(groups: StoreOption[], selection: StoreOptionSelection): st
|
|
|
140
138
|
return copyTemplate("product.optionsChoose", "Choose {options}", { options: missing.join(", ") });
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
/**
|
|
144
|
-
*
|
|
145
|
-
* `description` is an HTML string: stripped text, no document. */
|
|
141
|
+
/** Catalog V3 returns Ricos objects or serialized documents; V1 returns HTML.
|
|
142
|
+
* Keep plainDescription as the independent fallback, never raw document JSON. */
|
|
146
143
|
function productBody(p: StoreProduct): { description: string; descriptionJson: string } {
|
|
147
144
|
const raw = (p as { description?: unknown }).description;
|
|
148
|
-
const html =
|
|
149
|
-
const { bodyJson, bodyText } =
|
|
145
|
+
const html = descriptionBody(p.plainDescription).bodyText;
|
|
146
|
+
const { bodyJson, bodyText } = descriptionBody(raw, html);
|
|
150
147
|
return { description: html || bodyText, descriptionJson: bodyJson };
|
|
151
148
|
}
|
|
152
149
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// `chatRealtime` channel, else a 4 s foreground `_poll` of `refresh`. A message from the
|
|
10
10
|
// business then appears without the member leaving the screen. `load`, `setDraft` and
|
|
11
11
|
// `send` keep their contract; the directives are stripped by the runtime, never state.
|
|
12
|
-
import { defineService, type PollDirective, type StreamDirective } from "@himalaya/state";
|
|
12
|
+
import { defineService, type PollDirective, type PollStopDirective, type StreamDirective } from "@himalaya/state";
|
|
13
13
|
import { getMessages, sendChatMessage, getOrCreateDirectChatroom, getChatToken, type ChatMessage } from "@himalaya/wix-chat-web";
|
|
14
14
|
import { getMyData } from "@himalaya/wix-users";
|
|
15
15
|
import { formatMonthDay, formatMonthDayTime } from "../_shared/engine-intl.js";
|
|
@@ -17,7 +17,10 @@ import { initials } from "../_shared/himi-text.js";
|
|
|
17
17
|
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
18
18
|
import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
|
|
19
19
|
import { writeMayHaveLanded } from "../_shared/owner-load-error.js";
|
|
20
|
-
import { chatRealtimeConfig, createReloadGate, createSendIntents, createVisitToken, liveDirectives, postWithVisitToken, liveFallback, type ChatLiveMode, type ChatRealtimeConfig } from "../_shared/chat-live.js";
|
|
20
|
+
import { CHAT_LIVE_POLL_ID, chatRealtimeConfig, createReloadGate, createSendIntents, createVisitToken, liveDirectives, postWithVisitToken, isTokenRejected, liveFallback, type ChatLiveMode, type ChatRealtimeConfig } from "../_shared/chat-live.js";
|
|
21
|
+
|
|
22
|
+
import { memberAccess, memberAccessChanged } from "../_shared/member-access.js";
|
|
23
|
+
import { readAccess, assertReadAccess, readAccessDenied, NO_READ_SIGN_IN, type ReadAccess } from "../_shared/member-read.js";
|
|
21
24
|
|
|
22
25
|
const SERVICE = "spacesChatThread";
|
|
23
26
|
const EMPTY_TEXT = "No messages yet. Say hello!";
|
|
@@ -31,13 +34,14 @@ interface Row {
|
|
|
31
34
|
imageUrl: string; imageDescription: string; imageHidden: boolean; dayLabel: string; dayHidden: boolean;
|
|
32
35
|
}
|
|
33
36
|
interface S extends PresenceFlags {
|
|
37
|
+
threadMember: ReadAccess | null; signInRequired: boolean; noSignIn: boolean;
|
|
34
38
|
status: "loading" | "ready"; messages: Row[]; emptyText: string; emptyHidden: boolean; draft: string; sendHint: string; resolvedChatroomId: string; myUserId: string;
|
|
35
39
|
/** Send token for the host's transcript scroll: a new value follows the tail. */
|
|
36
40
|
scrollSend: string;
|
|
37
41
|
/** Which path keeps the open thread live (diagnostic). */
|
|
38
42
|
live?: ChatLiveMode;
|
|
39
43
|
_stream?: StreamDirective;
|
|
40
|
-
_poll?: PollDirective;
|
|
44
|
+
_poll?: PollDirective | PollStopDirective;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
function inputId(): string {
|
|
@@ -125,15 +129,21 @@ function nextScrollToken(prev: string): string {
|
|
|
125
129
|
// mint a token every 4 s on top of its GET. Reset on every `load`.
|
|
126
130
|
const memberToken = createVisitToken(() => getChatToken({ role: "member" }).then((t) => t.chatToken));
|
|
127
131
|
|
|
128
|
-
async function reload(id: string, myUserId: string): Promise<Row[]> {
|
|
132
|
+
async function reload(id: string, myUserId: string, member: ReadAccess): Promise<Row[]> {
|
|
129
133
|
if (!id) return [];
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
for (let attempt = 0; ; attempt++) {
|
|
135
|
+
const token = memberToken.get();
|
|
136
|
+
try {
|
|
137
|
+
const chatToken = await token;
|
|
138
|
+
await assertReadAccess(member);
|
|
139
|
+
const msgs = await getMessages(id, 30, { role: "member", chatToken });
|
|
140
|
+
return rowsOf(msgs ?? [], myUserId);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
memberToken.drop(token);
|
|
143
|
+
// An expired visit token gets one fresh attempt. A repeated refusal still
|
|
144
|
+
// clears private history; checking identity before each read prevents stale retries.
|
|
145
|
+
if (attempt > 0 || !isTokenRejected(error)) throw error;
|
|
146
|
+
}
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
@@ -142,9 +152,13 @@ const intents = createSendIntents();
|
|
|
142
152
|
|
|
143
153
|
/** Post once per intent: a retried tap of the same text reuses its message id, so a send
|
|
144
154
|
* that failed ambiguously and is tapped again stays one message. */
|
|
145
|
-
async function postOnce(chatroomId: string, text: string): Promise<void> {
|
|
155
|
+
async function postOnce(chatroomId: string, text: string, member: ReadAccess): Promise<void> {
|
|
146
156
|
const messageId = intents.idFor(chatroomId, text);
|
|
147
|
-
await postWithVisitToken(memberToken, (chatToken) =>
|
|
157
|
+
await postWithVisitToken(memberToken, async (chatToken) => {
|
|
158
|
+
await assertReadAccess(member);
|
|
159
|
+
if (liveState().threadMember !== member) throw memberAccessChanged();
|
|
160
|
+
return sendChatMessage(chatroomId, text, { role: "member", chatToken, messageId });
|
|
161
|
+
});
|
|
148
162
|
intents.settle(chatroomId, text);
|
|
149
163
|
}
|
|
150
164
|
|
|
@@ -155,7 +169,7 @@ let sendingNow = false;
|
|
|
155
169
|
// The conversation a live re-read reads, and the channel the screen passed (if any).
|
|
156
170
|
let current = { chatroomId: "", myUserId: "" };
|
|
157
171
|
let realtime: ChatRealtimeConfig | null = null;
|
|
158
|
-
const gate = createReloadGate(() => reload(current.chatroomId, current.myUserId));
|
|
172
|
+
const gate = createReloadGate(() => reload(current.chatroomId, current.myUserId, liveState().threadMember!));
|
|
159
173
|
|
|
160
174
|
/** A tick or a stream event: re-read the open conversation. A failed tick keeps the
|
|
161
175
|
* thread as it is — the next one tries again.
|
|
@@ -167,12 +181,16 @@ const gate = createReloadGate(() => reload(current.chatroomId, current.myUserId)
|
|
|
167
181
|
async function reread(s: S): Promise<S> {
|
|
168
182
|
if (!s.found || !s.resolvedChatroomId || s.resolvedChatroomId !== current.chatroomId) return s;
|
|
169
183
|
try {
|
|
184
|
+
await assertThreadMember(s);
|
|
170
185
|
const messages = await gate.request();
|
|
186
|
+
await assertThreadMember(s);
|
|
171
187
|
const now = svc.getState();
|
|
172
|
-
if (now.resolvedChatroomId !== s.resolvedChatroomId) return now;
|
|
188
|
+
if (now.threadMember !== s.threadMember || now.resolvedChatroomId !== s.resolvedChatroomId) return now;
|
|
173
189
|
return { ...now, messages, emptyText: messages.length ? "" : now.emptyText || EMPTY_TEXT, emptyHidden: messages.length > 0 };
|
|
174
|
-
} catch {
|
|
175
|
-
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const now = svc.getState();
|
|
192
|
+
if (now.threadMember !== s.threadMember) return now;
|
|
193
|
+
return readAccessDenied(error) ? threadFailure(error) : now;
|
|
176
194
|
}
|
|
177
195
|
}
|
|
178
196
|
|
|
@@ -187,40 +205,71 @@ async function resolveMyUserId(): Promise<string> {
|
|
|
187
205
|
try { return (await getMyData()).userId || ""; } catch { return ""; }
|
|
188
206
|
}
|
|
189
207
|
|
|
208
|
+
const INITIAL: S = { status: "loading", messages: [], emptyText: "", emptyHidden: true, scrollSend: "", draft: "", sendHint: "", resolvedChatroomId: "", myUserId: "", ...presence("loading"), ...NO_READ_SIGN_IN, threadMember: null };
|
|
209
|
+
let loadVersion = 0;
|
|
210
|
+
|
|
211
|
+
async function threadFailure(error: unknown): Promise<S> {
|
|
212
|
+
const version = loadVersion;
|
|
213
|
+
const member = await memberAccess();
|
|
214
|
+
const flags = member.email ? NO_READ_SIGN_IN : { signInRequired: true, noSignIn: false };
|
|
215
|
+
if (version !== loadVersion) return liveState();
|
|
216
|
+
current = { chatroomId: "", myUserId: "" };
|
|
217
|
+
memberToken.reset();
|
|
218
|
+
return { ...INITIAL, status: "ready", ...presenceAfterError(error), ...flags, live: "off", _poll: { id: CHAT_LIVE_POLL_ID, stop: true } };
|
|
219
|
+
}
|
|
220
|
+
async function assertThreadMember(s: S): Promise<void> {
|
|
221
|
+
if (!s.threadMember?.member?.email) throw new Error("Unauthenticated: sign in to read messages");
|
|
222
|
+
await assertReadAccess(s.threadMember);
|
|
223
|
+
}
|
|
224
|
+
|
|
190
225
|
const svc = defineService<S, {
|
|
191
|
-
|
|
226
|
+
prepare: (s: S) => S;
|
|
227
|
+
load: (s: S, a?: unknown) => Promise<S | undefined>;
|
|
192
228
|
setDraft: (s: S, a?: unknown) => S;
|
|
193
229
|
send: (s: S) => Promise<S>;
|
|
194
230
|
refresh: (s: S) => Promise<S>;
|
|
195
231
|
onStreamEvent: (s: S) => Promise<S>;
|
|
196
232
|
onStreamError: (s: S) => S;
|
|
197
233
|
}>(SERVICE, {
|
|
198
|
-
state:
|
|
234
|
+
state: INITIAL,
|
|
199
235
|
actions: {
|
|
200
|
-
|
|
236
|
+
prepare: (s) => ({ ...s, ...INITIAL, _poll: { id: CHAT_LIVE_POLL_ID, stop: true } }),
|
|
237
|
+
load: async (s, a): Promise<S | undefined> => {
|
|
238
|
+
const version = ++loadVersion;
|
|
239
|
+
await svc.prepare();
|
|
240
|
+
current = { chatroomId: "", myUserId: "" };
|
|
201
241
|
stashCopy(a);
|
|
202
242
|
const param = (a as { chatRealtime?: unknown } | null | undefined)?.chatRealtime;
|
|
203
243
|
if (param !== undefined) realtime = chatRealtimeConfig(param);
|
|
204
244
|
const raw = inputId();
|
|
205
245
|
// No thread / recipient id (a stale link, a route opened directly) -> the shared
|
|
206
246
|
// not-found state, and no composer: there is nobody to send to.
|
|
207
|
-
if (!detailInputId()) return { ...
|
|
247
|
+
if (!detailInputId()) return { ...INITIAL, status: "ready", messages: [], emptyText: "", emptyHidden: true, ...presence("missing") };
|
|
208
248
|
memberToken.reset();
|
|
209
|
-
// Resolve chatroom + my user id concurrently (myUserId tags own messages).
|
|
210
|
-
const [chatroomId, myUserId] = await Promise.all([resolveChatroomId(raw, inputKind()), resolveMyUserId()]);
|
|
211
|
-
// A recipient whose 1:1 chatroom could not be resolved is not a conversation yet:
|
|
212
|
-
// reload("") would return [] without throwing and publish a live, empty thread
|
|
213
|
-
// whose Send can only fail. Show the load-error line instead.
|
|
214
|
-
if (!chatroomId) return { ...s, status: "ready", resolvedChatroomId: "", myUserId, messages: [], emptyText: "", emptyHidden: true, ...presence("failed") };
|
|
215
249
|
try {
|
|
216
|
-
const
|
|
250
|
+
const member = await readAccess();
|
|
251
|
+
if (!member.member.email) throw new Error("Unauthenticated: sign in to read messages");
|
|
252
|
+
// Resolve chatroom + my user id concurrently (myUserId tags own messages).
|
|
253
|
+
const [chatroomId, myUserId] = await Promise.all([resolveChatroomId(raw, inputKind()), resolveMyUserId()]);
|
|
254
|
+
// A recipient whose 1:1 chatroom could not be resolved is not a conversation yet:
|
|
255
|
+
// reload("") would return [] without throwing and publish a live, empty thread
|
|
256
|
+
// whose Send can only fail. Show the load-error line instead.
|
|
257
|
+
if (!chatroomId) return version === loadVersion ? { ...INITIAL, status: "ready", ...presence("failed") } : undefined;
|
|
258
|
+
const messages = await reload(chatroomId, myUserId, member);
|
|
259
|
+
await assertReadAccess(member);
|
|
260
|
+
if (version !== loadVersion) return undefined;
|
|
217
261
|
current = { chatroomId, myUserId };
|
|
218
|
-
|
|
262
|
+
const previous = s.threadMember;
|
|
263
|
+
const sameReader = previous?.member.email === member.member.email && previous?.member.scope === member.member.scope && previous?.tenant === member.tenant;
|
|
264
|
+
const draft = sameReader && s.resolvedChatroomId === chatroomId ? s.draft : "";
|
|
265
|
+
return { ...INITIAL, draft, threadMember: member, status: "ready", resolvedChatroomId: chatroomId, myUserId, messages, emptyText: messages.length ? "" : EMPTY_TEXT, emptyHidden: messages.length > 0, scrollSend: messages.length ? nextScrollToken(s.scrollSend) : "", ...presence("found"), ...liveDirectives(realtime, chatroomId, SERVICE) };
|
|
219
266
|
} catch (e) {
|
|
220
267
|
// A thread whose messages cannot be read is not a live, empty conversation: a 404
|
|
221
268
|
// is the shared not-found state, anything else the load-error line — never a
|
|
222
269
|
// "Say hello!" composer that can only fail.
|
|
223
|
-
|
|
270
|
+
if (version !== loadVersion) return undefined;
|
|
271
|
+
const failed = await threadFailure(e);
|
|
272
|
+
return version === loadVersion ? failed : undefined;
|
|
224
273
|
}
|
|
225
274
|
},
|
|
226
275
|
setDraft: (s, a) => ({ ...s, draft: val(a) }),
|
|
@@ -234,9 +283,14 @@ const svc = defineService<S, {
|
|
|
234
283
|
// Every return builds on the LIVE state: the runtime replaces state wholesale, and ticks
|
|
235
284
|
// or typing may have changed it while this awaited.
|
|
236
285
|
send: async (s) => {
|
|
286
|
+
if (!s.found) return s;
|
|
237
287
|
const text = s.draft.trim();
|
|
238
|
-
|
|
239
|
-
|
|
288
|
+
try { await assertThreadMember(s); }
|
|
289
|
+
catch (error) { return liveState().threadMember !== s.threadMember ? liveState() : threadFailure(error); }
|
|
290
|
+
if (liveState().threadMember !== s.threadMember) return liveState();
|
|
291
|
+
const checked = liveState();
|
|
292
|
+
if (!text) return checked.draft.trim() ? checked : { ...checked, sendHint: "Type a message first." };
|
|
293
|
+
if (sendingNow) return checked;
|
|
240
294
|
sendingNow = true;
|
|
241
295
|
try {
|
|
242
296
|
// Resolve lazily in case send() runs before load() settled.
|
|
@@ -244,8 +298,11 @@ const svc = defineService<S, {
|
|
|
244
298
|
if (!chatroomId) chatroomId = await resolveChatroomId(inputId(), inputKind());
|
|
245
299
|
if (!chatroomId) return { ...liveState(), sendHint: "Couldn't start conversation." };
|
|
246
300
|
try {
|
|
247
|
-
await postOnce(chatroomId, text);
|
|
301
|
+
await postOnce(chatroomId, text, s.threadMember!);
|
|
248
302
|
} catch (e) {
|
|
303
|
+
if (liveState().threadMember !== s.threadMember) return liveState();
|
|
304
|
+
try { await assertThreadMember(s); }
|
|
305
|
+
catch (error) { return threadFailure(error); }
|
|
249
306
|
// Surface a short reason (the SDK rejects with "HTTP <code>: <body>") so a
|
|
250
307
|
// real failure is diagnosable on-device instead of an opaque "Couldn't send".
|
|
251
308
|
// A failure that may have posted anyway (a gateway timeout after the backend stored
|
|
@@ -255,6 +312,8 @@ const svc = defineService<S, {
|
|
|
255
312
|
if (writeMayHaveLanded(e)) return { ...liveState(), resolvedChatroomId: chatroomId, sendHint: "Couldn't confirm it was sent — check before sending again." };
|
|
256
313
|
return { ...liveState(), resolvedChatroomId: chatroomId, sendHint: msg ? `Couldn't send — ${msg.slice(0, 80)}` : "Couldn't send — try again." };
|
|
257
314
|
}
|
|
315
|
+
await assertThreadMember(s);
|
|
316
|
+
if (liveState().threadMember !== s.threadMember) return liveState();
|
|
258
317
|
// SENT. From here on nothing may say otherwise: a failed re-read must not offer a
|
|
259
318
|
// second Send of a message that already posted.
|
|
260
319
|
const myUserId = s.myUserId || (await resolveMyUserId());
|
|
@@ -264,18 +323,26 @@ const svc = defineService<S, {
|
|
|
264
323
|
const cleared = (now: S): string => (now.draft.trim() === text ? "" : now.draft);
|
|
265
324
|
try {
|
|
266
325
|
const messages = await gate.request();
|
|
326
|
+
await assertThreadMember(s);
|
|
327
|
+
if (liveState().threadMember !== s.threadMember) return liveState();
|
|
267
328
|
const now = liveState();
|
|
268
329
|
return { ...now, resolvedChatroomId: chatroomId, myUserId, messages, draft: cleared(now), emptyText: "", emptyHidden: true, scrollSend: nextScrollToken(now.scrollSend), sendHint: "Sent ✓" };
|
|
269
|
-
} catch {
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (liveState().threadMember !== s.threadMember) return liveState();
|
|
332
|
+
if (readAccessDenied(error)) return threadFailure(error);
|
|
270
333
|
const now = liveState();
|
|
271
334
|
const local: Row = { id: `local-${now.messages.length}`, text, when: "", leadFill: " ", mine: true, notMine: false, senderBadge: "", avatarUrl: "", avatarInitials: "", imageUrl: "", imageDescription: "", imageHidden: true, dayLabel: "", dayHidden: true };
|
|
272
335
|
return { ...now, resolvedChatroomId: chatroomId, myUserId, messages: [...now.messages, local], draft: cleared(now), emptyText: "", emptyHidden: true, scrollSend: nextScrollToken(now.scrollSend), sendHint: "Sent ✓" };
|
|
273
336
|
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (liveState().threadMember !== s.threadMember) return liveState();
|
|
339
|
+
return threadFailure(error);
|
|
274
340
|
} finally {
|
|
275
341
|
sendingNow = false;
|
|
276
342
|
}
|
|
277
343
|
},
|
|
278
344
|
},
|
|
279
345
|
});
|
|
346
|
+
svc.prepare();
|
|
280
347
|
svc.load();
|
|
281
348
|
export { svc };
|
|
@@ -7,7 +7,10 @@ import { presence, presenceAfterError, detailInputId, type PresenceFlags } from
|
|
|
7
7
|
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
8
8
|
import { richBody } from "../_shared/rich-body.js";
|
|
9
9
|
|
|
10
|
+
import { readAccess, assertReadAccess, readSignInFlags, NO_READ_SIGN_IN } from "../_shared/member-read.js";
|
|
11
|
+
|
|
10
12
|
interface S extends PresenceFlags {
|
|
13
|
+
signInRequired: boolean; noSignIn: boolean;
|
|
11
14
|
status: "loading" | "ready"; id: string; title: string; author: string; subtitle: string;
|
|
12
15
|
/** The plain-text body (`contentText`): RichContent's fallback, and what shows when there is no document. */
|
|
13
16
|
body: string;
|
|
@@ -26,8 +29,12 @@ function val(arg?: unknown): string {
|
|
|
26
29
|
return typeof arg === "string" ? arg : ((arg as { value?: string; text?: string })?.value ?? (arg as { text?: string })?.text ?? "");
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
const INITIAL: S = { status: "loading", id: "", title: "", author: "", subtitle: "", body: "", bodyJson: "", hasBodyJson: false, initial: "", likeHint: "", commentText: "", commentHint: "", ...presence("loading"), ...NO_READ_SIGN_IN };
|
|
33
|
+
let loadVersion = 0;
|
|
34
|
+
|
|
29
35
|
const svc = defineService<S, {
|
|
30
|
-
|
|
36
|
+
prepare: (s: S) => S;
|
|
37
|
+
load: (s: S) => Promise<S | undefined>;
|
|
31
38
|
like: (s: S) => Promise<S>;
|
|
32
39
|
unlike: (s: S) => Promise<S>;
|
|
33
40
|
setComment: (s: S, a?: unknown) => S;
|
|
@@ -35,12 +42,16 @@ const svc = defineService<S, {
|
|
|
35
42
|
/** [rico] #3372 — RichContent's on_link: `{ url }` of the tapped link. */
|
|
36
43
|
openLink: (s: S, a?: unknown) => Promise<S>;
|
|
37
44
|
}>("spacesForumPost", {
|
|
38
|
-
state:
|
|
45
|
+
state: INITIAL,
|
|
39
46
|
actions: {
|
|
40
|
-
|
|
47
|
+
prepare: (s) => ({ ...s, ...INITIAL }),
|
|
48
|
+
load: async (s): Promise<S | undefined> => {
|
|
49
|
+
const version = ++loadVersion;
|
|
50
|
+
await svc.prepare();
|
|
41
51
|
const id = detailInputId();
|
|
42
|
-
if (!id) return { ...
|
|
52
|
+
if (!id) return { ...INITIAL, status: "ready", ...presence("missing") };
|
|
43
53
|
try {
|
|
54
|
+
const access = await readAccess();
|
|
44
55
|
const res = await getPost(id);
|
|
45
56
|
const p = ((res as unknown as { post?: WixPost }).post) ?? (res as unknown as WixPost);
|
|
46
57
|
const pp = p as unknown as { title?: string; contentText?: string; content?: unknown; writer?: { name?: string }; commentCount?: number; viewCount?: number };
|
|
@@ -56,10 +67,15 @@ const svc = defineService<S, {
|
|
|
56
67
|
// which is the document's own text when there is no contentText.
|
|
57
68
|
const { bodyJson, bodyText } = richBody(doc, legacy);
|
|
58
69
|
const body = legacy || bodyText || "No content.";
|
|
59
|
-
|
|
70
|
+
await assertReadAccess(access);
|
|
71
|
+
if (version !== loadVersion) return undefined;
|
|
72
|
+
return { ...INITIAL, status: "ready", id, title: pp.title || "Post", author, subtitle: bits.join(" · "),
|
|
60
73
|
body, bodyJson, hasBodyJson: bodyJson !== "", initial: (author[0] || "#").toUpperCase(),
|
|
61
74
|
likeHint: "", commentText: "", commentHint: "", ...presence("found") };
|
|
62
|
-
} catch (e) {
|
|
75
|
+
} catch (e) {
|
|
76
|
+
const flags = await readSignInFlags(e);
|
|
77
|
+
return version === loadVersion ? { ...INITIAL, status: "ready", id, ...presenceAfterError(e), ...flags } : undefined;
|
|
78
|
+
}
|
|
63
79
|
},
|
|
64
80
|
// REAL WRITE: react to / un-react to the post.
|
|
65
81
|
like: async (s) => {
|
|
@@ -85,5 +101,6 @@ const svc = defineService<S, {
|
|
|
85
101
|
openLink: async (s, a) => ((await openRichLink(a)) ? s : { ...s, commentHint: "This link could not be opened." }),
|
|
86
102
|
},
|
|
87
103
|
});
|
|
104
|
+
svc.prepare();
|
|
88
105
|
svc.load();
|
|
89
106
|
export { svc };
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
} from "@himalaya/wix-members";
|
|
15
15
|
import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
|
|
16
16
|
|
|
17
|
+
import { readAccess, assertReadAccess, readSignInFlags, NO_READ_SIGN_IN } from "../_shared/member-read.js";
|
|
18
|
+
|
|
17
19
|
interface S extends PresenceFlags {
|
|
20
|
+
signInRequired: boolean; noSignIn: boolean;
|
|
18
21
|
status: "loading" | "ready";
|
|
19
22
|
id: string;
|
|
20
23
|
name: string;
|
|
@@ -43,11 +46,7 @@ function followCountFor(followers: number, following: number): string {
|
|
|
43
46
|
return followers + " Followers · " + following + " Following";
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
const
|
|
47
|
-
load: (s: S) => Promise<S>;
|
|
48
|
-
toggleFollow: (s: S) => Promise<S>;
|
|
49
|
-
}>("spacesMemberDetail", {
|
|
50
|
-
state: {
|
|
49
|
+
const INITIAL: S = {
|
|
51
50
|
status: "loading",
|
|
52
51
|
id: "",
|
|
53
52
|
name: "",
|
|
@@ -61,13 +60,25 @@ const svc = defineService<S, {
|
|
|
61
60
|
following: 0,
|
|
62
61
|
followLabel: "Follow",
|
|
63
62
|
followCount: "0 Followers · 0 Following",
|
|
64
|
-
...presence("loading"),
|
|
65
|
-
}
|
|
63
|
+
...presence("loading"), ...NO_READ_SIGN_IN,
|
|
64
|
+
};
|
|
65
|
+
let loadVersion = 0;
|
|
66
|
+
|
|
67
|
+
const svc = defineService<S, {
|
|
68
|
+
prepare: (s: S) => S;
|
|
69
|
+
load: (s: S) => Promise<S | undefined>;
|
|
70
|
+
toggleFollow: (s: S) => Promise<S>;
|
|
71
|
+
}>("spacesMemberDetail", {
|
|
72
|
+
state: INITIAL,
|
|
66
73
|
actions: {
|
|
67
|
-
|
|
74
|
+
prepare: (s) => ({ ...s, ...INITIAL }),
|
|
75
|
+
load: async (s): Promise<S | undefined> => {
|
|
76
|
+
const version = ++loadVersion;
|
|
77
|
+
await svc.prepare();
|
|
68
78
|
const id = detailInputId();
|
|
69
|
-
if (!id) return { ...
|
|
79
|
+
if (!id) return { ...INITIAL, status: "ready", ...presence("missing") };
|
|
70
80
|
try {
|
|
81
|
+
const access = await readAccess();
|
|
71
82
|
const res = await getMember(id);
|
|
72
83
|
const m = ((res as unknown as { member?: WixMember }).member) ?? (res as unknown as WixMember);
|
|
73
84
|
const n = name(m);
|
|
@@ -87,8 +98,10 @@ const svc = defineService<S, {
|
|
|
87
98
|
isFollowing = await isFollowingMember(id);
|
|
88
99
|
} catch { /* default false */ }
|
|
89
100
|
|
|
101
|
+
await assertReadAccess(access);
|
|
102
|
+
if (version !== loadVersion) return undefined;
|
|
90
103
|
return {
|
|
91
|
-
status: "ready",
|
|
104
|
+
...INITIAL, status: "ready",
|
|
92
105
|
id,
|
|
93
106
|
name: n,
|
|
94
107
|
email: m.loginEmail || "",
|
|
@@ -104,7 +117,8 @@ const svc = defineService<S, {
|
|
|
104
117
|
...presence("found"),
|
|
105
118
|
};
|
|
106
119
|
} catch (e) {
|
|
107
|
-
|
|
120
|
+
const flags = await readSignInFlags(e);
|
|
121
|
+
return version === loadVersion ? { ...INITIAL, status: "ready", id, ...presenceAfterError(e), ...flags } : undefined;
|
|
108
122
|
}
|
|
109
123
|
},
|
|
110
124
|
// REAL WRITE: two-state toggle. Flips the relationship and optimistically
|
|
@@ -144,5 +158,6 @@ const svc = defineService<S, {
|
|
|
144
158
|
},
|
|
145
159
|
},
|
|
146
160
|
});
|
|
161
|
+
svc.prepare();
|
|
147
162
|
svc.load();
|
|
148
163
|
export { svc };
|
|
@@ -37,7 +37,7 @@ export default defineApp({
|
|
|
37
37
|
actions: "http://localhost:8190/v1/actions",
|
|
38
38
|
sync: "http://localhost:8190/v1/sync",
|
|
39
39
|
},
|
|
40
|
-
capabilities: ["
|
|
40
|
+
capabilities: ["push","share","secure_storage","open_url","calendar","local_database","wix_api","wix_platform","app_info","iap","release_control","clipboard","theme","wix_cashier"],
|
|
41
41
|
cache: {
|
|
42
42
|
"rules": [
|
|
43
43
|
{
|