@wix/himalaya-cli 0.1157.0 → 0.1159.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 +1 -0
- package/guides/himi-skill.md +8 -0
- package/guides/index.json +6 -0
- package/guides/widgets.md +233 -0
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/brandy/config.ts +14 -3
- package/templates/brandy/dev/index.ts +339 -2
- package/templates/brandy/tier5-src/_shared/himi-copy.ts +56 -0
- package/templates/brandy/tier5-src/himi-notification-settings/index.ts +130 -0
- package/templates/brandy/tokens.json +10 -0
- package/templates/community-club/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/restaurant/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/retail-store/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +1 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -6783,6 +6783,7 @@ function walk(node, registry, path2, out2) {
|
|
|
6783
6783
|
var SYSTEM_FONT_FAMILY, TONAL_ELEVATION_ALPHA, COLOR_TOKEN_PROPS, TYPOGRAPHY_TOKEN_PROPS;
|
|
6784
6784
|
var init_tokens = __esm({
|
|
6785
6785
|
"../../core/ts/src/tokens.ts"() {
|
|
6786
|
+
"use strict";
|
|
6786
6787
|
SYSTEM_FONT_FAMILY = "$system";
|
|
6787
6788
|
TONAL_ELEVATION_ALPHA = [0, 0.05, 0.08, 0.11, 0.12, 0.14];
|
|
6788
6789
|
COLOR_TOKEN_PROPS = {
|
package/guides/himi-skill.md
CHANGED
|
@@ -450,6 +450,14 @@ I want to create a mobile app for my business. Follow this skill:
|
|
|
450
450
|
https://apps.wix.com/_api/himi-serve/skill.md
|
|
451
451
|
```
|
|
452
452
|
|
|
453
|
+
## Home-screen widgets
|
|
454
|
+
|
|
455
|
+
Read `himi docs widgets` **before** you write a `defineWidget`. A widget is a frozen poster frame:
|
|
456
|
+
its `render` runs once, at build time, over each service's **initial** `state`. Actions, `fetch` and
|
|
457
|
+
Wix data never reach it, and there is no live-data path yet, so a widget written over fetched data
|
|
458
|
+
ships its placeholder. Design it around initial state, constants or a `timeline`, and deep-link the
|
|
459
|
+
tap into the screen that has the live data.
|
|
460
|
+
|
|
453
461
|
## Native UX evidence gate
|
|
454
462
|
|
|
455
463
|
Before declaring a multi-screen app ready, run `himi ux audit --content . --strict` and resolve its findings. After driving each available native shell, persist the actual checks with `himi ux record`; record an unavailable platform with its reason rather than claiming coverage.
|
package/guides/index.json
CHANGED
|
@@ -190,6 +190,12 @@
|
|
|
190
190
|
"category": "Workers & SDKs",
|
|
191
191
|
"file": "cms-collections.md"
|
|
192
192
|
},
|
|
193
|
+
{
|
|
194
|
+
"id": "widgets",
|
|
195
|
+
"title": "Home-screen widgets (what a widget can show)",
|
|
196
|
+
"category": "Workers & SDKs",
|
|
197
|
+
"file": "widgets.md"
|
|
198
|
+
},
|
|
193
199
|
{
|
|
194
200
|
"id": "wix-sdk-overview",
|
|
195
201
|
"title": "Wix SDK overview (front door)",
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Home-screen widgets
|
|
2
|
+
|
|
3
|
+
A widget is a **frozen poster frame** of your app. It is drawn by the operating system on a home
|
|
4
|
+
screen, lock screen or watch face, and opened with a tap. Your worker never runs
|
|
5
|
+
inside it. This page explains where a widget's content comes from, because that is the part that
|
|
6
|
+
surprises people: **a widget shows what your worker's state is at build time, not what the worker
|
|
7
|
+
later fetches.**
|
|
8
|
+
|
|
9
|
+
## The one rule: a widget renders initial state, once
|
|
10
|
+
|
|
11
|
+
You declare a widget with `defineWidget` in the same worker file as your `defineService`:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { defineWidget } from "@wix/himalaya/widget";
|
|
15
|
+
|
|
16
|
+
defineWidget("<widget id>", {
|
|
17
|
+
family: "ios_home", // which OS surface — see the table below
|
|
18
|
+
size: "small",
|
|
19
|
+
deepLink: "<your app scheme>://screen/<screen id>",
|
|
20
|
+
refresh: { ttlSeconds: 900 },
|
|
21
|
+
render: (services) => ({ /* a component tree of literals */ }),
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
When your app is built or served, the platform **produces** each widget:
|
|
26
|
+
|
|
27
|
+
1. It evaluates your worker in a sandbox and records each `defineService`'s **initial `state`**.
|
|
28
|
+
2. It calls `render(services)` **once, synchronously**, where `services` is
|
|
29
|
+
`{ <service name>: <its initial state> }`.
|
|
30
|
+
3. It freezes the tree `render` returned and serves it as the widget's snapshot.
|
|
31
|
+
|
|
32
|
+
So, inside that sandbox:
|
|
33
|
+
|
|
34
|
+
| What your worker does | What the widget sees |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `state: { … }` in `defineService` | ✅ exactly that object |
|
|
37
|
+
| Constants and plain functions in the file | ✅ available to `render` |
|
|
38
|
+
| An action (`load`, `refresh`, …), sync or `async` | ❌ never runs — the widget keeps the initial state |
|
|
39
|
+
| `fetch`, Wix data queries, any Wix SDK call | ❌ `fetch` throws; nothing it would return arrives |
|
|
40
|
+
| `setTimeout` / `setInterval` | ❌ never fire |
|
|
41
|
+
| `$service:` / `$item` bindings in the tree | ❌ rejected — the snapshot must be literals |
|
|
42
|
+
|
|
43
|
+
`render` must therefore be **synchronous and side-effect free**: no network, no awaiting, no
|
|
44
|
+
bindings, only **resolved literal values**. Reading the clock is the one allowed dependency.
|
|
45
|
+
`Date.now()` works and is evaluated at produce time, so a value computed from it is as fresh as
|
|
46
|
+
the last produce (see [refresh](#refresh-taps-and-previews)). A `render` that throws, or returns a
|
|
47
|
+
tree with a binding in it, makes that widget fail to produce.
|
|
48
|
+
|
|
49
|
+
### There is no live-data path yet
|
|
50
|
+
|
|
51
|
+
Data your worker fetches **cannot** reach a widget today. `refresh.pushTopic` exists in the type
|
|
52
|
+
definitions, but nothing consumes it yet: setting it does not make the widget update when data
|
|
53
|
+
changes. Plan the widget around what you know when the app is built — a call to action, a
|
|
54
|
+
constant, or a schedule (see [timelines](#content-whose-future-you-know-live-time-and-timelines)) — and let the
|
|
55
|
+
tap take the user into the app, where the live data is.
|
|
56
|
+
|
|
57
|
+
## A widget that stays inside the contract
|
|
58
|
+
|
|
59
|
+
This widget shows the studio's name and a fixed call to action, both from initial state. Tapping
|
|
60
|
+
it opens the booking screen, which loads the real schedule.
|
|
61
|
+
|
|
62
|
+
<!-- widget-example: within-contract -->
|
|
63
|
+
```ts
|
|
64
|
+
import { defineService } from "@wix/himalaya/state";
|
|
65
|
+
import { defineWidget } from "@wix/himalaya/widget";
|
|
66
|
+
|
|
67
|
+
defineService("studio", {
|
|
68
|
+
state: { name: "Harbor Yoga", cta: "Book your next class" },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
defineWidget("studio.book", {
|
|
72
|
+
family: "ios_home",
|
|
73
|
+
size: "small",
|
|
74
|
+
deepLink: "harboryoga://screen/book",
|
|
75
|
+
refresh: { ttlSeconds: 86400 },
|
|
76
|
+
render: (services) => {
|
|
77
|
+
const studio = services.studio as { name: string; cta: string };
|
|
78
|
+
return {
|
|
79
|
+
id: "root",
|
|
80
|
+
body: { package: "himalaya", type: "VStack", props: { spacing: 4, children: [
|
|
81
|
+
{ id: "name", body: { package: "himalaya", type: "Text", props: { text: studio.name, style: "headline" } } },
|
|
82
|
+
{ id: "cta", body: { package: "himalaya", type: "Text", props: { text: studio.cta, style: "caption" } } },
|
|
83
|
+
] } },
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
It produces exactly this frozen tree:
|
|
90
|
+
|
|
91
|
+
<!-- widget-example: within-contract.payload -->
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"id": "root",
|
|
95
|
+
"body": { "package": "himalaya", "type": "VStack", "props": { "spacing": 4, "children": [
|
|
96
|
+
{ "id": "name", "body": { "package": "himalaya", "type": "Text", "props": { "text": "Harbor Yoga", "style": "headline" } } },
|
|
97
|
+
{ "id": "cta", "body": { "package": "himalaya", "type": "Text", "props": { "text": "Book your next class", "style": "caption" } } }
|
|
98
|
+
] } }
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## The mistake: a widget over fetched data
|
|
103
|
+
|
|
104
|
+
This is the natural way to write it, and it ships the placeholder:
|
|
105
|
+
|
|
106
|
+
<!-- widget-example: anti-pattern -->
|
|
107
|
+
```ts
|
|
108
|
+
import { defineService } from "@wix/himalaya/state";
|
|
109
|
+
import { defineWidget } from "@wix/himalaya/widget";
|
|
110
|
+
|
|
111
|
+
defineService("latest", {
|
|
112
|
+
state: { title: "Loading…" },
|
|
113
|
+
actions: {
|
|
114
|
+
async load(s) {
|
|
115
|
+
const res = await fetch("https://example.com/latest.json");
|
|
116
|
+
const { title } = (await res.json()) as { title: string };
|
|
117
|
+
return { ...s, title };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
defineWidget("latest.story", {
|
|
123
|
+
family: "ios_home",
|
|
124
|
+
size: "small",
|
|
125
|
+
deepLink: "myapp://screen/home",
|
|
126
|
+
refresh: { ttlSeconds: 900 },
|
|
127
|
+
render: (services) => ({
|
|
128
|
+
id: "w",
|
|
129
|
+
body: { package: "himalaya", type: "Text", props: { text: (services.latest as { title: string }).title } },
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`load` never runs at produce time, so `render` reads the initial state and the widget says
|
|
135
|
+
"Loading…" forever:
|
|
136
|
+
|
|
137
|
+
<!-- widget-example: anti-pattern.payload -->
|
|
138
|
+
```json
|
|
139
|
+
{ "id": "w", "body": { "package": "himalaya", "type": "Text", "props": { "text": "Loading…" } } }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Calling the action yourself at the top level (`svc.load()`) does not help either: at produce time
|
|
143
|
+
every action resolves to the initial state.
|
|
144
|
+
|
|
145
|
+
## Content whose future you know: live time and timelines
|
|
146
|
+
|
|
147
|
+
**Countdowns and clock text: `Text.timeRef`.** A `Text` with
|
|
148
|
+
`timeRef: { at: <epoch seconds>, style: "timer" | "relative" | "date" | "time" | "offset" }`
|
|
149
|
+
shows a time value that overrides the `Text`'s `text`. How live it is depends on the platform:
|
|
150
|
+
|
|
151
|
+
- **iPhone (home, lock, Live Activity)**: the OS ticks it every second, with no refresh and no worker.
|
|
152
|
+
- **Android home screen**: it is formatted only when the widget is redrawn, which the OS
|
|
153
|
+
schedules (typically no more often than every 15 minutes), so do not rely on it to count seconds.
|
|
154
|
+
- **`web_card`**: `timeRef` is ignored and the `Text`'s own `text` is shown, so always set a
|
|
155
|
+
`text` fallback.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
{ id: "starts", body: { package: "himalaya", type: "Text",
|
|
159
|
+
props: { text: "Starts soon", timeRef: { at: Math.floor(Date.now() / 1000) + 3600, style: "timer" } } } }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Discrete future states: `timeline`.** When you know in advance which *different content* the
|
|
163
|
+
widget should show later — today's agenda, a streak that resets at midnight — add a `timeline`. It returns frozen frames, each with the epoch
|
|
164
|
+
second it becomes current, and the OS advances through them with no network and no worker:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
// frame(title, time) returns a component tree; DAY_START is today's midnight in epoch seconds.
|
|
168
|
+
defineWidget("studio.agenda", {
|
|
169
|
+
family: "ios_home",
|
|
170
|
+
size: "small",
|
|
171
|
+
deepLink: "harboryoga://screen/schedule",
|
|
172
|
+
refresh: { ttlSeconds: 900 },
|
|
173
|
+
render: () => frame("Morning flow", "7:00"), // also frame 0 for older clients
|
|
174
|
+
timeline: () => [
|
|
175
|
+
{ startsAt: DAY_START + 7 * 3600, component: frame("Morning flow", "7:00") },
|
|
176
|
+
{ startsAt: DAY_START + 12 * 3600, component: frame("Lunch stretch", "12:00") },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`timeline` follows the same rule as `render`: synchronous, no network, literals only. It is
|
|
182
|
+
evaluated at produce time, so its frames are only as current as the last produce, and it keeps at
|
|
183
|
+
most 48 frames — never bake a second-by-second countdown into it; that is what `timeRef` is for.
|
|
184
|
+
|
|
185
|
+
## Families and sizes
|
|
186
|
+
|
|
187
|
+
| `family` | Surface |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `ios_home` | iPhone home screen |
|
|
190
|
+
| `ios_lock` | iPhone lock screen |
|
|
191
|
+
| `android_home` | Android home screen |
|
|
192
|
+
| `web_card` | A card on the web preview |
|
|
193
|
+
| `ios_live_activity` | iPhone Live Activity |
|
|
194
|
+
| `watch_complication` | Watch face complication |
|
|
195
|
+
|
|
196
|
+
`size` is the OS size hint: `small`, `medium` or `large` for home-screen widgets, and `inline`,
|
|
197
|
+
`circular` or `rectangular` for lock-screen and complication slots.
|
|
198
|
+
|
|
199
|
+
One worker can declare several widgets, one per family and size. To give a widget a *different
|
|
200
|
+
layout* from your phone screen while reading the same services, use `defineView` with a
|
|
201
|
+
`widget:<family>` surface instead. It is the same frozen contract, chosen per surface, and
|
|
202
|
+
`size`, `deepLink`, `refresh` and `timeline` are accepted only on those frozen surfaces.
|
|
203
|
+
|
|
204
|
+
**In-car screens are not a widget family.** The car display is authored with
|
|
205
|
+
`defineView("<view id>", { surface: "car", deepLink, refresh, render })`: frozen like a widget,
|
|
206
|
+
with the same rules for `render`. `deepLink` and `refresh` are required, as for a widget; `size` is
|
|
207
|
+
not, because the car lays out its own rows. It never goes through `defineWidget`.
|
|
208
|
+
|
|
209
|
+
## Refresh, taps and previews
|
|
210
|
+
|
|
211
|
+
- **`refresh.ttlSeconds`** is how long a snapshot may be served before it is produced again. A new
|
|
212
|
+
produce still renders the *initial* state, so a shorter TTL only helps content that is computed
|
|
213
|
+
from the clock at produce time. It does not make fetched data appear.
|
|
214
|
+
- **`refresh.preferredRefreshSeconds`** (optional) is a hint to the OS background-refresh
|
|
215
|
+
scheduler about how often to redraw the widget; when unset, `ttlSeconds` is used. The OS may
|
|
216
|
+
clamp or defer it (Android never redraws more often than every 15 minutes), so treat it as a
|
|
217
|
+
request, not a schedule.
|
|
218
|
+
- **`deepLink`** is where a tap takes the user. Point it at the screen that shows the live version
|
|
219
|
+
of what the widget summarises.
|
|
220
|
+
- **Preview a widget** while you work: `himi run --watch --content .` serves it at
|
|
221
|
+
`GET /v1/widget/<widget id>`, and `GET /v1/widgets` lists every widget the app produces.
|
|
222
|
+
`himi surfaces --measure-widgets` reports which families your package actually produces.
|
|
223
|
+
|
|
224
|
+
## What `himi validate` checks
|
|
225
|
+
|
|
226
|
+
A widget is **declared** when a worker calls `defineWidget` (or `defineView` with a `widget:`
|
|
227
|
+
surface). If a declared widget fails to produce — `render` threw, returned a binding, or used a
|
|
228
|
+
component its family does not allow — `himi validate` reports `widget-not-produced`. It is a
|
|
229
|
+
warning by default and an **error under `--strict`**, so a package cannot ship a widget it
|
|
230
|
+
declared but that renders nothing.
|
|
231
|
+
|
|
232
|
+
A widget that produces the *placeholder* (the mistake above) is not an error: it renders, it just
|
|
233
|
+
shows initial state. Check the snapshot at `GET /v1/widget/<widget id>` before you ship.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1159.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": [
|
|
@@ -67,5 +67,5 @@
|
|
|
67
67
|
"artifactId": "himalaya-cli"
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
|
-
"falconPackageHash": "
|
|
70
|
+
"falconPackageHash": "b894b541da6352858ee72b8651ce6c4885ab2e35c614be481fd38bd5"
|
|
71
71
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -47,7 +47,14 @@ export default defineApp({
|
|
|
47
47
|
actions: "http://localhost:8190/v1/actions",
|
|
48
48
|
sync: "http://localhost:8190/v1/sync",
|
|
49
49
|
},
|
|
50
|
-
|
|
50
|
+
// `push` lets the app ask for notification permission and show the Notifications screen.
|
|
51
|
+
capabilities: ["push"],
|
|
52
|
+
// Ask for notification permission on the first launch. Tapped notifications that carry one of
|
|
53
|
+
// your site's URLs open the page's tab (`navigation.site` below).
|
|
54
|
+
push: {
|
|
55
|
+
registrar: "generic",
|
|
56
|
+
requestPermissionOnLaunch: true,
|
|
57
|
+
},
|
|
51
58
|
// Your brand colours paint the NATIVE chrome (tabs, drawer, splash); the site paints its pages.
|
|
52
59
|
designTokensPath: "./tokens.json",
|
|
53
60
|
// "Ready" means the first page has painted, not just the app shell — otherwise the splash
|
|
@@ -66,11 +73,15 @@ export default defineApp({
|
|
|
66
73
|
navKind: "responsive",
|
|
67
74
|
scheme: "himalaya-__HIMI_APP_SLUG__",
|
|
68
75
|
deepLinkPatterns: [],
|
|
69
|
-
linkableScreens:
|
|
76
|
+
linkableScreens: [
|
|
77
|
+
...siteLinkableScreens(site),
|
|
78
|
+
{ screenId: "himi_notification_settings", isPublic: true, paramKeys: [], parentScreenId: "" },
|
|
79
|
+
],
|
|
70
80
|
// A notification or app link carrying one of your site's URLs opens that page's tab.
|
|
71
81
|
site: siteNavConfig(site),
|
|
72
82
|
tabBar: { items: nav.tabs },
|
|
73
|
-
|
|
83
|
+
// Your site's drawer pages, then the native Notifications screen.
|
|
84
|
+
drawer: { items: [...nav.drawer, { id: "drawer_notifications", screenId: "himi_notification_settings", label: "Notifications", icon: "bell" }] },
|
|
74
85
|
},
|
|
75
86
|
entities: {},
|
|
76
87
|
});
|
|
@@ -5,6 +5,340 @@ import { siteScreens } from "@himalaya/core";
|
|
|
5
5
|
import config, { site } from "../config.js";
|
|
6
6
|
|
|
7
7
|
const SCREENS = siteScreens(site);
|
|
8
|
+
|
|
9
|
+
// The native Notifications screen (a drawer row): whether this app may notify, and the one action
|
|
10
|
+
// that changes it — Turn on (the system prompt when it can still show, otherwise the device
|
|
11
|
+
// settings) or Open settings. It runs on the himi-notification-settings worker in tier5-src/.
|
|
12
|
+
const NOTIFICATIONS = {
|
|
13
|
+
"screenId": "himi_notification_settings",
|
|
14
|
+
"renderer": "sdui_intent",
|
|
15
|
+
"title": "",
|
|
16
|
+
"schemaVersion": 1,
|
|
17
|
+
"cacheControl": {
|
|
18
|
+
"maxAge": 0,
|
|
19
|
+
"staleWhileRevalidate": 0
|
|
20
|
+
},
|
|
21
|
+
"payload": {
|
|
22
|
+
"id": "himi_notification_settings.root",
|
|
23
|
+
"body": {
|
|
24
|
+
"package": "himalaya",
|
|
25
|
+
"type": "VStack",
|
|
26
|
+
"props": {
|
|
27
|
+
"spacing": 16,
|
|
28
|
+
"alignment": "leading",
|
|
29
|
+
"children": [
|
|
30
|
+
{
|
|
31
|
+
"id": "himi_notification_settings.title",
|
|
32
|
+
"body": {
|
|
33
|
+
"package": "himalaya",
|
|
34
|
+
"type": "Text",
|
|
35
|
+
"props": {
|
|
36
|
+
"text": "Notifications",
|
|
37
|
+
"style": "title1"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "himi_notification_settings.status",
|
|
43
|
+
"body": {
|
|
44
|
+
"package": "himalaya",
|
|
45
|
+
"type": "HStack",
|
|
46
|
+
"props": {
|
|
47
|
+
"spacing": 12,
|
|
48
|
+
"children": [
|
|
49
|
+
{
|
|
50
|
+
"id": "",
|
|
51
|
+
"body": {
|
|
52
|
+
"package": "himalaya",
|
|
53
|
+
"type": "Icon",
|
|
54
|
+
"props": {
|
|
55
|
+
"name": "bell",
|
|
56
|
+
"size": 24,
|
|
57
|
+
"tint": "accentPrimary"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"modifiers": {
|
|
61
|
+
"accessibilityRole": "decorative"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "",
|
|
66
|
+
"body": {
|
|
67
|
+
"package": "himalaya",
|
|
68
|
+
"type": "VStack",
|
|
69
|
+
"props": {
|
|
70
|
+
"spacing": 4,
|
|
71
|
+
"alignment": "leading",
|
|
72
|
+
"children": [
|
|
73
|
+
{
|
|
74
|
+
"id": "himi_notification_settings.status_title",
|
|
75
|
+
"body": {
|
|
76
|
+
"package": "himalaya",
|
|
77
|
+
"type": "Text",
|
|
78
|
+
"props": {
|
|
79
|
+
"text": "$service:himiNotificationSettings.statusTitle",
|
|
80
|
+
"style": "title2"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "himi_notification_settings.status_body",
|
|
86
|
+
"body": {
|
|
87
|
+
"package": "himalaya",
|
|
88
|
+
"type": "Text",
|
|
89
|
+
"props": {
|
|
90
|
+
"text": "$service:himiNotificationSettings.statusBody",
|
|
91
|
+
"style": "body",
|
|
92
|
+
"color": "textSecondary",
|
|
93
|
+
"collapseWhenEmpty": true
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"modifiers": {
|
|
105
|
+
"background": {
|
|
106
|
+
"token": "surfaceElevated"
|
|
107
|
+
},
|
|
108
|
+
"border": {
|
|
109
|
+
"width": 1,
|
|
110
|
+
"color": {
|
|
111
|
+
"token": "border"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"cornerRadius": 16,
|
|
115
|
+
"padding": {
|
|
116
|
+
"leading": 16,
|
|
117
|
+
"trailing": 16,
|
|
118
|
+
"top": 16,
|
|
119
|
+
"bottom": 16
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"id": "himi_notification_settings.turn_on",
|
|
125
|
+
"body": {
|
|
126
|
+
"package": "himalaya",
|
|
127
|
+
"type": "Button",
|
|
128
|
+
"props": {
|
|
129
|
+
"title": "Turn on notifications",
|
|
130
|
+
"style": "primary",
|
|
131
|
+
"fullWidth": true,
|
|
132
|
+
"action": {
|
|
133
|
+
"actionId": "tier5.himiNotificationSettings.enable"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"modifiers": {
|
|
138
|
+
"hiddenPath": "$service:himiNotificationSettings.hideTurnOn"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"id": "himi_notification_settings.open_settings",
|
|
143
|
+
"body": {
|
|
144
|
+
"package": "himalaya",
|
|
145
|
+
"type": "Button",
|
|
146
|
+
"props": {
|
|
147
|
+
"title": "Open settings",
|
|
148
|
+
"style": "secondary",
|
|
149
|
+
"fullWidth": true,
|
|
150
|
+
"action": {
|
|
151
|
+
"actionId": "tier5.himiNotificationSettings.openSettings"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"modifiers": {
|
|
156
|
+
"hiddenPath": "$service:himiNotificationSettings.hideOpenSettings"
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"id": "himi_notification_settings.message",
|
|
161
|
+
"body": {
|
|
162
|
+
"package": "himalaya",
|
|
163
|
+
"type": "Text",
|
|
164
|
+
"props": {
|
|
165
|
+
"text": "$service:himiNotificationSettings.message",
|
|
166
|
+
"style": "footnote",
|
|
167
|
+
"color": "textSecondary",
|
|
168
|
+
"collapseWhenEmpty": true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"modifiers": {
|
|
176
|
+
"background": {
|
|
177
|
+
"token": "surfacePrimary"
|
|
178
|
+
},
|
|
179
|
+
"padding": {
|
|
180
|
+
"leading": 16,
|
|
181
|
+
"trailing": 16,
|
|
182
|
+
"top": 18,
|
|
183
|
+
"bottom": 28
|
|
184
|
+
},
|
|
185
|
+
"onAppear": {
|
|
186
|
+
"actionId": "tier5.himiNotificationSettings.refresh"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"logicBundle": {
|
|
191
|
+
"url": "http://localhost:8190/v1/bundles/himi-notification-settings?integrity=",
|
|
192
|
+
"integrity": "",
|
|
193
|
+
"contentType": "application/javascript+ts-bundle",
|
|
194
|
+
"declaredCapabilities": [
|
|
195
|
+
"push"
|
|
196
|
+
],
|
|
197
|
+
"minBinaryVersion": "0.2.0",
|
|
198
|
+
"bundleVersion": "0.0.1"
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
// The offline seed copy has no running worker, so it carries no buttons and no live bindings.
|
|
202
|
+
const NOTIFICATIONS_SEED = {
|
|
203
|
+
"screenId": "himi_notification_settings",
|
|
204
|
+
"renderer": "sdui_intent",
|
|
205
|
+
"title": "",
|
|
206
|
+
"schemaVersion": 1,
|
|
207
|
+
"cacheControl": {
|
|
208
|
+
"maxAge": 0,
|
|
209
|
+
"staleWhileRevalidate": 0
|
|
210
|
+
},
|
|
211
|
+
"payload": {
|
|
212
|
+
"id": "himi_notification_settings.root",
|
|
213
|
+
"body": {
|
|
214
|
+
"package": "himalaya",
|
|
215
|
+
"type": "VStack",
|
|
216
|
+
"props": {
|
|
217
|
+
"spacing": 16,
|
|
218
|
+
"alignment": "leading",
|
|
219
|
+
"children": [
|
|
220
|
+
{
|
|
221
|
+
"id": "himi_notification_settings.title",
|
|
222
|
+
"body": {
|
|
223
|
+
"package": "himalaya",
|
|
224
|
+
"type": "Text",
|
|
225
|
+
"props": {
|
|
226
|
+
"text": "Notifications",
|
|
227
|
+
"style": "title1"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"id": "himi_notification_settings.status",
|
|
233
|
+
"body": {
|
|
234
|
+
"package": "himalaya",
|
|
235
|
+
"type": "HStack",
|
|
236
|
+
"props": {
|
|
237
|
+
"spacing": 12,
|
|
238
|
+
"children": [
|
|
239
|
+
{
|
|
240
|
+
"id": "",
|
|
241
|
+
"body": {
|
|
242
|
+
"package": "himalaya",
|
|
243
|
+
"type": "Icon",
|
|
244
|
+
"props": {
|
|
245
|
+
"name": "bell",
|
|
246
|
+
"size": 24,
|
|
247
|
+
"tint": "accentPrimary"
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
"modifiers": {
|
|
251
|
+
"accessibilityRole": "decorative"
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"id": "",
|
|
256
|
+
"body": {
|
|
257
|
+
"package": "himalaya",
|
|
258
|
+
"type": "VStack",
|
|
259
|
+
"props": {
|
|
260
|
+
"spacing": 4,
|
|
261
|
+
"alignment": "leading",
|
|
262
|
+
"children": [
|
|
263
|
+
{
|
|
264
|
+
"id": "himi_notification_settings.status_title",
|
|
265
|
+
"body": {
|
|
266
|
+
"package": "himalaya",
|
|
267
|
+
"type": "Text",
|
|
268
|
+
"props": {
|
|
269
|
+
"text": "Notifications",
|
|
270
|
+
"style": "title2"
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"id": "himi_notification_settings.status_body",
|
|
276
|
+
"body": {
|
|
277
|
+
"package": "himalaya",
|
|
278
|
+
"type": "Text",
|
|
279
|
+
"props": {
|
|
280
|
+
"text": "Connect to the internet to change notification settings.",
|
|
281
|
+
"style": "body",
|
|
282
|
+
"color": "textSecondary",
|
|
283
|
+
"collapseWhenEmpty": true
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
"modifiers": {
|
|
295
|
+
"background": {
|
|
296
|
+
"token": "surfaceElevated"
|
|
297
|
+
},
|
|
298
|
+
"border": {
|
|
299
|
+
"width": 1,
|
|
300
|
+
"color": {
|
|
301
|
+
"token": "border"
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
"cornerRadius": 16,
|
|
305
|
+
"padding": {
|
|
306
|
+
"leading": 16,
|
|
307
|
+
"trailing": 16,
|
|
308
|
+
"top": 16,
|
|
309
|
+
"bottom": 16
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
]
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
"modifiers": {
|
|
317
|
+
"background": {
|
|
318
|
+
"token": "surfacePrimary"
|
|
319
|
+
},
|
|
320
|
+
"padding": {
|
|
321
|
+
"leading": 16,
|
|
322
|
+
"trailing": 16,
|
|
323
|
+
"top": 18,
|
|
324
|
+
"bottom": 28
|
|
325
|
+
},
|
|
326
|
+
"onAppear": {
|
|
327
|
+
"actionId": "tier5.himiNotificationSettings.refresh"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
"logicBundle": {
|
|
332
|
+
"url": "http://localhost:8190/v1/bundles/himi-notification-settings?integrity=",
|
|
333
|
+
"integrity": "",
|
|
334
|
+
"contentType": "application/javascript+ts-bundle",
|
|
335
|
+
"declaredCapabilities": [
|
|
336
|
+
"push"
|
|
337
|
+
],
|
|
338
|
+
"minBinaryVersion": "0.2.0",
|
|
339
|
+
"bundleVersion": "0.0.1"
|
|
340
|
+
}
|
|
341
|
+
};
|
|
8
342
|
// The home screen is always the `home` page's: `site_home`. A literal on purpose — `himi init`
|
|
9
343
|
// and the agent tooling read `homeScreenId` from this file as written; config.ts refuses a map
|
|
10
344
|
// whose first tab is anything else, so the literal and the tab bar cannot disagree.
|
|
@@ -14,12 +348,15 @@ export default {
|
|
|
14
348
|
homeScreenId: "site_home",
|
|
15
349
|
screens: {
|
|
16
350
|
// A fresh copy per build: descriptors are pure functions of the site block.
|
|
17
|
-
exact:
|
|
351
|
+
exact: {
|
|
352
|
+
...Object.fromEntries(Object.keys(SCREENS).map((id) => [id, () => structuredClone(SCREENS[id])])),
|
|
353
|
+
himi_notification_settings: () => structuredClone(NOTIFICATIONS),
|
|
354
|
+
},
|
|
18
355
|
templated: [],
|
|
19
356
|
},
|
|
20
357
|
actions: [],
|
|
21
358
|
createStore: () => ({}),
|
|
22
359
|
// Every page is seeded, so the whole tab bar and drawer work on a cold offline start (each
|
|
23
360
|
// WebView then shows its own offline state until the network returns).
|
|
24
|
-
seed: { screens: SCREENS, data: {} },
|
|
361
|
+
seed: { screens: { ...SCREENS, himi_notification_settings: NOTIFICATIONS_SEED }, data: {} },
|
|
25
362
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
|
+
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
|
+
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
|
+
// into its onAppear action params as a `copy` JSON object (key → override
|
|
5
|
+
// string, computed from the app contract at descriptor-build time), and the
|
|
6
|
+
// worker reads every user-facing string through `copyParam(key, fallback)`.
|
|
7
|
+
//
|
|
8
|
+
// STASHED (the stashed appear-params idiom, himi-branded-lite-home): the
|
|
9
|
+
// param-carrying onAppear dispatch updates the stash; later arg-less calls
|
|
10
|
+
// (self-kick refreshes, retry buttons, pull-to-refresh) fall back to it, so
|
|
11
|
+
// resolution order can never drop the pack. With no pack the param is absent
|
|
12
|
+
// and every read returns `fallback` verbatim — routing a worker string through
|
|
13
|
+
// copyParam is byte-for-byte behavior-preserving.
|
|
14
|
+
//
|
|
15
|
+
// Pure + Hermes-safe (no host globals, no Node APIs, no imports): bundled into
|
|
16
|
+
// every worker that emits user-facing copy, and copied verbatim into the
|
|
17
|
+
// generated templates' tier5-src/_shared/.
|
|
18
|
+
|
|
19
|
+
let stashedCopy: Record<string, string> = {};
|
|
20
|
+
|
|
21
|
+
/** Absorb the `copy` onAppear param (a JSON object of key → override string)
|
|
22
|
+
* from an action arg. Call first in every action that can receive onAppear
|
|
23
|
+
* params (load/refresh). Non-copy args and malformed packs are ignored — the
|
|
24
|
+
* stock fallbacks keep rendering. */
|
|
25
|
+
export function stashCopy(arg: unknown): void {
|
|
26
|
+
if (!arg || typeof arg !== "object") return;
|
|
27
|
+
const raw = (arg as Record<string, unknown>).copy;
|
|
28
|
+
if (typeof raw !== "string" || !raw) return;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
31
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
32
|
+
const next: Record<string, string> = {};
|
|
33
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
34
|
+
if (typeof value === "string") next[key] = value;
|
|
35
|
+
}
|
|
36
|
+
stashedCopy = next;
|
|
37
|
+
} catch {
|
|
38
|
+
// Malformed pack param — keep the stock copy rather than dying mid-action.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The provisioned override for `key`, or `fallback` (the stock copy) when no
|
|
43
|
+
* pack / no such key reached this worker. */
|
|
44
|
+
export function copyParam(key: string, fallback: string): string {
|
|
45
|
+
const value = stashedCopy[key];
|
|
46
|
+
return typeof value === "string" && value !== "" ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Interpolate `{name}` placeholders in a (possibly overridden) template:
|
|
50
|
+
* `copyTemplate("plans.joinOnSite", "…join {name}.", { name })`. Unknown
|
|
51
|
+
* placeholders resolve to "" so a typo'd override degrades, never crashes. */
|
|
52
|
+
export function copyTemplate(key: string, fallback: string, values: Record<string, string | number>): string {
|
|
53
|
+
return copyParam(key, fallback).replace(/\{([A-Za-z0-9_]+)\}/g, (_all, name: string) =>
|
|
54
|
+
name in values ? String(values[name]) : "",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// himi-notification-settings — the shared Tier-5 worker behind an app's Notifications
|
|
2
|
+
// settings screen: a native feature an app built on its website needs (App Store guideline 4.2).
|
|
3
|
+
//
|
|
4
|
+
// It shows whether this app may send notifications and offers ONE action:
|
|
5
|
+
// - off → "Turn on notifications": the OS prompt when the OS can still show it, else the OS
|
|
6
|
+
// settings. Whether a prompt is still possible is not knowable up front on every platform
|
|
7
|
+
// (Android reports "denied" both before the first ask and after a second refusal), so the
|
|
8
|
+
// action asks first and falls through to the settings when the answer is not a grant.
|
|
9
|
+
// - on → "Open settings", to turn them off there.
|
|
10
|
+
// While the status is loading — or cannot be read at all — neither action shows.
|
|
11
|
+
//
|
|
12
|
+
// The screen must refresh when the user comes BACK from the OS settings, and a worker has
|
|
13
|
+
// no clock. `refresh` (the screen's onAppear) therefore arms a `_poll`: the host suspends it
|
|
14
|
+
// in the background and FIRES IT IMMEDIATELY on foreground, which is exactly the return
|
|
15
|
+
// from Settings; the slow cadence in between is only a backstop. Re-armed on every entry,
|
|
16
|
+
// because restored state arms no timer. A tick that sees notifications newly turned on calls
|
|
17
|
+
// requestPermission(), which (with no prompt, the user has already decided) registers the
|
|
18
|
+
// push token for this session.
|
|
19
|
+
//
|
|
20
|
+
// Copy is overridable through an optional `copy` param on the onAppear action (stashCopy /
|
|
21
|
+
// copyParam), like the account worker; a descriptor that passes none gets the English
|
|
22
|
+
// defaults. Declares the "push" capability.
|
|
23
|
+
|
|
24
|
+
import { defineService, type PollDirective } from "@himalaya/state";
|
|
25
|
+
import { getPermissionStatus, openSettings, requestPermission, type PushPermission } from "@himalaya/push";
|
|
26
|
+
import { stashCopy, copyParam } from "../_shared/himi-copy.js";
|
|
27
|
+
|
|
28
|
+
type Status = PushPermission | "loading" | "error";
|
|
29
|
+
|
|
30
|
+
interface S {
|
|
31
|
+
status: Status;
|
|
32
|
+
statusTitle: string;
|
|
33
|
+
statusBody: string;
|
|
34
|
+
/** Hide flags, true until the status is known (and in the error state), so the live
|
|
35
|
+
* screen shows no action while loading. NOTE an unresolved `$service:` hidden path fails
|
|
36
|
+
* OPEN on every renderer — a descriptor with no running worker would show both — which is
|
|
37
|
+
* why a seeded, worker-less copy of the screen must not carry the buttons at all. */
|
|
38
|
+
hideTurnOn: boolean;
|
|
39
|
+
hideOpenSettings: boolean;
|
|
40
|
+
/** A one-line note after an action (e.g. settings could not be opened here). */
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const POLL: PollDirective = {
|
|
45
|
+
id: "himi-notification-settings",
|
|
46
|
+
intervalMs: 60_000,
|
|
47
|
+
action: "himiNotificationSettings.check",
|
|
48
|
+
immediate: false,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The whole view for a status. Pure, so the test pins every branch. */
|
|
52
|
+
export function viewFor(status: Status): Omit<S, "message"> {
|
|
53
|
+
const on = status === "granted";
|
|
54
|
+
const off = status === "denied" || status === "undetermined";
|
|
55
|
+
const statusTitle = on
|
|
56
|
+
? copyParam("notificationSettings.onTitle", "Notifications are on")
|
|
57
|
+
: off
|
|
58
|
+
? copyParam("notificationSettings.offTitle", "Notifications are off")
|
|
59
|
+
: copyParam("notificationSettings.loadingTitle", "Notifications");
|
|
60
|
+
const statusBody = on
|
|
61
|
+
? copyParam("notificationSettings.onBody", "You'll get updates from this app. You can turn them off in your device settings.")
|
|
62
|
+
: off
|
|
63
|
+
? copyParam("notificationSettings.offBody", "Turn them on to get updates from this app.")
|
|
64
|
+
: status === "error"
|
|
65
|
+
? copyParam("notificationSettings.unavailableBody", "Notification settings aren't available here.")
|
|
66
|
+
: "";
|
|
67
|
+
return { status, statusTitle, statusBody, hideTurnOn: !off, hideOpenSettings: !on };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readStatus(): Promise<Status> {
|
|
71
|
+
try {
|
|
72
|
+
return await getPermissionStatus();
|
|
73
|
+
} catch {
|
|
74
|
+
// A missing or failing push capability is not "undetermined": offering Turn on would
|
|
75
|
+
// lead nowhere. Say it is unavailable and offer nothing.
|
|
76
|
+
return "error";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function open(): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
return await openSettings();
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const OPEN_FAILED = () => copyParam("notificationSettings.openFailed", "Change notifications for this app in your device or browser settings.");
|
|
89
|
+
|
|
90
|
+
const svc = defineService<S, {
|
|
91
|
+
refresh: (s: S, arg?: unknown) => Promise<S & { _poll: PollDirective }>;
|
|
92
|
+
check: (s: S) => Promise<S>;
|
|
93
|
+
enable: (s: S) => Promise<S>;
|
|
94
|
+
openSettings: (s: S) => Promise<S>;
|
|
95
|
+
}>("himiNotificationSettings", {
|
|
96
|
+
state: { ...viewFor("loading"), message: "" },
|
|
97
|
+
actions: {
|
|
98
|
+
// onAppear: read the status and (re-)arm the foreground-refresh poll.
|
|
99
|
+
refresh: async (s, arg) => {
|
|
100
|
+
stashCopy(arg);
|
|
101
|
+
return { ...s, ...viewFor(await readStatus()), message: "", _poll: POLL };
|
|
102
|
+
},
|
|
103
|
+
// One poll tick: idempotent — the status is just re-read.
|
|
104
|
+
check: async (s) => {
|
|
105
|
+
const status = await readStatus();
|
|
106
|
+
if (status === s.status) return s;
|
|
107
|
+
// Turned on in the OS settings while away: register the token for this session.
|
|
108
|
+
if (status === "granted") await requestPermission().catch(() => "granted");
|
|
109
|
+
return { ...s, ...viewFor(status), message: "" };
|
|
110
|
+
},
|
|
111
|
+
// Ask if the OS can still prompt; anything but a grant goes to the OS settings.
|
|
112
|
+
enable: async (s) => {
|
|
113
|
+
let status: Status;
|
|
114
|
+
try {
|
|
115
|
+
status = await requestPermission();
|
|
116
|
+
} catch {
|
|
117
|
+
status = await readStatus();
|
|
118
|
+
}
|
|
119
|
+
if (status === "granted") return { ...s, ...viewFor(status), message: "" };
|
|
120
|
+
const opened = await open();
|
|
121
|
+
return { ...s, ...viewFor(status), message: opened ? "" : OPEN_FAILED() };
|
|
122
|
+
},
|
|
123
|
+
openSettings: async (s) => {
|
|
124
|
+
// Coming back fires the poll, which repaints the status; nothing to do on success.
|
|
125
|
+
return { ...s, message: (await open()) ? "" : OPEN_FAILED() };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export { svc };
|
|
@@ -109,11 +109,21 @@
|
|
|
109
109
|
"weight": 700,
|
|
110
110
|
"line": 1.2
|
|
111
111
|
},
|
|
112
|
+
"title2": {
|
|
113
|
+
"size": 22,
|
|
114
|
+
"weight": 700,
|
|
115
|
+
"line": 1.22
|
|
116
|
+
},
|
|
112
117
|
"body": {
|
|
113
118
|
"size": 16,
|
|
114
119
|
"weight": 400,
|
|
115
120
|
"line": 1.5
|
|
116
121
|
},
|
|
122
|
+
"footnote": {
|
|
123
|
+
"size": 14,
|
|
124
|
+
"weight": 500,
|
|
125
|
+
"line": 1.35
|
|
126
|
+
},
|
|
117
127
|
"caption": {
|
|
118
128
|
"size": 13,
|
|
119
129
|
"weight": 400,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copy-as-configuration for WORKER-emitted strings (docs
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
2
|
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
3
|
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
4
|
// into its onAppear action params as a `copy` JSON object (key → override
|