@wix/himalaya-cli 0.1633.0 → 0.1635.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/dev-server/src/app-config.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-module.d.ts +2 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +22 -10
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-wire.d.ts +16 -6
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +11 -4
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +6 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +10 -4
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-feed-viewer/src/index.d.ts +10 -6
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +34 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +84 -16
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +67 -10
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +83 -15
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members-feed-viewer.mjs +8 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +29 -0
- package/dist/browser-plane/assets.json +166 -0
- package/dist/browser-plane/host.html +156 -0
- package/dist/browser-plane/package-reader.js +4 -0
- package/dist/browser-plane/preview-deferred/368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5.bin +0 -0
- package/dist/browser-plane/preview-deferred/37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29.bin +0 -0
- package/dist/browser-plane/preview-deferred/4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02.bin +0 -0
- package/dist/browser-plane/preview-deferred/521a0e528916d678d7588b2cddf3dadd3bb959a6d62426382f61c77a82017e35.bin +1 -0
- package/dist/browser-plane/preview-deferred/62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9.bin +0 -0
- package/dist/browser-plane/preview-deferred/642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e.bin +0 -0
- package/dist/browser-plane/preview-deferred/67feb362281a3040ee05592769227c58a98ec3c8073dac95ecb5629141c67061.bin +56 -0
- package/dist/browser-plane/preview-deferred/97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1.bin +0 -0
- package/dist/browser-plane/preview-deferred/a33cfe728c584fdba4fcc1fd54bcdc2f9f2f13889ddbb5b2bd1d0f8cbe49b84e.bin +28 -0
- package/dist/browser-plane/preview-deferred/bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa.bin +0 -0
- package/dist/browser-plane/preview-deferred/d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134.bin +0 -0
- package/dist/browser-plane/preview-deferred/dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b.bin +0 -0
- package/dist/browser-plane/preview-deferred/dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5.bin +0 -0
- package/dist/browser-plane/preview-deferred/ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf.bin +0 -0
- package/dist/browser-plane/sw.js +13 -0
- package/dist/browser-plane/t5-frame.html +350 -0
- package/dist/browser-plane/t5-worker-frame.html +350 -0
- package/dist/cli.mjs +6594 -5078
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +34 -1
- package/guides/designing-himi-screens.md +5 -1
- package/guides/himi-json-output.md +16 -1
- package/guides/iap-products.md +8 -0
- package/guides/one-app-to-himi-app.md +81 -5
- package/guides/sdk-reference.json +56 -11
- package/guides/skill.md +1 -0
- package/package.json +3 -3
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/beauty-salon/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/branded-lite/config.ts +7 -0
- package/templates/branded-lite/dev/index.ts +1109 -2307
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/l10n/en.json +6 -6
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-history.ts +4 -1
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +2 -7
- package/templates/branded-lite/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/branded-lite/tier5-src/_shared/header-badges.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/himi-cms.ts +2 -1
- package/templates/branded-lite/tier5-src/_shared/inbox-rooms.ts +68 -0
- package/templates/branded-lite/tier5-src/_shared/member-activity.ts +34 -0
- package/templates/branded-lite/tier5-src/_shared/member-row-extras.ts +167 -0
- package/templates/branded-lite/tier5-src/_shared/notification-target.ts +141 -0
- package/templates/branded-lite/tier5-src/_shared/source-badges.ts +6 -4
- package/templates/branded-lite/tier5-src/_shared/wix-member-links.ts +114 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +114 -59
- package/templates/branded-lite/tier5-src/himi-branded-lite-cms/index.ts +160 -39
- package/templates/branded-lite/tier5-src/himi-branded-lite-inbox/index.ts +83 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +62 -7
- package/templates/branded-lite/tier5-src/wix-spaces-members/index.ts +60 -12
- package/templates/community-club/dev/index.ts +210 -2044
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/community-club/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/community-club/tier5-src/_shared/himi-cms.ts +2 -1
- package/templates/community-club/tier5-src/himi-branded-lite-cms/index.ts +160 -39
- package/templates/community-club-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/events-meetups/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/events-meetups-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/nonprofit-cause/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/owner-lite/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/podcast-creator/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/restaurant/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/restaurant-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/retail-store-owner/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/salon-spa/dev/index.ts +112 -70
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +4 -1
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +2 -7
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/salon-spa/tier5-src/_shared/inbox-rooms.ts +68 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +114 -59
- package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/yoga-studio/tier5-src/_shared/collection-contract.ts +0 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/collection-contract.ts +0 -1
|
@@ -1,58 +1,135 @@
|
|
|
1
1
|
import { defineService } from "../_shared/collection-service.js";
|
|
2
|
-
import { collectionDefinitions, type CmsCollectionDefinition } from "../_shared/himi-cms";
|
|
3
|
-
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
2
|
+
import { collectionDefinitions, countDataItems, type CmsCollectionDefinition } from "../_shared/himi-cms";
|
|
3
|
+
import { copyParam, copyPlural, stashCopy } from "../_shared/himi-copy.js";
|
|
4
4
|
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
5
5
|
|
|
6
6
|
// Offline-first (data-offline): this catalog is derived from the app's BAKED CMS
|
|
7
7
|
// contract (himalaya.input.cmsCollections) — there is no live wixApi fetch to
|
|
8
|
-
// revalidate, so
|
|
9
|
-
// around that baked load path: the derived
|
|
8
|
+
// revalidate the NAMES, so they are structurally read-only. We still add the read-through seam
|
|
9
|
+
// around that baked load path: the derived entries are mirrored into the on-device store,
|
|
10
10
|
// and if the contract ever arrives empty we fall back to the last-synced cache.
|
|
11
11
|
// DEGRADES GRACEFULLY: every local-db call is wrapped so a build that hasn't granted
|
|
12
12
|
// `local_database` (+ declared the `cms_collections` entity) behaves exactly as before.
|
|
13
|
+
//
|
|
14
|
+
// The item COUNT under each name is the one live read. It is never cached: a stale number would
|
|
15
|
+
// read as the business's own, and a count the API did not return is not shown at all.
|
|
13
16
|
const ENTITY = "cms_collections";
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
|
|
18
|
+
/** What the screen needs to know about a collection, with no per-visit state. Mirrored to the cache. */
|
|
19
|
+
interface Entry {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
detailScreenId: string;
|
|
23
|
+
isMembersOnly: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readCached(): Promise<Entry[]> {
|
|
27
|
+
try {
|
|
28
|
+
return (await getAll(ENTITY)).map((r) => {
|
|
29
|
+
const stored = r as unknown as Partial<Entry> & { meta?: string };
|
|
30
|
+
return {
|
|
31
|
+
id: String(stored.id ?? ""),
|
|
32
|
+
title: typeof stored.title === "string" ? stored.title : String(stored.id ?? ""),
|
|
33
|
+
detailScreenId: typeof stored.detailScreenId === "string" ? stored.detailScreenId : `himi_cms_collection_${encodeURIComponent(String(stored.id ?? ""))}`,
|
|
34
|
+
// A row cached before counts carried its "Members only" label in `meta`.
|
|
35
|
+
isMembersOnly: stored.isMembersOnly ?? Boolean(stored.meta),
|
|
36
|
+
};
|
|
37
|
+
}).filter((entry) => entry.id !== "");
|
|
38
|
+
} catch { return []; }
|
|
16
39
|
}
|
|
17
|
-
async function writeCached(
|
|
18
|
-
try { for (const
|
|
40
|
+
async function writeCached(entries: Entry[]): Promise<void> {
|
|
41
|
+
try { for (const e of entries) await upsert(ENTITY, e as unknown as DbEntity); } catch { /* capability not granted → online-only */ }
|
|
19
42
|
}
|
|
20
43
|
|
|
44
|
+
/** "pending" until the count call settles; "none" for a members-only collection, which shows a lock instead. */
|
|
45
|
+
type CountStatus = "pending" | "ready" | "error" | "none";
|
|
46
|
+
|
|
21
47
|
interface CollectionRow {
|
|
22
48
|
id: string;
|
|
23
49
|
title: string;
|
|
24
|
-
subtitle: string;
|
|
25
|
-
meta: string;
|
|
26
|
-
initial: string;
|
|
27
50
|
detailScreenId: string;
|
|
51
|
+
isMembersOnly: boolean;
|
|
52
|
+
membersOnlyText: string;
|
|
53
|
+
countStatus: CountStatus;
|
|
54
|
+
countText: string;
|
|
55
|
+
/** The row's accessible name: the collection, then whichever caption it shows. */
|
|
56
|
+
accessibilityLabel: string;
|
|
57
|
+
// The descriptor hides a node when its bound flag is TRUE, so each caption has its inverse.
|
|
58
|
+
noCountLoading: boolean;
|
|
59
|
+
noCountText: boolean;
|
|
60
|
+
noMembersOnly: boolean;
|
|
28
61
|
}
|
|
29
62
|
|
|
30
63
|
interface State {
|
|
31
64
|
status: "ready";
|
|
32
|
-
all: CollectionRow[];
|
|
33
65
|
rows: CollectionRow[];
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
/** More than 5 collections: the name search shows. */
|
|
67
|
+
showSearch: boolean;
|
|
68
|
+
hideSearch: boolean;
|
|
69
|
+
/** The contract holds no collection at all. */
|
|
36
70
|
noEmpty: boolean;
|
|
71
|
+
/** A search matched none of them. */
|
|
72
|
+
noNoMatch: boolean;
|
|
73
|
+
noRows: boolean;
|
|
37
74
|
}
|
|
38
75
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
76
|
+
/** A name search earns its place only past this many collections. */
|
|
77
|
+
const SEARCH_THRESHOLD = 5;
|
|
78
|
+
|
|
79
|
+
let entries: Entry[] = [];
|
|
80
|
+
let query = "";
|
|
81
|
+
/** A settled count by collection id. A failed refresh never replaces a count already known. */
|
|
82
|
+
const counts = new Map<string, { status: "ready" | "error"; count: number }>();
|
|
83
|
+
/** Ids with a count call on the wire, so a second `load` (onAppear fires on every visit) does not double it. */
|
|
84
|
+
const inFlight = new Set<string>();
|
|
85
|
+
|
|
86
|
+
function entryFor(collection: CmsCollectionDefinition): Entry {
|
|
44
87
|
return {
|
|
45
88
|
id: collection.id,
|
|
46
|
-
title,
|
|
47
|
-
subtitle: "",
|
|
48
|
-
meta: collection.readRole === "ANYONE" ? "" : copyParam("cms.membersOnlyAccessLabel", "Members only"),
|
|
49
|
-
initial: (title[0] || "C").toUpperCase(),
|
|
89
|
+
title: collection.displayName.trim() || collection.id,
|
|
50
90
|
detailScreenId: `himi_cms_collection_${encodeURIComponent(collection.id)}`,
|
|
91
|
+
isMembersOnly: collection.readRole !== "ANYONE",
|
|
51
92
|
};
|
|
52
93
|
}
|
|
53
94
|
|
|
54
|
-
function
|
|
55
|
-
|
|
95
|
+
function rowFor(entry: Entry): CollectionRow {
|
|
96
|
+
const membersOnlyText = copyParam("cms.membersOnlyAccessLabel", "Members only");
|
|
97
|
+
const known = counts.get(entry.id);
|
|
98
|
+
const countStatus: CountStatus = entry.isMembersOnly ? "none" : known?.status ?? "pending";
|
|
99
|
+
const countText = countStatus === "ready" && known
|
|
100
|
+
? copyPlural("cms.itemCount", "{n, plural, one {# item} other {# items}}", known.count)
|
|
101
|
+
: "";
|
|
102
|
+
const caption = entry.isMembersOnly ? membersOnlyText : countText;
|
|
103
|
+
return {
|
|
104
|
+
id: entry.id,
|
|
105
|
+
title: entry.title,
|
|
106
|
+
detailScreenId: entry.detailScreenId,
|
|
107
|
+
isMembersOnly: entry.isMembersOnly,
|
|
108
|
+
membersOnlyText,
|
|
109
|
+
countStatus,
|
|
110
|
+
countText,
|
|
111
|
+
accessibilityLabel: caption ? `${entry.title}, ${caption}` : entry.title,
|
|
112
|
+
noCountLoading: countStatus !== "pending",
|
|
113
|
+
noCountText: countStatus !== "ready",
|
|
114
|
+
noMembersOnly: !entry.isMembersOnly,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every visible field, derived from the module's truth at the moment of the call, so an action that
|
|
119
|
+
* settles later (a count arriving, a search typed) can never publish an older picture of it. */
|
|
120
|
+
function view(): State {
|
|
121
|
+
const needle = query.trim().toLowerCase();
|
|
122
|
+
const shown = needle ? entries.filter((entry) => entry.title.toLowerCase().includes(needle)) : entries;
|
|
123
|
+
const showSearch = entries.length > SEARCH_THRESHOLD;
|
|
124
|
+
return {
|
|
125
|
+
status: "ready",
|
|
126
|
+
rows: shown.map(rowFor),
|
|
127
|
+
showSearch,
|
|
128
|
+
hideSearch: !showSearch,
|
|
129
|
+
noEmpty: entries.length > 0,
|
|
130
|
+
noNoMatch: entries.length === 0 || shown.length > 0,
|
|
131
|
+
noRows: shown.length === 0,
|
|
132
|
+
};
|
|
56
133
|
}
|
|
57
134
|
|
|
58
135
|
function value(arg: unknown, key: string): string {
|
|
@@ -61,30 +138,74 @@ function value(arg: unknown, key: string): string {
|
|
|
61
138
|
return typeof result === "string" ? result : "";
|
|
62
139
|
}
|
|
63
140
|
|
|
141
|
+
/** The count the API returned, or undefined: a missing or malformed total is never read as zero. */
|
|
142
|
+
function totalOf(response: { totalCount?: number } | undefined): number | undefined {
|
|
143
|
+
const total = response?.totalCount;
|
|
144
|
+
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One count call per readable collection, all at once; each row fills as ITS call returns. */
|
|
148
|
+
function startCounts(): void {
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (entry.isMembersOnly || inFlight.has(entry.id)) continue;
|
|
151
|
+
inFlight.add(entry.id);
|
|
152
|
+
const settle = (total: number | undefined) => { inFlight.delete(entry.id); void svc.countSettled({ id: entry.id, total }); };
|
|
153
|
+
countDataItems(entry.id).then((response) => settle(totalOf(response)), () => settle(undefined));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
64
157
|
const inputCollections = (globalThis as { himalaya?: { input?: { cmsCollections?: unknown } } }).himalaya?.input?.cmsCollections;
|
|
158
|
+
entries = collectionDefinitions(typeof inputCollections === "string" ? inputCollections : "").map(entryFor);
|
|
159
|
+
const initialState = view();
|
|
160
|
+
const derivedStateKeys = Object.keys(initialState) as Array<keyof State>;
|
|
65
161
|
|
|
66
162
|
const svc = defineService<State, {
|
|
67
|
-
load: (state: State, arg?: unknown) => Promise<State>;
|
|
163
|
+
load: (state: State, arg?: unknown) => Promise<State | undefined>;
|
|
68
164
|
search: (state: State, arg?: unknown) => State;
|
|
165
|
+
countSettled: (state: State, arg: { id: string; total: number | undefined }) => State;
|
|
69
166
|
}>("himiBrandedCms", {
|
|
70
|
-
state:
|
|
167
|
+
state: initialState,
|
|
168
|
+
// Rows and visibility are derived from this run's contract/query/counts.
|
|
169
|
+
// A prior search or count must not override them on a cold boot. Collection
|
|
170
|
+
// definitions already have their own explicit local-database cache.
|
|
171
|
+
notHydrated: derivedStateKeys,
|
|
172
|
+
notPersisted: derivedStateKeys,
|
|
71
173
|
actions: {
|
|
72
|
-
load: async (
|
|
174
|
+
load: async (state, arg) => {
|
|
73
175
|
stashCopy(arg);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
176
|
+
// The remounted SearchField starts empty. Reset before awaiting the cache so
|
|
177
|
+
// a search typed during this load is not cleared by its eventual completion.
|
|
178
|
+
query = "";
|
|
179
|
+
const given = value(arg, "collections");
|
|
180
|
+
const baked = collectionDefinitions(given).map(entryFor);
|
|
181
|
+
if (baked.length) {
|
|
182
|
+
entries = baked;
|
|
183
|
+
await writeCached(baked);
|
|
184
|
+
} else if (given !== "" || !entries.length) {
|
|
185
|
+
// The contract named no collection (or nothing at all seeded this screen): what was seeded from the
|
|
186
|
+
// input no longer stands. Fall back to the last-synced cache (offline), else the empty state.
|
|
187
|
+
entries = await readCached();
|
|
188
|
+
}
|
|
189
|
+
startCounts();
|
|
190
|
+
// Actions are not serialized: a count that settles while this one is still committing would be
|
|
191
|
+
// overwritten by the older picture returned here. The names normally match what the screen already
|
|
192
|
+
// shows (the same contract seeded it), so then there is nothing to commit and nothing to overwrite.
|
|
193
|
+
const next = view();
|
|
194
|
+
return JSON.stringify(next) === JSON.stringify(state) ? undefined : next;
|
|
195
|
+
},
|
|
196
|
+
search: (_state, arg) => {
|
|
197
|
+
query = value(arg, "value");
|
|
198
|
+
return view();
|
|
79
199
|
},
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
: state.all;
|
|
85
|
-
return ready(state.all, rows, query ? copyParam("cms.searchEmptyText", "No collections match your search.") : undefined);
|
|
200
|
+
countSettled: (_state, arg) => {
|
|
201
|
+
if (arg.total !== undefined) counts.set(arg.id, { status: "ready", count: arg.total });
|
|
202
|
+
else if (counts.get(arg.id)?.status !== "ready") counts.set(arg.id, { status: "error", count: 0 });
|
|
203
|
+
return view();
|
|
86
204
|
},
|
|
87
205
|
},
|
|
88
206
|
});
|
|
89
207
|
|
|
208
|
+
// NO module-scope `startCounts()`: a bridge call made during the bundle's own evaluation stalls on
|
|
209
|
+
// the Android WebView (see himi-owner-lite-aria-history). The names paint from the baked input, and
|
|
210
|
+
// onAppear's `load` starts the counts right after that first frame.
|
|
90
211
|
export { svc };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// himi-branded-lite-inbox — the member's Inbox: every conversation the member-role chat list returns, the
|
|
2
|
+
// business's and other members'. Only the member-role list is used (`GET inbox/v2/conversations`, the call the
|
|
3
|
+
// first Inbox made, is an OWNER endpoint that answers a member with 404). A failed query is an error with Retry
|
|
4
|
+
// (or Sign in for a visitor) and an empty one is a real empty state — never sample rows presented as the
|
|
5
|
+
// customer's own. Samples appear only in the host's explicit sample preview (_shared/branded-load-state.ts).
|
|
6
|
+
import { defineService } from "../_shared/collection-service.js";
|
|
7
|
+
import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
|
|
8
|
+
import { getChatToken, listChatroomsPage } from "@himalaya/wix-chat-web";
|
|
9
|
+
import { assertMemberAccess, memberAccess } from "../_shared/member-access.js";
|
|
10
|
+
import { inboxRows, type InboxRow } from "../_shared/inbox-rooms.js";
|
|
11
|
+
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
12
|
+
|
|
13
|
+
type Row = Omit<InboxRow, "at">;
|
|
14
|
+
interface S extends LoadFlags {
|
|
15
|
+
conversations: Row[]; emptyText: string;
|
|
16
|
+
isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
|
|
17
|
+
* shows only when the site really has none, and the unavailable one only when the query failed. A failed
|
|
18
|
+
* query must never render the sentence that claims the customer has no conversations. */
|
|
19
|
+
notMockEmpty: boolean; notMockUnavailable: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MEMBER = { role: "member" } as const;
|
|
23
|
+
const PAGE_SIZE = 50;
|
|
24
|
+
// 2000 conversations: far beyond a real member. The cap only stops a runaway cursor from looping, and reaching it is an
|
|
25
|
+
// error, never a quietly shorter list.
|
|
26
|
+
const MAX_PAGES = 40;
|
|
27
|
+
|
|
28
|
+
const MOCK: Row[] = [
|
|
29
|
+
{ id: "sample-support", title: "Support", subtitle: "Hi! Let us know how we can help.", screenId: "himi_business_chat", kind: "", photo: "", initial: "S", unread: false, unreadDot: "" },
|
|
30
|
+
{ id: "sample-member", title: "A member", subtitle: "Welcome 👋 Thanks for joining us!", screenId: "himi_chat_thread", kind: "chatroom", photo: "", initial: "A", unread: false, unreadDot: "" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Sample rows reach the screen ONLY in the host's explicit sample preview; a failed or empty query is an
|
|
34
|
+
* honest error / empty state (branded-load-state.ts). */
|
|
35
|
+
const COPY: LoadCopy<Row> = {
|
|
36
|
+
prefix: "inbox",
|
|
37
|
+
empty: "No messages yet.",
|
|
38
|
+
error: "Your messages couldn't be loaded.",
|
|
39
|
+
signIn: "Sign in to see your messages.",
|
|
40
|
+
sample: () => MOCK.map((r) => ({ ...r })),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const signInRequired = () => new Error("Unauthenticated: sign in to see your messages");
|
|
44
|
+
|
|
45
|
+
async function listAllRooms(): Promise<unknown[]> {
|
|
46
|
+
// One token for every page; a refused one fails the whole load rather than showing half a list.
|
|
47
|
+
const { chatToken } = await getChatToken(MEMBER);
|
|
48
|
+
const rooms: unknown[] = [];
|
|
49
|
+
let nextPointer: number | string | undefined;
|
|
50
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
51
|
+
const result = await listChatroomsPage({ ...MEMBER, chatToken, pageSize: PAGE_SIZE, ...(nextPointer !== undefined ? { nextPointer } : {}) });
|
|
52
|
+
rooms.push(...result.items);
|
|
53
|
+
nextPointer = result.nextPointer;
|
|
54
|
+
if (nextPointer === undefined) return rooms;
|
|
55
|
+
}
|
|
56
|
+
throw new Error("Conversation list is longer than the pages read");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const svc = defineService<S, { load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedInbox", {
|
|
60
|
+
state: { ...LOADING, conversations: [], emptyText: "" },
|
|
61
|
+
actions: {
|
|
62
|
+
load: async (s, arg) => {
|
|
63
|
+
// The screen's onAppear carries the copy pack (Hebrew, an owner's wording). This service is not a collection, so
|
|
64
|
+
// nothing else stashes it; a retry sends no params and keeps the pack already stashed.
|
|
65
|
+
stashCopy(arg);
|
|
66
|
+
try {
|
|
67
|
+
const member = await memberAccess();
|
|
68
|
+
if (!member.email) throw signInRequired();
|
|
69
|
+
const rows = inboxRows(await listAllRooms(), copyParam("inbox.conversationFallback", "Conversation")).map(({ at: _at, ...row }) => row);
|
|
70
|
+
// A sign-out or account switch while the list loaded must not paint the previous member's rooms.
|
|
71
|
+
await assertMemberAccess(member);
|
|
72
|
+
return { ...s, ...loaded("conversations", rows, COPY) };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const failed = loadFailed("conversations", (await memberAccess()).email ? err : signInRequired(), COPY);
|
|
75
|
+
// The failure sentence is the screen banner's stock key, not a second key for the same English (the extractor
|
|
76
|
+
// keeps one key per extracted row); loadFailed would read `inbox.errorText`, which does not exist.
|
|
77
|
+
return { ...s, ...failed, ...(failed.signInRequired ? {} : { error: copyParam("inbox.unavailableBody", COPY.error) }) };
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
svc.load();
|
|
83
|
+
export { svc };
|
|
@@ -10,6 +10,10 @@ import { defineService as registerService } from "../_shared/collection-service.
|
|
|
10
10
|
import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
|
|
11
11
|
import { queryNotifications, markRead, markAllRead, type MfvNotification } from "@himalaya/wix-members-feed-viewer";
|
|
12
12
|
import { assertMemberAccess, memberAccess, type MemberAccess } from "../_shared/member-access.js";
|
|
13
|
+
import { navigate } from "@himalaya/state";
|
|
14
|
+
import { notificationOpen, urlHost, type NotificationOpen } from "../_shared/notification-target.js";
|
|
15
|
+
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
16
|
+
import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
13
17
|
|
|
14
18
|
interface Row {
|
|
15
19
|
id: string;
|
|
@@ -22,9 +26,13 @@ interface Row {
|
|
|
22
26
|
photo: string;
|
|
23
27
|
initial: string;
|
|
24
28
|
dest: string;
|
|
29
|
+
/** What a tap does (a screen, a web link or nothing). Absent on the preview samples, which open nothing. */
|
|
30
|
+
open?: NotificationOpen;
|
|
25
31
|
}
|
|
26
32
|
interface S extends LoadFlags {
|
|
27
33
|
items: Row[]; emptyText: string; actionMessage: string;
|
|
34
|
+
/** The web link the last tap named and is waiting for a second tap to open (see `open`). */
|
|
35
|
+
pendingUrl: string;
|
|
28
36
|
/** Ownership travels with the rows, including a state captured by an async write. */
|
|
29
37
|
inboxMember: MemberAccess | null;
|
|
30
38
|
isMock: boolean; notMock: boolean;
|
|
@@ -67,7 +75,17 @@ function photo(n: MfvNotification): string {
|
|
|
67
75
|
return n.icon?.personAvatarData?.data || n.icon?.webUrlData?.webUrl || "";
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
|
|
78
|
+
/** The screens the Notifications screen says its app serves, or undefined when it did not say. */
|
|
79
|
+
function servedScreens(arg: unknown): ReadonlySet<string> | undefined {
|
|
80
|
+
const served = (arg as { served?: unknown } | null | undefined)?.served;
|
|
81
|
+
// A Retry reaches `load` with no params (the collection layer re-dispatches the action bare), so the list the screen
|
|
82
|
+
// last named is kept: dropping it would let every catalog link be tried again.
|
|
83
|
+
if (typeof served === "string") lastServed = new Set(served.split(",").filter(Boolean));
|
|
84
|
+
return lastServed;
|
|
85
|
+
}
|
|
86
|
+
let lastServed: ReadonlySet<string> | undefined;
|
|
87
|
+
|
|
88
|
+
function norm(n: MfvNotification, served?: ReadonlySet<string>): Row {
|
|
71
89
|
const name = (n.title || n.message || "Notification").trim() || "Notification";
|
|
72
90
|
const rel = relativeTime(n.sentDate);
|
|
73
91
|
const lead = n.title && n.message ? n.message.trim() : "";
|
|
@@ -83,6 +101,7 @@ function norm(n: MfvNotification): Row {
|
|
|
83
101
|
photo: p,
|
|
84
102
|
initial: (name[0] || "#").toUpperCase(),
|
|
85
103
|
dest: dest(n),
|
|
104
|
+
open: notificationOpen(n.deeplink, served),
|
|
86
105
|
};
|
|
87
106
|
}
|
|
88
107
|
|
|
@@ -128,12 +147,13 @@ function currentInbox(s: S): S | undefined {
|
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
const defineService = withOnlineWrites(registerService, ["markOneRead","markAll"], { messageFields: ["actionMessage"] });
|
|
131
|
-
const INITIAL = { ...LOADING, items: [] as Row[], emptyText: "", actionMessage: "", inboxMember: null };
|
|
150
|
+
const INITIAL = { ...LOADING, items: [] as Row[], emptyText: "", actionMessage: "", pendingUrl: "", inboxMember: null };
|
|
132
151
|
const svc = defineService<S, {
|
|
133
152
|
prepare: (s: S) => S;
|
|
134
|
-
load: (s: S) => Promise<S>;
|
|
153
|
+
load: (s: S, arg?: unknown) => Promise<S>;
|
|
135
154
|
markOneRead: (s: S, id: string) => Promise<S | undefined>;
|
|
136
155
|
markAll: (s: S) => Promise<S | undefined>;
|
|
156
|
+
open: (s: S, arg?: unknown) => Promise<S | undefined>;
|
|
137
157
|
}>("himiBrandedNotifications", {
|
|
138
158
|
state: INITIAL,
|
|
139
159
|
actions: {
|
|
@@ -141,14 +161,16 @@ const svc = defineService<S, {
|
|
|
141
161
|
// Native hosts may already cold-paint their account-scoped snapshot; this
|
|
142
162
|
// reducer does not control that earlier host phase.
|
|
143
163
|
prepare: (s) => ({ ...s, ...INITIAL }),
|
|
144
|
-
load: async (s) => {
|
|
164
|
+
load: async (s, arg) => {
|
|
165
|
+
// The screen names which of the link targets this app serves (a comma list); none given means any may be tried.
|
|
166
|
+
const served = servedScreens(arg);
|
|
145
167
|
try {
|
|
146
168
|
const member = await requireMember();
|
|
147
|
-
const items = (await queryNotifications(25)).map(norm);
|
|
169
|
+
const items = (await queryNotifications(25)).map((n) => norm(n, served));
|
|
148
170
|
await assertMemberAccess(member);
|
|
149
|
-
return { ...s, ...loaded("items", items, COPY), inboxMember: member, actionMessage: "" };
|
|
171
|
+
return { ...s, ...loaded("items", items, COPY), inboxMember: member, actionMessage: "", pendingUrl: "" };
|
|
150
172
|
} catch (err) {
|
|
151
|
-
return { ...s, ...loadFailed("items", (await memberAccess()).email ? err : signInRequired(), COPY), inboxMember: null };
|
|
173
|
+
return { ...s, ...loadFailed("items", (await memberAccess()).email ? err : signInRequired(), COPY), inboxMember: null, actionMessage: "", pendingUrl: "" };
|
|
152
174
|
}
|
|
153
175
|
},
|
|
154
176
|
// Mark one read; optimistically clear its dot. Real write is best-effort and
|
|
@@ -174,6 +196,39 @@ const svc = defineService<S, {
|
|
|
174
196
|
const current = currentInbox(s);
|
|
175
197
|
return current ? { ...current, items: current.items.map((r) => (r.id === id ? { ...r, unread: false, unreadDot: "" } : r)) } : undefined;
|
|
176
198
|
},
|
|
199
|
+
// A tap on a row opens what the notification points at, like the One App: a Himi screen when its link names
|
|
200
|
+
// one of ours (see _shared/notification-target.ts), else its https link, else a visible "can't open".
|
|
201
|
+
// A web link is written by the site, so it needs a SECOND tap on the same row, after the first tap has
|
|
202
|
+
// shown where it goes. It never replaces the inbox, and an unknown link never guesses a screen.
|
|
203
|
+
open: async (s, arg): Promise<S | undefined> => {
|
|
204
|
+
const id = String((arg as { id?: unknown } | null | undefined)?.id ?? "");
|
|
205
|
+
const row = s.items.find((r) => r.id === id);
|
|
206
|
+
if (!row || s.busy) return undefined;
|
|
207
|
+
if (!s.isMock) {
|
|
208
|
+
try { await requireInboxMember(s); } catch (err) {
|
|
209
|
+
const current = currentInbox(s);
|
|
210
|
+
return current ? { ...current, ...loadFailed("items", err, COPY), inboxMember: null } : undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const current = currentInbox(s);
|
|
214
|
+
if (!current) return undefined;
|
|
215
|
+
const action = row.open ?? { kind: "none" as const };
|
|
216
|
+
if (action.kind === "url") {
|
|
217
|
+
if (current.pendingUrl !== action.url) {
|
|
218
|
+
return { ...current, pendingUrl: action.url, actionMessage: copyTemplate("notifications.openLinkText", "Tap again to open {host}.", { host: urlHost(action.url) }) };
|
|
219
|
+
}
|
|
220
|
+
const opened = await openRichLink(action.url);
|
|
221
|
+
const now = currentInbox(s);
|
|
222
|
+
return now ? { ...now, pendingUrl: "", actionMessage: opened ? "" : copyParam("notifications.cantOpenText", "This notification can't be opened here.") } : undefined;
|
|
223
|
+
}
|
|
224
|
+
if (action.kind === "none") {
|
|
225
|
+
return { ...current, pendingUrl: "", actionMessage: copyParam("notifications.cantOpenText", "This notification can't be opened here.") };
|
|
226
|
+
}
|
|
227
|
+
// Read on open: the dot clears at once, the write is best effort and never holds the navigation.
|
|
228
|
+
if (!id.startsWith("sample-") && row.unread) markRead([id]).catch(() => undefined);
|
|
229
|
+
const read = { ...current, pendingUrl: "", actionMessage: "", items: current.items.map((r) => (r.id === id ? { ...r, unread: false, unreadDot: "" } : r)) };
|
|
230
|
+
return navigate(read, { screenId: action.target.screenId, args: action.target.args });
|
|
231
|
+
},
|
|
177
232
|
// Mark everything read; optimistically clear all dots.
|
|
178
233
|
markAll: async (s): Promise<S | undefined> => {
|
|
179
234
|
if (s.busy) return undefined;
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
// Public site-member directory. Search, sorting, totals and offset paging match
|
|
2
2
|
// OneApp's Members v1 query; no owner roster or build-time count is substituted.
|
|
3
3
|
import { defineService } from "@himalaya/state";
|
|
4
|
-
import { queryMembers, type WixMember } from "@himalaya/wix-members";
|
|
4
|
+
import { queryMembers, followMember, unfollowMember, type WixMember } from "@himalaya/wix-members";
|
|
5
5
|
import { createCollectionQuery } from "../_shared/collection-query.js";
|
|
6
6
|
import { readAccess, assertReadAccess, readAccessDenied, type ReadAccess } from "../_shared/member-read.js";
|
|
7
7
|
import { memberAccessChanged } from "../_shared/member-access.js";
|
|
8
8
|
import { withEmptyFlags } from "../_shared/empty-flags.js";
|
|
9
|
+
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
10
|
+
import { memberRowExtras, followLabel, type MemberRowExtras } from "../_shared/member-row-extras.js";
|
|
9
11
|
|
|
10
|
-
interface Row { id: string; name: string;
|
|
12
|
+
interface Row extends MemberRowExtras { id: string; name: string; initial: string; photo: string; sectionTitle: string }
|
|
11
13
|
interface S {
|
|
12
14
|
status: "loading" | "ready" | "error";
|
|
13
15
|
members: Row[]; emptyText: string;
|
|
14
16
|
query: string; total: number | null; searchLabel: string;
|
|
15
17
|
nextCursor: string; noMore: boolean; noPageError: boolean;
|
|
16
18
|
error: string; notErrored: boolean; notLoading: boolean; hideSearch: boolean;
|
|
19
|
+
followHint: string;
|
|
17
20
|
}
|
|
18
21
|
const PAGE_SIZE = 50;
|
|
19
22
|
const SEARCH_FIELDS = ["contact.firstName", "contact.lastName", "profile.nickname"];
|
|
20
23
|
let directoryAccess: ReadAccess | undefined;
|
|
24
|
+
// A Follow the signed-in member just made or undone. Rows keep the state they were read with, so this
|
|
25
|
+
// shows the change at once; the next first-page read replaces it with what the server says.
|
|
26
|
+
const followChanges = new Map<string, boolean>();
|
|
27
|
+
const pendingFollows = new Set<string>();
|
|
28
|
+
|
|
29
|
+
/** Who a directory read, and a write that follows it, belong to: the site and the signed-in member. A reload
|
|
30
|
+
* builds a NEW ReadAccess object for the same reader, so identity says nothing; this key does. */
|
|
31
|
+
const readerKey = (access: ReadAccess | undefined): string => access ? JSON.stringify([access.tenant, access.member.scope, access.member.email]) : "";
|
|
21
32
|
|
|
22
33
|
function name(m: WixMember): string {
|
|
23
34
|
const nick = m.profile?.nickname?.trim();
|
|
@@ -25,10 +36,14 @@ function name(m: WixMember): string {
|
|
|
25
36
|
const full = [m.contact?.firstName, m.contact?.lastName].filter(Boolean).join(" ").trim();
|
|
26
37
|
return full || "Member";
|
|
27
38
|
}
|
|
28
|
-
function row(m: WixMember): Row {
|
|
39
|
+
function row(m: WixMember, extras: MemberRowExtras): Row {
|
|
29
40
|
const n = name(m);
|
|
30
41
|
// Login emails are not directory subtitles. Only public profile content is presented.
|
|
31
|
-
return { id: m.id, name: n,
|
|
42
|
+
return { id: m.id, name: n, initial: (n[0] || "#").toUpperCase(), photo: m.profile?.photo?.url || "", sectionTitle: "", ...extras };
|
|
43
|
+
}
|
|
44
|
+
function withFollowChange(item: Row): Row {
|
|
45
|
+
const following = followChanges.get(item.id);
|
|
46
|
+
return following === undefined ? item : { ...item, isFollowing: following, followLabel: followLabel(following) };
|
|
32
47
|
}
|
|
33
48
|
function sections(rows: Row[]): Row[] {
|
|
34
49
|
const groups = new Map<string, Row[]>();
|
|
@@ -47,16 +62,17 @@ const directory = createCollectionQuery<Row>({
|
|
|
47
62
|
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid member page offset");
|
|
48
63
|
const access = cursor ? directoryAccess : await readAccess();
|
|
49
64
|
if (!access) throw memberAccessChanged();
|
|
50
|
-
if (!cursor) directoryAccess = access;
|
|
65
|
+
if (!cursor) { directoryAccess = access; followChanges.clear(); followHint = ""; }
|
|
51
66
|
await assertReadAccess(access);
|
|
52
67
|
const response = await queryMembers({ query: {
|
|
53
68
|
filter: { status: "APPROVED", privacyStatus: "PUBLIC" },
|
|
54
69
|
sorting: [{ fieldName: "profile.nickname", order: "ASC" }],
|
|
55
70
|
paging: { limit: PAGE_SIZE, offset },
|
|
56
71
|
}, ...(criteria.query ? { search: { expression: criteria.query, fields: SEARCH_FIELDS } } : {}) });
|
|
72
|
+
if (!Array.isArray(response.members) || response.members.some(member => !member?.id)) throw new Error("Invalid member page");
|
|
73
|
+
const extras = response.members.length ? await memberRowExtras(response.members.map(member => member.id), access) : undefined;
|
|
57
74
|
await assertReadAccess(access);
|
|
58
75
|
if (access !== directoryAccess) throw memberAccessChanged();
|
|
59
|
-
if (!Array.isArray(response.members) || response.members.some(member => !member?.id)) throw new Error("Invalid member page");
|
|
60
76
|
const metadata = response.metadata;
|
|
61
77
|
if (metadata?.offset !== undefined && metadata.offset !== offset) throw new Error("Mismatched member page offset");
|
|
62
78
|
const total = !metadata?.tooManyToCount && typeof metadata?.total === "number" && Number.isSafeInteger(metadata.total) && metadata.total >= 0
|
|
@@ -64,7 +80,7 @@ const directory = createCollectionQuery<Row>({
|
|
|
64
80
|
const next = offset + response.members.length;
|
|
65
81
|
if (!response.members.length && total !== undefined && total > next) throw new Error("Incomplete member page");
|
|
66
82
|
return {
|
|
67
|
-
items: response.members.map(row), total,
|
|
83
|
+
items: response.members.map(member => row(member, extras!(member.id))), total,
|
|
68
84
|
nextCursor: response.members.length && (total === undefined ? response.members.length === PAGE_SIZE : next < total) ? String(next) : "",
|
|
69
85
|
};
|
|
70
86
|
},
|
|
@@ -73,6 +89,7 @@ const directory = createCollectionQuery<Row>({
|
|
|
73
89
|
// Set once the reader has typed a search: from then on the field stays mounted, even while a cleared search
|
|
74
90
|
// reloads the full directory (no rows, no query yet), so it never unmounts and loses focus mid-edit.
|
|
75
91
|
let searched = false;
|
|
92
|
+
let followHint = "";
|
|
76
93
|
|
|
77
94
|
function project(): S {
|
|
78
95
|
const page = directory.getState();
|
|
@@ -81,7 +98,7 @@ function project(): S {
|
|
|
81
98
|
const failed = page.status === "error";
|
|
82
99
|
return {
|
|
83
100
|
status: failed ? "error" : loading ? "loading" : "ready",
|
|
84
|
-
members: sections(page.items), query: page.criteria.query, total,
|
|
101
|
+
members: sections(page.items.map(withFollowChange)), query: page.criteria.query, total,
|
|
85
102
|
searchLabel: total === null ? "" : `${total} ${total === 1 ? "member" : "members"}`,
|
|
86
103
|
emptyText: page.status === "ready" && !page.items.length ? page.criteria.query ? "No results" : "No members yet" : "",
|
|
87
104
|
nextCursor: loading || page.loadingMore || page.pageError ? "" : page.nextCursor,
|
|
@@ -94,21 +111,24 @@ function project(): S {
|
|
|
94
111
|
// A successful empty SEARCH keeps its field reachable (to clear the query); a directory that is
|
|
95
112
|
// loading or truly empty (no query, no rows) has nothing to search.
|
|
96
113
|
hideSearch: failed || (!page.items.length && !page.criteria.query && !searched),
|
|
114
|
+
followHint,
|
|
97
115
|
};
|
|
98
116
|
}
|
|
99
117
|
const svc = defineService<S, {
|
|
100
|
-
load: (s: S) => Promise<undefined>;
|
|
118
|
+
load: (s: S, arg?: unknown) => Promise<undefined>;
|
|
101
119
|
filter: (s: S, arg?: unknown) => Promise<undefined>;
|
|
102
120
|
loadMore: (s: S) => Promise<undefined>;
|
|
103
121
|
retry: (s: S) => Promise<undefined>;
|
|
122
|
+
toggleFollow: (s: S, arg?: unknown) => Promise<S | undefined>;
|
|
104
123
|
changed: (s: S) => S;
|
|
105
124
|
}>("spacesMembers", withEmptyFlags({
|
|
106
125
|
state: { status: "loading", members: [], emptyText: "", query: "", total: null, searchLabel: "", nextCursor: "", noMore: true, noPageError: true,
|
|
107
|
-
error: "", notErrored: true, notLoading: false, hideSearch: true },
|
|
126
|
+
error: "", notErrored: true, notLoading: false, hideSearch: true, followHint: "" },
|
|
108
127
|
// A restored worker must re-read the current reader/site before showing a roster.
|
|
109
|
-
notHydrated: ["status", "members", "emptyText", "query", "total", "searchLabel", "nextCursor", "noMore", "noPageError", "error", "notErrored", "notLoading", "hideSearch"],
|
|
128
|
+
notHydrated: ["status", "members", "emptyText", "query", "total", "searchLabel", "nextCursor", "noMore", "noPageError", "error", "notErrored", "notLoading", "hideSearch", "followHint"],
|
|
110
129
|
actions: {
|
|
111
|
-
|
|
130
|
+
// The screen's onAppear carries the copy pack (the owner's wording, the stock translations) and the app language.
|
|
131
|
+
load: async (_s, arg) => { stashCopy(arg); await directory.query(directory.getState().criteria); return undefined; },
|
|
112
132
|
filter: async (_s, arg) => {
|
|
113
133
|
const value = typeof arg === "string" ? arg : (arg as { value?: unknown })?.value;
|
|
114
134
|
if (typeof value === "string" && value.trim()) searched = true;
|
|
@@ -116,6 +136,34 @@ const svc = defineService<S, {
|
|
|
116
136
|
},
|
|
117
137
|
loadMore: async () => { await directory.loadMore(); return undefined; },
|
|
118
138
|
retry: async () => { await directory.retry(); return undefined; },
|
|
139
|
+
// Only a signed-in member reaches this: a Visitor's Follow opens sign-in from the descriptor instead.
|
|
140
|
+
toggleFollow: async (_s, arg) => {
|
|
141
|
+
const id = (arg as { id?: unknown } | undefined)?.id;
|
|
142
|
+
const access = directoryAccess;
|
|
143
|
+
if (typeof id !== "string" || !id || !access?.member.email || pendingFollows.has(id)) return undefined;
|
|
144
|
+
const row = directory.getState().items.find(item => item.id === id);
|
|
145
|
+
if (!row) return undefined;
|
|
146
|
+
const following = withFollowChange(row).isFollowing;
|
|
147
|
+
pendingFollows.add(id);
|
|
148
|
+
try {
|
|
149
|
+
await assertReadAccess(access);
|
|
150
|
+
if (following) await unfollowMember(id); else await followMember(id);
|
|
151
|
+
await assertReadAccess(access);
|
|
152
|
+
// A first-page reload (typing in search, Retry) may have replaced directoryAccess while the write was in
|
|
153
|
+
// flight. The write still stands for the same reader on the same site, so it is recorded; only another
|
|
154
|
+
// reader's list must not take it.
|
|
155
|
+
if (readerKey(access) !== readerKey(directoryAccess)) return undefined;
|
|
156
|
+
followChanges.set(id, !following);
|
|
157
|
+
followHint = "";
|
|
158
|
+
} catch {
|
|
159
|
+
// A failed write belongs to the member who tapped: the control keeps its state and says so.
|
|
160
|
+
if (readerKey(access) !== readerKey(directoryAccess)) return undefined;
|
|
161
|
+
followHint = copyParam("members.followError", "Couldn't update Follow — try again.");
|
|
162
|
+
} finally {
|
|
163
|
+
pendingFollows.delete(id);
|
|
164
|
+
}
|
|
165
|
+
return project();
|
|
166
|
+
},
|
|
119
167
|
changed: () => project(),
|
|
120
168
|
},
|
|
121
169
|
}, { members: "noMembers" }));
|