@wix/himalaya-cli 0.861.0 → 0.863.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/cli.mjs +3 -6
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +1 -1
- package/templates/beauty-salon/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/beauty-salon/tier5-src/_shared/home-service.ts +23 -3
- package/templates/beauty-salon-owner/dev/index.ts +1 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/branded-lite/dev/index.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/branded-lite/tier5-src/_shared/home-service.ts +23 -3
- package/templates/community-club/dev/index.ts +1 -1
- package/templates/community-club/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/community-club/tier5-src/_shared/home-service.ts +23 -3
- package/templates/community-club-owner/dev/index.ts +1 -1
- package/templates/community-club-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/community-club-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/events-meetups/dev/index.ts +1 -1
- package/templates/events-meetups/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/events-meetups/tier5-src/_shared/home-service.ts +23 -3
- package/templates/events-meetups-owner/dev/index.ts +1 -1
- package/templates/events-meetups-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/events-meetups-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/gym-fitness/dev/index.ts +1 -1
- package/templates/gym-fitness/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/gym-fitness/tier5-src/_shared/home-service.ts +23 -3
- package/templates/gym-fitness-owner/dev/index.ts +1 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/nonprofit-cause/dev/index.ts +1 -1
- package/templates/nonprofit-cause/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/nonprofit-cause/tier5-src/_shared/home-service.ts +23 -3
- package/templates/nonprofit-cause-owner/dev/index.ts +1 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/owner-lite/dev/index.ts +1 -1
- package/templates/owner-lite/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/owner-lite/tier5-src/_shared/home-service.ts +23 -3
- package/templates/podcast-creator/dev/index.ts +1 -1
- package/templates/podcast-creator/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/podcast-creator/tier5-src/_shared/home-service.ts +23 -3
- package/templates/podcast-creator-owner/dev/index.ts +1 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/portfolio-creative/dev/index.ts +1 -1
- package/templates/portfolio-creative/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/portfolio-creative/tier5-src/_shared/home-service.ts +23 -3
- package/templates/portfolio-creative-owner/dev/index.ts +1 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/restaurant/dev/index.ts +1 -1
- package/templates/restaurant/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/restaurant/tier5-src/_shared/home-service.ts +23 -3
- package/templates/restaurant-owner/dev/index.ts +1 -1
- package/templates/restaurant-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/restaurant-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/retail-store/dev/index.ts +1 -1
- package/templates/retail-store/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/retail-store/tier5-src/_shared/home-service.ts +23 -3
- package/templates/retail-store-owner/dev/index.ts +1 -1
- package/templates/retail-store-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/retail-store-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/yoga-studio/dev/index.ts +1 -1
- package/templates/yoga-studio/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/yoga-studio/tier5-src/_shared/home-service.ts +23 -3
- package/templates/yoga-studio-owner/dev/index.ts +1 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/home-service.ts +23 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.863.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -63,5 +63,5 @@
|
|
|
63
63
|
"artifactId": "himalaya-cli"
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
|
-
"falconPackageHash": "
|
|
66
|
+
"falconPackageHash": "c5d242935309a310e8916c1fc607f5bfc7aebc217e81fa9fbaa08ca7"
|
|
67
67
|
}
|
|
@@ -2280,7 +2280,7 @@ const SCREENS = runtimeSetting({
|
|
|
2280
2280
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
2281
2281
|
"params": {
|
|
2282
2282
|
"verticals": "[\"store\",\"bookings\",\"plans\",\"blog\"]",
|
|
2283
|
-
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"This week's schedule\",\"dataSource\":\"member.bookings.schedule\"},{\"id\":\"up_next\",\"label\":\"Up next\",\"dataSource\":\"member.bookings\"},{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"salonLuxe.section0\"},{\"id\":\"section_1\",\"label\":\"__HIMI_APP_NAME__\",\"dataSource\":\"salonLuxe.section1\"}]",
|
|
2283
|
+
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"This week's schedule\",\"dataSource\":\"member.bookings.schedule\",\"emptyWhen\":[\"hideMemberProgress\"]},{\"id\":\"up_next\",\"label\":\"Up next\",\"dataSource\":\"member.bookings\",\"emptyWhen\":[\"hideNextBooking\",\"hidePersonalError\"]},{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"salonLuxe.section0\",\"emptyWhen\":[\"hideHeroImage\"]},{\"id\":\"section_1\",\"label\":\"__HIMI_APP_NAME__\",\"dataSource\":\"salonLuxe.section1\"}]",
|
|
2284
2284
|
"ownerDefaultLayout": "salonLuxe",
|
|
2285
2285
|
"brandAccent": "#A65E58",
|
|
2286
2286
|
"copy": "{\"home.bookVisitCta\":\"Book a visit\",\"home.featuredTitle\":\"Salon picks\",\"home.stylistsCaption\":\"Meet your stylists\",\"home.treatmentsSubtitle\":\"Book your favorite in a couple of taps.\",\"home.treatmentsTitle\":\"Signature treatments\"}"
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -49676,7 +49676,7 @@ const SCREENS = runtimeSetting({
|
|
|
49676
49676
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49677
49677
|
"params": {
|
|
49678
49678
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the salon\",\"ownerHome.briefCta\":\"Show me the chair\",\"ownerHome.briefSubtitle\":\"Who’s in the chair, where the gaps are, and which retail lines are down to the last tube.\",\"ownerHome.briefTitle\":\"Today at the salon\",\"ownerHome.errorLabel\":\"Couldn’t load today’s chair\",\"ownerHome.headerSubtitle\":\"Salon owner · today’s chair\",\"ownerHome.openManage\":\"Open the salon\",\"ownerHome.ordersDaily\":\"Retail sales · daily\",\"ownerHome.statAppointments\":\"Booked in\",\"ownerHome.statOrders\":\"Retail orders\",\"ownerHome.statPosts\":\"Salon notes\",\"ownerHome.statProducts\":\"Retail lines\",\"ownerHome.statRevenue\":\"Takings\",\"ownerHome.statSites\":\"Salons\",\"ownerHome.switchBusiness\":\"Switch salon\"}",
|
|
49679
|
-
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\"},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\"},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\"},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49679
|
+
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49680
49680
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"bookings\",\"members\",\"reviews\",\"reservations\"]"
|
|
49681
49681
|
}
|
|
49682
49682
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -2839,7 +2839,7 @@ const SCREENS = runtimeSetting({
|
|
|
2839
2839
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
2840
2840
|
"params": {
|
|
2841
2841
|
"verticals": "[\"store\",\"bookings\",\"restaurants\",\"programs\",\"plans\",\"feed\",\"members\",\"forum\",\"coupons\",\"notifications\",\"loyalty\",\"reviews\",\"portfolio\",\"reservations\",\"invoices\",\"badges\",\"notifsettings\",\"events\",\"blog\",\"groups\",\"cms\",\"donations\",\"giftCards\",\"videos\",\"podcasts\",\"forms\",\"galleries\",\"inbox\",\"faq\"]",
|
|
2842
|
-
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"This week's schedule\",\"dataSource\":\"member.bookings.schedule\"},{\"id\":\"up_next\",\"label\":\"Up next\",\"dataSource\":\"member.bookings\"},{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"classic.section0\"},{\"id\":\"section_1\",\"label\":\"Featured products\",\"dataSource\":\"classic.section1\"}]",
|
|
2842
|
+
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"This week's schedule\",\"dataSource\":\"member.bookings.schedule\",\"emptyWhen\":[\"hideMemberProgress\"]},{\"id\":\"up_next\",\"label\":\"Up next\",\"dataSource\":\"member.bookings\",\"emptyWhen\":[\"hideNextBooking\",\"hidePersonalError\"]},{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"classic.section0\",\"emptyWhen\":[\"hideFeaturedSkeleton\"]},{\"id\":\"section_1\",\"label\":\"Featured products\",\"dataSource\":\"classic.section1\"}]",
|
|
2843
2843
|
"ownerDefaultLayout": "",
|
|
2844
2844
|
"brandAccent": "#0A66C2"
|
|
2845
2845
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -1917,7 +1917,7 @@ const SCREENS = runtimeSetting({
|
|
|
1917
1917
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
1918
1918
|
"params": {
|
|
1919
1919
|
"verticals": "[\"feed\",\"events\",\"blog\",\"groups\",\"cms\"]",
|
|
1920
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"communityPulse.section0\"},{\"id\":\"section_1\",\"label\":\"Announcement\",\"dataSource\":\"communityPulse.section1\"},{\"id\":\"section_2\",\"label\":\"Groups\",\"dataSource\":\"communityPulse.section2\"},{\"id\":\"section_3\",\"label\":\"Latest activity\",\"dataSource\":\"communityPulse.section3\"}]",
|
|
1920
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"communityPulse.section0\",\"emptyWhen\":[\"noMemberAvatars\"]},{\"id\":\"section_1\",\"label\":\"Announcement\",\"dataSource\":\"communityPulse.section1\",\"emptyWhen\":[\"noAnnouncement\",\"noUpcomingEvents\"]},{\"id\":\"section_2\",\"label\":\"Groups\",\"dataSource\":\"communityPulse.section2\",\"emptyWhen\":[\"noGroupCards\"]},{\"id\":\"section_3\",\"label\":\"Latest activity\",\"dataSource\":\"communityPulse.section3\"}]",
|
|
1921
1921
|
"ownerDefaultLayout": "communityPulse",
|
|
1922
1922
|
"brandAccent": "#6C3FC4",
|
|
1923
1923
|
"copy": "{\"home.activityTitle\":\"Latest activity\",\"home.announcementCaption\":\"Announcement\",\"home.eventsTitle\":\"Upcoming events\",\"home.groupsSubtitle\":\"Find your people.\",\"home.groupsTitle\":\"Groups\",\"home.membersCaption\":\"Our members\",\"home.onboardingBody\":\"Join groups, RSVP to events, and keep up with the community feed — all in one place.\"}"
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -49676,7 +49676,7 @@ const SCREENS = runtimeSetting({
|
|
|
49676
49676
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49677
49677
|
"params": {
|
|
49678
49678
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the club\",\"ownerHome.briefCta\":\"Show me the week\",\"ownerHome.briefSubtitle\":\"Who’s signed up for the next club night, what the kit shop owes, and which notice still needs writing.\",\"ownerHome.briefTitle\":\"This week at the club\",\"ownerHome.errorLabel\":\"Couldn’t load the committee view\",\"ownerHome.headerSubtitle\":\"Club committee · this week\",\"ownerHome.openManage\":\"Open the club\",\"ownerHome.ordersDaily\":\"Kit shop orders · daily\",\"ownerHome.statEvents\":\"Club nights ahead\",\"ownerHome.statOrders\":\"Kit orders\",\"ownerHome.statPosts\":\"Notices\",\"ownerHome.statProducts\":\"Kit items\",\"ownerHome.statRevenue\":\"Club funds\",\"ownerHome.statSites\":\"Clubs\",\"ownerHome.switchBusiness\":\"Switch club\"}",
|
|
49679
|
-
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\"},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\"},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\"},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49679
|
+
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49680
49680
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"events\",\"members\",\"reviews\",\"reservations\"]"
|
|
49681
49681
|
}
|
|
49682
49682
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -2099,7 +2099,7 @@ const SCREENS = runtimeSetting({
|
|
|
2099
2099
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
2100
2100
|
"params": {
|
|
2101
2101
|
"verticals": "[\"feed\",\"events\",\"blog\",\"groups\"]",
|
|
2102
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Next up\",\"dataSource\":\"eventsFirst.section0\"},{\"id\":\"section_1\",\"label\":\"This season\",\"dataSource\":\"eventsFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Discover 3\",\"dataSource\":\"eventsFirst.section2\"},{\"id\":\"section_3\",\"label\":\"Latest from the community\",\"dataSource\":\"eventsFirst.section3\"}]",
|
|
2102
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Next up\",\"dataSource\":\"eventsFirst.section0\",\"emptyWhen\":[\"noNextEvent\"]},{\"id\":\"section_1\",\"label\":\"This season\",\"dataSource\":\"eventsFirst.section1\",\"emptyWhen\":[\"noUpcomingEvents\"]},{\"id\":\"section_2\",\"label\":\"Discover 3\",\"dataSource\":\"eventsFirst.section2\"},{\"id\":\"section_3\",\"label\":\"Latest from the community\",\"dataSource\":\"eventsFirst.section3\"}]",
|
|
2103
2103
|
"ownerDefaultLayout": "eventsFirst",
|
|
2104
2104
|
"brandAccent": "#5B3E96",
|
|
2105
2105
|
"copy": "{\"home.activityTitle\":\"Latest from the community\",\"home.agendaTitle\":\"This season\",\"home.allEventsCta\":\"All events\",\"home.eventsTitle\":\"Upcoming events\",\"home.groupsSubtitle\":\"Find your crew.\",\"home.groupsTitle\":\"Groups\",\"home.membersCaption\":\"Who's coming\",\"home.nextUpCaption\":\"Next up\",\"home.onboardingBody\":\"See what's next, RSVP in a couple of taps, and keep up with the community between events — all in one place.\",\"home.recapsCaption\":\"Event recaps\",\"home.rsvpCta\":\"View event & RSVP\"}"
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|