@acms-subir/core 0.3.28 → 0.3.30
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/CHANGELOG.md +18 -0
- package/cli/mobile-doctor.mjs +4 -1
- package/docs/live-video.md +11 -1
- package/docs/mobile.md +25 -4
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/components/LiveManager.tsx +29 -6
- package/src/components/MobileApp.tsx +11 -2
- package/src/lib/live/service.ts +24 -6
- package/src/lib/live/store.ts +13 -3
- package/src/lib/live/types.ts +2 -0
- package/src/lib/mobile/live.ts +25 -0
- package/src/lib/mobile/openapi.ts +1 -0
- package/src/lib/mobile/schemas.ts +13 -2
- package/src/lib/mobile/site.ts +6 -2
- package/src/lib/push/api.ts +3 -0
- package/src/lib/push/service.ts +101 -12
- package/src/lib/push/store.ts +6 -2
- package/src/lib/push/types.ts +4 -2
- package/src/lib/themeTokens.ts +8 -4
- package/src/pages/api/live/oauth/youtube/callback.ts +8 -11
- package/src/pages/api/live/settings.ts +15 -3
- package/src/pages/api/v1/live.ts +8 -0
- package/src/styles/mobile-app.css +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.30
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aeaed2c: YouTube **Authorize** (Integrations → Live video) now finishes on sites that use a Clerk development instance. Before, Google's redirect back to `/api/live/oauth/youtube/callback` was always signed out there: development instances set `__client_uat` as `SameSite=Strict`, so the browser drops it from the cross-site return, Clerk answers with a handshake, and the page showed "YouTube authorization didn't finish". The same thing could happen on production when the owner spent more than about a minute on Google's screens and the session token lapsed.
|
|
8
|
+
|
|
9
|
+
The callback no longer checks the sign-in. It attaches Google's code, encrypted, to its one-use pending state for up to two minutes. Only the first return counts. It then sends the owner to `/admin/live?live=finish`, where a lapsed sign-in renews like on any admin page. That page finishes with a same-origin request (the new `finishAuthorize` action on `/api/live/settings`, gated like every other owner action), and only the session that started authorization can finish it. The code never appears in a URL again, and nothing changes in Google's setup: the redirect URI is the same. No migration.
|
|
10
|
+
|
|
11
|
+
## 0.3.29
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- da0382f: Apps can show live video. `GET /api/v1/live` returns the snapshot the website's live placements use: `snapshot` (or `null`), `serverNow`, and the owner's "How it looks" choices as `display` (`style`, `bar`, `header`, `dock`), so an app can follow the website's live bar and header switches. The thumbnail URL is absolute. The route is `no-store` with no ETag, so a shared cache never serves a stale "live", and it has the same CORS and read limit as the other v1 routes. While live video is off it answers 404, and `/api/v1/site` reports `features.live: false`. `/api/v1/theme` adds `live` to both palettes: the website's own `--live` color for that scheme, readable on its background. The OpenAPI document describes `/live` with a `LiveStatus` schema.
|
|
16
|
+
|
|
17
|
+
"We're live" push notifications, with push on. **Mobile app → Automatic notifications** has a new rule, **We go live**, off by default. When live video detects a broadcast, phones on the new `live` topic get "We're live" in their language (English, Spanish, French, German or Portuguese) with the broadcast title, and the tap opens the stream. Each broadcast is announced once, keyed `live:<provider>:<id>` in the push ledger, so a stream that drops and comes back, or a server restart, never sends it twice. A broadcast first seen more than 20 minutes after it started isn't announced, and neither is a second one within 3 hours (a stream restarted as a new broadcast). On **Integrations → Live video**, a manual link gets a **Notify app users** checkbox. It sends once per link, and a YouTube video that was already announced isn't announced again.
|
|
18
|
+
|
|
19
|
+
`/api/v1/push/topics` lists `live` ("Live video") while live video isn't off, and devices may keep it while it's off. The topic only reaches devices that choose it. Turning the rule on, or upgrading, never subscribes devices that registered before, as App Store guidelines 4.5.3 and 4.5.4 and Mexico's data protection law expect. Apps should offer it with a one-time prompt. `acms mobile doctor` counts a Live video section as native with `@acms-subir/native` 0.1.5 or later.
|
|
20
|
+
|
|
3
21
|
## 0.3.28
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/cli/mobile-doctor.mjs
CHANGED
|
@@ -298,7 +298,10 @@ async function doctorApp(app, flags, ctx) {
|
|
|
298
298
|
const all = [...texts.values()].join('\n');
|
|
299
299
|
const fallbackOff = !deps['react-native-webview'] || /webSectionFallback=\{\s*false\s*\}/.test(all);
|
|
300
300
|
const partlyOff = /webFallback=\{\s*false\s*\}/.test(all);
|
|
301
|
-
|
|
301
|
+
// built-in sections the native package renders itself (not in the section library), by the version that added them
|
|
302
|
+
const nativeAt = (min) => { const v = versions.native?.match(/^(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number); const m = min.split('.').map(Number); return !!v && (v[0] - m[0] || v[1] - m[1] || v[2] - m[2]) >= 0; };
|
|
303
|
+
const builtIn = new Set(nativeAt('0.1.5') ? ['live'] : []);
|
|
304
|
+
const web = [...used.keys()].filter((t) => !registry.has(t) && !library[t] && !builtIn.has(t)).sort();
|
|
302
305
|
const where = (t) => `${t} (${[...new Set(used.get(t))].join(', ')})`;
|
|
303
306
|
const checked = pages.filter((p) => p.r.status === 200).map((p) => p.slug);
|
|
304
307
|
if (!web.length) c.pass('sections', 'Section coverage', `All ${used.size} section types on ${checked.join(', ')} render natively.`);
|
package/docs/live-video.md
CHANGED
|
@@ -28,7 +28,7 @@ ACMS obtains an app token with the client-credentials flow and checks Get Stream
|
|
|
28
28
|
4. Save the Client ID and Client Secret in ACMS, then choose **Authorize YouTube**. Approve YouTube read-only access using the correct Google/Brand channel identity.
|
|
29
29
|
5. Confirm the available channel, test, and turn the connection on. Selecting another channel requires a new test. A typed channel ID cannot override the authorized identity.
|
|
30
30
|
|
|
31
|
-
The authorization-code flow uses session-bound, one-use state and PKCE. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
31
|
+
The authorization-code flow uses session-bound, one-use state and PKCE. Google's redirect back is a cross-site request that may arrive without a usable sign-in (a Clerk development instance withholds its `SameSite=Strict` cookie, and a session token can lapse while the owner is on Google's screens). It doesn't need one: it only attaches the code, encrypted, to its pending state for up to two minutes, then sends the owner to `/admin/live?live=finish`. That page finishes with a same-origin request from the signed-in session that began authorization; no other session can finish it, and the code never appears in a URL again. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
32
32
|
|
|
33
33
|
External OAuth apps left in **Testing** receive refresh tokens for YouTube scopes that expire after seven days. The UI's Testing/Production choice is owner-reported; ACMS cannot verify the app's publishing status. Follow Google's applicable publishing and verification requirements before relying on a long-running connection. A revoked or expired grant requires reauthorization. See [Google token expiration](https://developers.google.com/identity/protocols/oauth2#expiration).
|
|
34
34
|
|
|
@@ -68,6 +68,16 @@ All placements share the same status. Changing credentials or live mode takes ef
|
|
|
68
68
|
|
|
69
69
|
Player mode loads the official platform iframe only after a visitor clicks. Autoplay is off. The external watch link stays available when playback is blocked. Twitch requires HTTPS, the embedding hostname in `parent`, and at least 400×300 pixels; narrow slots use a watch link. YouTube embeds preserve an origin referrer. The compact links card never embeds a player. Cross-domain embedding of an entire ACMS site may require additional Twitch parent domains and is not configured automatically. See [Twitch embeds](https://dev.twitch.tv/docs/embed/video-and-clips/) and [YouTube players](https://developers.google.com/youtube/iframe_api_reference).
|
|
70
70
|
|
|
71
|
+
## In the app
|
|
72
|
+
|
|
73
|
+
Apps built with `@acms-subir/native` show the same status (see [mobile](mobile.md) and [the API](../../../docs/mobile-api-v1.md#live-video)):
|
|
74
|
+
|
|
75
|
+
- **Status.** `GET /api/v1/live` returns the public snapshot with an absolute thumbnail URL, the server's clock and the owner's **How it looks** choices (`style`, `bar`, `header`, `dock`). It is never cached and answers 404 while live video is **Off**; `site.features.live` tells apps whether to ask. App requests read the stored snapshot, so more phones don't use more YouTube or Twitch quota.
|
|
76
|
+
- **Hook and card.** `useLive()` checks every 30 seconds while the app is in the foreground and again when it comes back. It drops an expired "live" on time even without a connection, using the same rule as the website. `<LiveCard>` renders the poster, split or row layout (the owner's card style unless the app picks one) with the theme's live color (`/api/v1/theme` → `colors.light.live` / `colors.dark.live`). Taps open the broadcast in YouTube, Twitch or the browser. A **Live video** section on a page renders natively; there is no in-app player.
|
|
77
|
+
- **Signals.** The website's **live bar**, **header marker** and **floating card** switches arrive as `display.bar`, `display.header` and `display.dock`, so an app's own bar or tab badge can follow them.
|
|
78
|
+
- **"We're live" notifications** (with push on, `acms({ mobile: { push: true } })`). In **Mobile app → Automatic notifications**, turn on **We go live**. When live video detects a broadcast, phones that turned on the **Live video** topic get "We're live" (in their language) with the broadcast title. Tapping it opens the stream. It is sent once per broadcast, never again when the stream flickers or the server restarts. It is skipped if the broadcast was first seen more than 20 minutes after it started (for example, a connection turned on mid-service), and if another live notice went out in the last 3 hours (a stream restarted as a new broadcast). A **manual** link notifies only when you tick **Notify app users** while saving it, once per link. A YouTube video already announced isn't announced again.
|
|
79
|
+
- **Consent.** The `live` topic reaches only phones that chose it. Turning the feature on never subscribes people who installed the app earlier. The app should ask them once, which App Store rules (4.5.3, 4.5.4) and Mexico's data protection law expect.
|
|
80
|
+
|
|
71
81
|
## Manual, off, fallback and outages
|
|
72
82
|
|
|
73
83
|
**Manual** accepts an HTTPS watch URL and a bounded expiry of up to 12 hours (UI default: four hours). It overrides automatic detection until it expires; automatic detection then resumes. **End manual link now** resumes automatic immediately. **Off** hides every placement; turn off individual provider connections too if you want to stop their background checks.
|
package/docs/mobile.md
CHANGED
|
@@ -113,7 +113,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
113
113
|
|
|
114
114
|
| Hook | For |
|
|
115
115
|
| --- | --- |
|
|
116
|
-
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`) |
|
|
116
|
+
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
|
|
117
117
|
| `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
|
|
118
118
|
| `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
|
|
119
119
|
| `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
|
|
@@ -122,13 +122,14 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
122
122
|
| `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
|
|
123
123
|
| `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
|
|
124
124
|
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
|
|
125
|
+
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
125
126
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
126
127
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
127
128
|
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
|
|
128
129
|
| `useAcms()` | the typed client (e.g. `calendars()`) for your own `useQuery` |
|
|
129
130
|
|
|
130
131
|
Components: `<Sections>`, `<RichText html>`, `<EventDate start end allDay timezone>`,
|
|
131
|
-
`<Button>`, `<ErrorView error retry>`, `<GenericSection>`. Full reference:
|
|
132
|
+
`<Button>`, `<ErrorView error retry>`, `<GenericSection>`, `<LiveCard layout>`, `<LiveTag>`. Full reference:
|
|
132
133
|
`@acms-subir/native` README; API contract: the site's `docs/mobile-api-v1.md`
|
|
133
134
|
and `<site>/api/v1/openapi.json`.
|
|
134
135
|
|
|
@@ -269,8 +270,8 @@ keyed by `locale`.
|
|
|
269
270
|
## Push notifications
|
|
270
271
|
|
|
271
272
|
Optional; the owner sends them from **Admin → Mobile app** (manual messages
|
|
272
|
-
plus automatic ones: new post, new event, "starting soon"). The
|
|
273
|
-
registers and chooses topics; it never sends.
|
|
273
|
+
plus automatic ones: new post, new event, "starting soon", "we're live"). The
|
|
274
|
+
app only registers and chooses topics; it never sends.
|
|
274
275
|
|
|
275
276
|
1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
|
|
276
277
|
default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
|
|
@@ -291,6 +292,26 @@ reader's language when a translation exists. Test on a real device (simulators
|
|
|
291
292
|
get no push); doctor checks the plugin, the project id and that the site has
|
|
292
293
|
push on.
|
|
293
294
|
|
|
295
|
+
**Consent (App Store, US and Mexico).** Apple's guidelines (4.5.3, 4.5.4,
|
|
296
|
+
5.1.2(i)) don't allow unsolicited push messages or requiring notifications, and
|
|
297
|
+
they need in-app consent and an in-app way to opt out of anything promotional.
|
|
298
|
+
Mexico's data law (LFPDPPP) also asks for free, specific, informed consent, so
|
|
299
|
+
a pre-ticked choice doesn't count. So:
|
|
300
|
+
|
|
301
|
+
- A new topic (for example `live`) never reaches existing devices by itself.
|
|
302
|
+
To offer it to people who installed the app before it existed, show a
|
|
303
|
+
one-time in-app prompt ("Get notified when we're live?") and call
|
|
304
|
+
`setTopics([...topics, 'live'])` only on yes. Remember that you asked
|
|
305
|
+
(AsyncStorage), and don't ask again.
|
|
306
|
+
- Starting topics for new installs (`usePushRegistration({ topics })`) are
|
|
307
|
+
fine once the person has allowed notifications from your explained prompt.
|
|
308
|
+
List the topics they'll get on that screen.
|
|
309
|
+
- Keep a Notifications screen with a switch per topic (`usePushTopics()`) and
|
|
310
|
+
an "Off" (`unregister()`). The app must work fully with notifications off.
|
|
311
|
+
- Describe push notifications in the app's privacy policy (aviso de privacidad
|
|
312
|
+
in Mexico) and App Privacy answers. The site stores only the Expo token,
|
|
313
|
+
platform, language and topics, with no name or email.
|
|
314
|
+
|
|
294
315
|
## Editor apps (staff sign-in)
|
|
295
316
|
|
|
296
317
|
A second, separate Expo app for the people who run the site: owners and
|
package/integration.mjs
CHANGED
|
@@ -714,6 +714,8 @@ export default function acms(options = {}) {
|
|
|
714
714
|
['/api/v1/content', 'api/v1/content/index.ts'],
|
|
715
715
|
['/api/v1/content/[id]', 'api/v1/content/[id].ts'],
|
|
716
716
|
['/api/v1/links', 'api/v1/links.ts'],
|
|
717
|
+
// live video status (404 while Integrations → Live video is off)
|
|
718
|
+
['/api/v1/live', 'api/v1/live.ts'],
|
|
717
719
|
['/api/v1/forms/[id]', 'api/v1/forms/[id]/index.ts'],
|
|
718
720
|
['/api/v1/forms/[id]/responses', 'api/v1/forms/[id]/responses.ts'],
|
|
719
721
|
];
|
package/package.json
CHANGED
|
@@ -18,6 +18,8 @@ interface ConnForm { clientId: string; clientSecret: string; channelInput: strin
|
|
|
18
18
|
interface ConfigForm {
|
|
19
19
|
mode: Mode; primary: Provider; fallbackUrl: string; showUpcoming: boolean;
|
|
20
20
|
watchUrl: string; title: string; hours: string; display: Display; dirty: boolean;
|
|
21
|
+
/** "Notify app users" for this save only; never remembered */
|
|
22
|
+
notify: boolean;
|
|
21
23
|
}
|
|
22
24
|
type Display = LiveConfig['display'];
|
|
23
25
|
|
|
@@ -79,6 +81,7 @@ const toConfigForm = (c: LiveConfig, now: number): ConfigForm => ({
|
|
|
79
81
|
hours: c.mode === 'manual' && c.manual.until > now ? 'keep' : '4',
|
|
80
82
|
display: c.display,
|
|
81
83
|
dirty: false,
|
|
84
|
+
notify: false,
|
|
82
85
|
});
|
|
83
86
|
|
|
84
87
|
function rel(ts: number, now: number) {
|
|
@@ -164,11 +167,23 @@ export default function LiveManager() {
|
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
|
|
170
|
+
// Google sent the owner back (?live=finish): the callback parked the code; finish it with this
|
|
171
|
+
// signed-in session (a same-origin POST, so a lapsed sign-in renews on this page first)
|
|
172
|
+
async function finishAuthorize() {
|
|
173
|
+
try {
|
|
174
|
+
adopt(await api<LiveAdminState>({ action: 'finishAuthorize', provider: 'youtube' }));
|
|
175
|
+
setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
setPageMsg({ ok: '', err: `YouTube authorization didn’t finish: ${errText(e)} Try Authorize again.` });
|
|
178
|
+
await load();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
167
182
|
useEffect(() => {
|
|
168
183
|
const q = new URLSearchParams(location.search);
|
|
169
184
|
const r = q.get('live');
|
|
170
|
-
if (r === '
|
|
171
|
-
setPageMsg({ ok: 'YouTube
|
|
185
|
+
if (r === 'finish') {
|
|
186
|
+
setPageMsg({ ok: 'Finishing YouTube authorization…', err: '' });
|
|
172
187
|
} else if (r === 'error') {
|
|
173
188
|
setPageMsg({ ok: '', err: 'YouTube authorization didn’t finish. Check that the redirect URI in Google matches exactly, then try Authorize again.' });
|
|
174
189
|
}
|
|
@@ -179,7 +194,7 @@ export default function LiveManager() {
|
|
|
179
194
|
const qs = q.toString();
|
|
180
195
|
history.replaceState(history.state, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
|
|
181
196
|
}
|
|
182
|
-
void load();
|
|
197
|
+
void (r === 'finish' ? finishAuthorize() : load());
|
|
183
198
|
const tick = setInterval(() => setNow(Date.now()), 30_000);
|
|
184
199
|
return () => clearInterval(tick);
|
|
185
200
|
}, []);
|
|
@@ -256,13 +271,15 @@ export default function LiveManager() {
|
|
|
256
271
|
}
|
|
257
272
|
await run('config', async () => {
|
|
258
273
|
try {
|
|
259
|
-
const
|
|
274
|
+
const notify = f.mode === 'manual' && f.notify && state.push;
|
|
275
|
+
const s = await api<LiveAdminState & { notified?: { recipients: number } | null; notifyError?: string }>({ action: 'saveConfig', config: configBody(f), notify });
|
|
260
276
|
adopt(s, { config: true });
|
|
277
|
+
const told = !notify ? '' : s.notifyError ? '' : s.notified ? ` Notified ${s.notified.recipients.toLocaleString()} app user${s.notified.recipients === 1 ? '' : 's'}.` : ' App users were already notified about this link.';
|
|
261
278
|
setCfgMsg({
|
|
262
|
-
ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}
|
|
279
|
+
ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}.${told}`
|
|
263
280
|
: s.config.mode === 'automatic' ? 'Saved. Your site will show broadcasts from your connected channels.'
|
|
264
281
|
: 'Saved. Live video is hidden on your site.',
|
|
265
|
-
err: '',
|
|
282
|
+
err: s.notifyError ?? '',
|
|
266
283
|
});
|
|
267
284
|
} catch (err) {
|
|
268
285
|
setCfgMsg({ ok: '', err: errText(err) });
|
|
@@ -815,6 +832,12 @@ export default function LiveManager() {
|
|
|
815
832
|
</select>
|
|
816
833
|
</label>
|
|
817
834
|
</div>
|
|
835
|
+
{state.push && (
|
|
836
|
+
<label className="check-row">
|
|
837
|
+
<input type="checkbox" checked={cfg.notify} onChange={(e) => setCfgField('notify', e.target.checked)} />
|
|
838
|
+
<span>Notify app users <span className="hint">(sends “We’re live” once to phones that turned on Live video notifications in your app)</span></span>
|
|
839
|
+
</label>
|
|
840
|
+
)}
|
|
818
841
|
</fieldset>
|
|
819
842
|
)}
|
|
820
843
|
|
|
@@ -35,7 +35,7 @@ function ago(ms: number, now: number) {
|
|
|
35
35
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon' };
|
|
38
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live' };
|
|
39
39
|
|
|
40
40
|
export default function MobileApp() {
|
|
41
41
|
const [state, setState] = useState<AdminPushState | null>(null);
|
|
@@ -279,7 +279,7 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
279
279
|
<section className="card card-pad" aria-labelledby="ma-auto-h">
|
|
280
280
|
<header className="ma-head">
|
|
281
281
|
<h2 id="ma-auto-h">Automatic notifications</h2>
|
|
282
|
-
<p>Sent once, when something
|
|
282
|
+
<p>Sent once, when something is published for the first time or a broadcast starts. Edits never notify again.</p>
|
|
283
283
|
</header>
|
|
284
284
|
<div className="ma-rules">
|
|
285
285
|
<div className="ma-rule">
|
|
@@ -309,6 +309,15 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
|
|
|
309
309
|
</div>
|
|
310
310
|
</>
|
|
311
311
|
)}
|
|
312
|
+
<div className="ma-rule">
|
|
313
|
+
<div className="ma-rule-head"><b>We go live</b><small>“We’re live” and the broadcast title, opens the stream. Once per broadcast, not when it was found more than 20 minutes after it started, and at most once every 3 hours.</small></div>
|
|
314
|
+
<div className="ma-choices">
|
|
315
|
+
<label className="ma-check"><input type="checkbox" checked={auto.live.enabled} onChange={() => setAuto({ ...auto, live: { enabled: !auto.live.enabled } })} /><span>When live video detects a broadcast</span></label>
|
|
316
|
+
</div>
|
|
317
|
+
<p className="hint">{state.liveMode === 'off'
|
|
318
|
+
? <>Live video is off. Turn it on in <a href="/admin/live">Integrations → Live video</a> first.</>
|
|
319
|
+
: 'Only phones that turned on Live video notifications in the app get it. Manual links notify only when you tick “Notify app users” on the link.'}</p>
|
|
320
|
+
</div>
|
|
312
321
|
</div>
|
|
313
322
|
{msg.err && <p className="flash error" role="alert">{msg.err}</p>}
|
|
314
323
|
<div className="form-actions">
|
package/src/lib/live/service.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ApiError } from '../api';
|
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import { encrypt, decrypt, encryptionReady, hash, randomToken } from './crypto';
|
|
4
4
|
import { channels, detect, exchangeGoogleCode, ProviderError, revoke, twitchLogin } from './providers';
|
|
5
|
+
import { PUSH } from '../push/config';
|
|
5
6
|
import { LiveConfig, type Connection, type Credentials, type Provider, type LiveAdminState, type LiveSnapshot, type PublicLiveSnapshot } from './types';
|
|
6
7
|
|
|
7
8
|
export function canonicalOrigin(siteUrl: string, requestUrl: URL) {
|
|
@@ -67,7 +68,7 @@ export async function adminState(repo:ContentRepository,url:URL,locals?:unknown)
|
|
|
67
68
|
let authorized=false;try{const s=decrypt<Credentials>(c.encrypted,c.provider,locals);authorized=c.provider==='twitch'?!!s.clientSecret:!!s.refreshToken;}catch{/* needs reconnect */}
|
|
68
69
|
const {encrypted,lease,leaseUntil,snapshot,upcoming,...visible}=c;
|
|
69
70
|
return {...visible,hasSecret:!!encrypted,authorized,broadcastStatus:snapshot&&snapshot.expiresAt>Date.now()?snapshot.status:'unknown'};
|
|
70
|
-
}),callbackUrl:`${origin}/api/live/oauth/youtube/callback`,encryptionReady:encryptionReady(locals),scheduler:{lastAt,healthy:lastAt>Date.now()-180_000},snapshot:effectiveSnapshot(settings.live,connections)};
|
|
71
|
+
}),callbackUrl:`${origin}/api/live/oauth/youtube/callback`,encryptionReady:encryptionReady(locals),scheduler:{lastAt,healthy:lastAt>Date.now()-180_000},snapshot:effectiveSnapshot(settings.live,connections),push:PUSH.enabled};
|
|
71
72
|
}
|
|
72
73
|
const providerError=(error:unknown)=>error instanceof ProviderError?error:error instanceof ApiError?new ProviderError('auth',error.message):new ProviderError('unavailable','The provider returned an unexpected response. Try the connection test again.');
|
|
73
74
|
export async function syncConnection(repo:ContentRepository,provider:Provider,locals?:unknown,force=false):Promise<boolean> {
|
|
@@ -140,15 +141,32 @@ export async function beginOAuth(repo:ContentRepository,session:string,url:URL,l
|
|
|
140
141
|
const params={client_id:conn.clientId,redirect_uri:callbackUrl,response_type:'code',scope:'https://www.googleapis.com/auth/youtube.readonly',access_type:'offline',prompt:'consent select_account',state,code_challenge:Buffer.from(hash(verifier),'hex').toString('base64url'),code_challenge_method:'S256'};
|
|
141
142
|
Object.entries(params).forEach(([k,v])=>auth.searchParams.set(k,v));return auth.href;
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
type PendingOAuth={verifier:string;callbackUrl:string;code?:string};
|
|
145
|
+
// Google sends the browser back with a cross-site GET, which often carries no usable sign-in: a
|
|
146
|
+
// Clerk development instance withholds its SameSite=Strict cookie, and a session token can expire
|
|
147
|
+
// while the owner is on Google's screens. So the callback only parks the code on its pending state
|
|
148
|
+
// (found by the state value, which only Google's redirect carries); the signed-in admin page then
|
|
149
|
+
// finishes it with a same-origin POST bound to the session that began it.
|
|
150
|
+
export async function receiveOAuthCallback(repo:ContentRepository,url:URL,locals?:unknown) {
|
|
144
151
|
const state=url.searchParams.get('state')??'';
|
|
145
152
|
if(!/^[\w-]{43}$/.test(state))throw new ApiError(400,'Invalid OAuth state. Start authorization again.');
|
|
146
|
-
const stored=await repo.live.consumeState(hash(state),hash(session),Date.now());
|
|
147
|
-
const conn=await repo.live.get('youtube');
|
|
148
|
-
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
149
153
|
if(url.searchParams.has('error'))throw new ApiError(400,'YouTube authorization was not completed. Try again and approve read-only access.');
|
|
150
154
|
const code=url.searchParams.get('code');if(!code||code.length>8192)throw new ApiError(400,'Missing authorization code.');
|
|
151
|
-
const
|
|
155
|
+
const now=Date.now(),stored=await repo.live.pendingState(hash(state),now);
|
|
156
|
+
if(!stored)throw new ApiError(400,'Authorization expired. Start again.');
|
|
157
|
+
const pending=decrypt<PendingOAuth>(stored.encrypted,'oauth',locals);
|
|
158
|
+
if(pending.code)throw new ApiError(409,'This authorization already came back. Start again.');
|
|
159
|
+
if(new URL(pending.callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
160
|
+
// the admin page finishes within seconds; a parked code doesn't wait around
|
|
161
|
+
if(!await repo.live.attachCode(hash(state),stored.encrypted,encrypt({...pending,code},'oauth',locals),Math.min(stored.expiresAt,now+120_000),now))throw new ApiError(409,'This authorization already came back. Start again.');
|
|
162
|
+
}
|
|
163
|
+
/** Exchanges the parked code for tokens. `url` is the signed-in admin request (same origin as the callback). */
|
|
164
|
+
export async function finishOAuth(repo:ContentRepository,session:string,url:URL,locals?:unknown) {
|
|
165
|
+
const rows=session?await repo.live.consumeSessionStates(hash(session),Date.now()):[];
|
|
166
|
+
const stored=rows.map(r=>({...r,...decrypt<PendingOAuth>(r.encrypted,'oauth',locals)})).find(r=>r.code);
|
|
167
|
+
const conn=await repo.live.get('youtube');
|
|
168
|
+
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
169
|
+
const {verifier,callbackUrl,code}=stored as Required<typeof stored>;
|
|
152
170
|
if(new URL(callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
153
171
|
const old=decrypt<Credentials>(conn.encrypted,'youtube',locals);
|
|
154
172
|
try{
|
package/src/lib/live/store.ts
CHANGED
|
@@ -40,9 +40,19 @@ export class LiveStore {
|
|
|
40
40
|
async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
|
|
41
41
|
await (await this.driver()).batch([{sql:'DELETE FROM live_oauth_states WHERE expires_at<? OR session_hash=?',args:[Date.now(),sessionHash]}, {sql:'INSERT INTO live_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)',args:[hash,sessionHash,revision,encrypted,expiresAt]}]);
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Google's redirect finds its pending state by the state value alone (it often arrives without a
|
|
44
|
+
// usable sign-in); only the signed-in admin who began it can finish it (consumeSessionStates).
|
|
45
|
+
async pendingState(hash: string, now: number) {
|
|
46
|
+
const rows = await (await this.driver()).all('SELECT encrypted,expires_at FROM live_oauth_states WHERE hash=? AND expires_at>?', [hash,now]);
|
|
47
|
+
return rows[0] ? {encrypted:String(rows[0].encrypted),expiresAt:Number(rows[0].expires_at)} : null;
|
|
48
|
+
}
|
|
49
|
+
/** Swaps in the payload with Google's code. Compare-and-swap on the old ciphertext: only the first callback counts. */
|
|
50
|
+
async attachCode(hash: string, before: string, after: string, expiresAt: number, now: number) {
|
|
51
|
+
return (await (await this.driver()).all('UPDATE live_oauth_states SET encrypted=?,expires_at=? WHERE hash=? AND encrypted=? AND expires_at>? RETURNING hash', [after,expiresAt,hash,before,now])).length > 0;
|
|
52
|
+
}
|
|
53
|
+
async consumeSessionStates(sessionHash: string, now: number) {
|
|
54
|
+
const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE session_hash=? AND expires_at>? RETURNING revision,encrypted', [sessionHash,now]);
|
|
55
|
+
return rows.map(r => ({revision:Number(r.revision),encrypted:String(r.encrypted)}));
|
|
46
56
|
}
|
|
47
57
|
async heartbeat(now: number) { await (await this.driver()).run('UPDATE live_health SET last_at=? WHERE id=1',[now]); }
|
|
48
58
|
async lastHeartbeat() { return Number((await (await this.driver()).all('SELECT last_at FROM live_health WHERE id=1'))[0]?.last_at ?? 0); }
|
package/src/lib/live/types.ts
CHANGED
|
@@ -48,5 +48,7 @@ export interface LiveAdminState {
|
|
|
48
48
|
config: LiveConfig; connections: PublicConnection[]; callbackUrl: string;
|
|
49
49
|
encryptionReady: boolean; scheduler: { lastAt: number; healthy: boolean };
|
|
50
50
|
snapshot: LiveSnapshot | null;
|
|
51
|
+
/** push notifications are on (acms({ mobile: { push } })): manual links can notify app users */
|
|
52
|
+
push: boolean;
|
|
51
53
|
}
|
|
52
54
|
export const emptySnapshot = (provider: Provider, now: number): LiveSnapshot => ({ status: 'offline', source: 'automatic', provider, id: '', title: '', watchUrl: '', embedId: '', startedAt: 0, checkedAt: now, expiresAt: now + 120_000 });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiError } from '../api';
|
|
3
|
+
import type { ContentRepository } from '../repo/types';
|
|
4
|
+
import { siteOrigin } from '../seo';
|
|
5
|
+
import { effectiveSnapshot, publicSnapshot } from '../live/service';
|
|
6
|
+
import { queryParams } from './http';
|
|
7
|
+
|
|
8
|
+
// Live video for apps: the website's public snapshot (/api/live/status) with
|
|
9
|
+
// an absolute thumbnail URL, plus the owner's "How it looks" choices so apps
|
|
10
|
+
// can follow them. Served no-store: a cached "live" would outlive the stream.
|
|
11
|
+
|
|
12
|
+
export async function getLive(repo: ContentRepository, url: URL) {
|
|
13
|
+
z.object({}).strict().parse(queryParams(url));
|
|
14
|
+
const [settings, connections] = await Promise.all([repo.getSettings(), repo.live.list()]);
|
|
15
|
+
const config = settings.live;
|
|
16
|
+
if (config.mode === 'off') throw new ApiError(404, 'This feature is disabled.');
|
|
17
|
+
const origin = siteOrigin(settings, url);
|
|
18
|
+
const s = publicSnapshot(effectiveSnapshot(config, connections), config);
|
|
19
|
+
const { style, bar, header, dock } = config.display;
|
|
20
|
+
return {
|
|
21
|
+
snapshot: s && { ...s, thumbUrl: s.thumbUrl && new URL(s.thumbUrl, origin).href },
|
|
22
|
+
serverNow: Date.now(),
|
|
23
|
+
display: { style, bar, header, dock },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -50,6 +50,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
50
50
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
51
51
|
['/push/topics', 'Topics an app can offer as notification toggles; requires push', empty, ref('PushTopics')],
|
|
52
52
|
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
53
|
+
['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
|
|
53
54
|
['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
|
|
54
55
|
['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
|
|
55
56
|
];
|
|
@@ -4,11 +4,11 @@ import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAv
|
|
|
4
4
|
const str = z.string(), bool = z.boolean(), strings = z.array(str);
|
|
5
5
|
const date = str.datetime(), locale = str.nullable();
|
|
6
6
|
export const Pagination = z.object({ page: z.number().int(), limit: z.number().int(), nextPage: z.number().int().nullable() });
|
|
7
|
-
const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str });
|
|
7
|
+
const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str, live: str });
|
|
8
8
|
export const Site = z.object({
|
|
9
9
|
name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
10
10
|
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
11
|
-
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool }), apiVersion: z.literal(1),
|
|
11
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }), apiVersion: z.literal(1),
|
|
12
12
|
});
|
|
13
13
|
export const Theme = z.object({
|
|
14
14
|
id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
|
|
@@ -105,6 +105,17 @@ export const Device = z.object({
|
|
|
105
105
|
export const DeviceRegistration = z.object({ created: bool, device: Device });
|
|
106
106
|
export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
|
|
107
107
|
export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
|
|
108
|
+
// times are Unix milliseconds (as on the website); compare them with serverNow, not the device clock
|
|
109
|
+
const ms = z.number().int();
|
|
110
|
+
export const LiveSnapshot = z.object({
|
|
111
|
+
status: z.enum(['live', 'upcoming', 'offline', 'unknown']), source: z.enum(['automatic', 'manual']), provider: z.enum(['youtube', 'twitch', 'facebook', 'other']),
|
|
112
|
+
id: str, title: str, watchUrl: str, embedId: str, startedAt: ms, checkedAt: ms, expiresAt: ms,
|
|
113
|
+
channel: str.optional(), viewers: z.number().int().optional(), thumbUrl: str,
|
|
114
|
+
});
|
|
115
|
+
export const LiveStatus = z.object({
|
|
116
|
+
snapshot: LiveSnapshot.nullable(), serverNow: ms,
|
|
117
|
+
display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
|
|
118
|
+
});
|
|
108
119
|
export const Error = z.object({ version: z.literal(1), error: z.object({
|
|
109
120
|
code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
|
|
110
121
|
message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
|
6
6
|
import { SEARCH } from '../search-config';
|
|
7
7
|
import { siteOrigin } from '../seo';
|
|
8
8
|
import { resolveDesign } from '../theme';
|
|
9
|
-
import { darkPalette } from '../themeTokens';
|
|
9
|
+
import { darkPalette, liveColor } from '../themeTokens';
|
|
10
10
|
import { safeUrl } from './content';
|
|
11
11
|
import { queryParams } from './http';
|
|
12
12
|
import { guestSignInReady } from './guest';
|
|
@@ -35,6 +35,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
35
35
|
// Admin → Mobile app → App access
|
|
36
36
|
bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
|
|
37
37
|
forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
|
|
38
|
+
// Integrations → Live video; GET /api/v1/live answers 404 while it's off
|
|
39
|
+
live: settings.live.mode !== 'off',
|
|
38
40
|
},
|
|
39
41
|
apiVersion: 1,
|
|
40
42
|
};
|
|
@@ -42,9 +44,11 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
42
44
|
export async function getThemeTokens(repo: ContentRepository, url: URL) {
|
|
43
45
|
z.object({}).strict().parse(queryParams(url));
|
|
44
46
|
const { theme, tokens: t } = resolveDesign(await repo.getSettings());
|
|
47
|
+
const light = { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft }, dark = darkPalette(t);
|
|
45
48
|
return {
|
|
46
49
|
id: theme.slug, name: theme.name, scheme: t.scheme,
|
|
47
|
-
|
|
50
|
+
// live: the live-video marker color the website uses (--live)
|
|
51
|
+
colors: { light: { ...light, live: liveColor(t, light, false) }, dark: { ...dark, live: liveColor(t, dark, true) } },
|
|
48
52
|
fonts: { heading: t.headingFont, body: t.bodyFont, mono: t.monoFont }, radius: t.radius,
|
|
49
53
|
};
|
|
50
54
|
}
|
package/src/lib/push/api.ts
CHANGED
|
@@ -108,6 +108,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
108
108
|
stats, topics, auto: settings.push.auto, links, log,
|
|
109
109
|
collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
|
|
110
110
|
calendarEnabled: CALENDAR.enabled,
|
|
111
|
+
// "we're live" needs live video on (Integrations → Live video)
|
|
112
|
+
liveMode: settings.live.mode,
|
|
111
113
|
calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
112
114
|
expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
|
|
113
115
|
};
|
|
@@ -150,6 +152,7 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
|
|
|
150
152
|
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
151
153
|
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
152
154
|
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
155
|
+
live: { enabled: input.auto.live?.enabled === true },
|
|
153
156
|
};
|
|
154
157
|
await repo.updateSettings({ push: { auto } });
|
|
155
158
|
return { auto };
|
package/src/lib/push/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Push notifications: topics, audiences, the Expo sender pipeline, automatic
|
|
2
|
-
// notices (first publish of a post/event, "starting soon") and receipts.
|
|
2
|
+
// notices (first publish of a post/event, "starting soon", "we're live") and receipts.
|
|
3
3
|
import type { ContentRepository } from '../repo/types';
|
|
4
4
|
import type { CalEvent, Calendar, Post, Settings } from '../schema';
|
|
5
5
|
import { CONTENT, collectionUrl } from '../content';
|
|
@@ -8,6 +8,8 @@ import { eventIsListed, eventTimeZone, expandEvents } from '../calendar/events';
|
|
|
8
8
|
import { I18N } from '../i18n';
|
|
9
9
|
import { localizeDoc } from '../translations';
|
|
10
10
|
import { localizedUrl } from '../mobile/locale';
|
|
11
|
+
import { effectiveSnapshot } from '../live/service';
|
|
12
|
+
import type { LiveSnapshot } from '../live/types';
|
|
11
13
|
import { PUSH } from './config';
|
|
12
14
|
import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
|
|
13
15
|
import type { PushNotification, PushSource, PushTopic } from './types';
|
|
@@ -22,8 +24,11 @@ export const RECEIPT_DELAY = 15 * MINUTE;
|
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Topics an app can offer as toggles. `news` = every new post, `events` = every
|
|
25
|
-
* new event
|
|
26
|
-
* than one of them (a single
|
|
27
|
+
* new event, `live` = "we're live" (while live video isn't off). Per-collection
|
|
28
|
+
* and per-calendar topics appear once there is more than one of them (a single
|
|
29
|
+
* one would just repeat news/events). Devices only get the topics they (or the
|
|
30
|
+
* app, at their first registration) choose: a new topic never reaches existing
|
|
31
|
+
* devices on its own.
|
|
27
32
|
*/
|
|
28
33
|
export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]> {
|
|
29
34
|
const collections = Object.entries(CONTENT.collections);
|
|
@@ -34,12 +39,13 @@ export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]>
|
|
|
34
39
|
const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
|
|
35
40
|
if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
|
|
36
41
|
}
|
|
42
|
+
if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: 'Live video', kind: 'general' });
|
|
37
43
|
return out;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
/** Every topic a device may subscribe to (listed or not). */
|
|
46
|
+
/** Every topic a device may subscribe to (listed or not). `live` stays valid while live video is off, so a saved choice survives. */
|
|
41
47
|
export async function knownTopics(repo: ContentRepository): Promise<Set<string>> {
|
|
42
|
-
const set = new Set(['news', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
|
|
48
|
+
const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
|
|
43
49
|
if (CALENDAR.enabled) {
|
|
44
50
|
set.add('events');
|
|
45
51
|
for (const c of await repo.listCalendars()) if (c.visibility === 'public') set.add(`calendar:${c.slug}`);
|
|
@@ -57,12 +63,12 @@ export function deviceLocale(raw: string): string | null {
|
|
|
57
63
|
return match && match !== I18N.defaultLocale ? match : null;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
const STRINGS: Record<string, { startsIn: (m: number) => string; now: string }> = {
|
|
61
|
-
en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now' },
|
|
62
|
-
es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora' },
|
|
63
|
-
fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant' },
|
|
64
|
-
de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt' },
|
|
65
|
-
pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora' },
|
|
66
|
+
const STRINGS: Record<string, { startsIn: (m: number) => string; now: string; live: string }> = {
|
|
67
|
+
en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live' },
|
|
68
|
+
es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo' },
|
|
69
|
+
fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct' },
|
|
70
|
+
de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live' },
|
|
71
|
+
pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo' },
|
|
66
72
|
};
|
|
67
73
|
export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
|
|
68
74
|
|
|
@@ -289,13 +295,96 @@ export async function checkPushReceipts(repo: ContentRepository, opts: PushRunOp
|
|
|
289
295
|
return done.length;
|
|
290
296
|
}
|
|
291
297
|
|
|
292
|
-
|
|
298
|
+
// ---- live video ------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/** A broadcast first seen this long after it started (connection turned on mid-service, scheduler down) is not announced. */
|
|
301
|
+
export const LIVE_LATE_AFTER = 20 * MINUTE;
|
|
302
|
+
/** At most one automatic "we're live" notice in this window: a stream that drops and restarts as a new broadcast never notifies twice. */
|
|
303
|
+
export const LIVE_COOLDOWN = 3 * HOUR;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The ledger key: one notice per broadcast. YouTube video ids are the same
|
|
307
|
+
* whether detected or pasted as a manual link, so the two never both notify;
|
|
308
|
+
* other manual links count once per link expiry.
|
|
309
|
+
*/
|
|
310
|
+
export function liveKey(s: Pick<LiveSnapshot, 'source' | 'provider' | 'id' | 'expiresAt'>): string {
|
|
311
|
+
if (s.source === 'manual' && s.provider !== 'youtube') return `live:manual:${s.expiresAt}`;
|
|
312
|
+
return `live:${s.provider}:${s.id}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** "We're live": localized title, the broadcast title as the body, and the watch link (apps open it outside the app). */
|
|
316
|
+
export function liveNotice(s: LiveSnapshot, settings: Settings): SendInput {
|
|
317
|
+
// a manual link keeps its default title "We’re live": don't say it twice
|
|
318
|
+
const body = clip(s.title && s.title !== STRINGS.en.live ? s.title : settings.siteTitle, 240);
|
|
319
|
+
const words = (locale: string): Composed => ({ title: pushStrings(locale).live, body, url: s.watchUrl });
|
|
320
|
+
return { ...words(displayLocale(settings)), audience: ['live'], source: 'live', sourceKey: liveKey(s),
|
|
321
|
+
data: { kind: 'live', provider: s.provider, id: s.id }, localize: (l) => words(l) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function announceLive(repo: ContentRepository, s: LiveSnapshot, settings: Settings, key: string, run: PushRunOptions & { now: number }) {
|
|
325
|
+
try {
|
|
326
|
+
const n = await sendNotification(repo, liveNotice(s, settings), run);
|
|
327
|
+
await repo.push.markQueue(key, 'sent', n.id, run.now);
|
|
328
|
+
return n;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
await repo.push.markQueue(key, 'failed', null, run.now).catch(() => {});
|
|
331
|
+
throw e;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Automatic "we're live" (Mobile app → automatic notifications): runs after
|
|
337
|
+
* the scheduler's live check. Each detected broadcast is claimed once in the
|
|
338
|
+
* push ledger, so a stream that flickers offline and back, or a restart, can't
|
|
339
|
+
* send it twice. Late detections and restarts inside the cooldown are skipped.
|
|
340
|
+
*/
|
|
341
|
+
export async function sendLiveNotice(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
|
|
342
|
+
if (!PUSH.enabled) return 0;
|
|
343
|
+
const now = opts.now ?? Date.now();
|
|
344
|
+
const [settings, connections] = await Promise.all([repo.getSettings(), repo.live.list()]);
|
|
345
|
+
if (!settings.push.auto.live.enabled) return 0;
|
|
346
|
+
const s = effectiveSnapshot(settings.live, connections, now);
|
|
347
|
+
if (s?.status !== 'live' || s.source !== 'automatic' || !s.id) return 0;
|
|
348
|
+
const key = liveKey(s);
|
|
349
|
+
if (!(await repo.push.claimKey(key, 'live', s.id, s.startedAt || null, now))) return 0;
|
|
350
|
+
const late = s.startedAt > 0 && now - s.startedAt > LIVE_LATE_AFTER;
|
|
351
|
+
if (late || (await repo.push.lastSent('live')) > now - LIVE_COOLDOWN) {
|
|
352
|
+
await repo.push.markQueue(key, 'skipped', null, now);
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
await announceLive(repo, s, settings, key, { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) });
|
|
357
|
+
return 1;
|
|
358
|
+
} catch (e) {
|
|
359
|
+
console.error('acms push: live notification failed', e);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* "Notify app users" on a manual link (Integrations → Live video): one notice
|
|
366
|
+
* for the active manual link, sent right away. Saving the same link again
|
|
367
|
+
* (or a YouTube video already announced) sends nothing. Null when nothing was sent.
|
|
368
|
+
*/
|
|
369
|
+
export async function sendManualLiveNotice(repo: ContentRepository, opts: PushRunOptions = {}): Promise<PushNotification | null> {
|
|
370
|
+
if (!PUSH.enabled) return null;
|
|
371
|
+
const now = opts.now ?? Date.now();
|
|
372
|
+
const settings = await repo.getSettings();
|
|
373
|
+
const s = effectiveSnapshot(settings.live, [], now);
|
|
374
|
+
if (s?.source !== 'manual' || s.status !== 'live') return null;
|
|
375
|
+
const key = liveKey(s);
|
|
376
|
+
if (!(await repo.push.claimKey(key, 'live', s.id, null, now))) return null;
|
|
377
|
+
return announceLive(repo, s, settings, key, { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Scheduler entry: queue, starting soon, we're live, receipts and retention. Disabled → queued notices are skipped. */
|
|
293
381
|
export async function runPushJobs(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
|
|
294
382
|
const now = opts.now ?? Date.now();
|
|
295
383
|
if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
|
|
296
384
|
const run = { ...opts, now };
|
|
297
385
|
let count = await processPushQueue(repo, run);
|
|
298
386
|
count += await sendStartingSoon(repo, run);
|
|
387
|
+
count += await sendLiveNotice(repo, run);
|
|
299
388
|
count += await checkPushReceipts(repo, run);
|
|
300
389
|
await repo.push.purge(now, now - PUSH.retentionDays * DAY);
|
|
301
390
|
return count;
|
package/src/lib/push/store.ts
CHANGED
|
@@ -128,16 +128,20 @@ export class PushStore {
|
|
|
128
128
|
await this.run('UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=?', [status, notificationId, now, key]);
|
|
129
129
|
}
|
|
130
130
|
async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
|
|
131
|
+
/** When a notice of this kind last went out (0 = never). */
|
|
132
|
+
async lastSent(kind: PushQueueRow['kind']): Promise<number> {
|
|
133
|
+
return Number((await this.all(`SELECT MAX(updated_at) AS at FROM push_queue WHERE kind=? AND status='sent'`, [kind]))[0]?.at ?? 0);
|
|
134
|
+
}
|
|
131
135
|
async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
|
|
132
136
|
|
|
133
137
|
// ---- retention ----
|
|
134
|
-
/** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets and old occurrence keys. */
|
|
138
|
+
/** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence and broadcast keys. */
|
|
135
139
|
async purge(now: number, devicesBefore: number): Promise<number> {
|
|
136
140
|
const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
|
|
137
141
|
await (await this.driver()).batch([
|
|
138
142
|
{ sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
|
|
139
143
|
{ sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
|
|
140
|
-
{ sql: `DELETE FROM push_queue WHERE kind
|
|
144
|
+
{ sql: `DELETE FROM push_queue WHERE kind IN ('soon','live') AND updated_at<?`, args: [now - 30 * 86400000] },
|
|
141
145
|
]);
|
|
142
146
|
return gone.length;
|
|
143
147
|
}
|
package/src/lib/push/types.ts
CHANGED
|
@@ -9,6 +9,8 @@ export const PushAuto = z.object({
|
|
|
9
9
|
newEvent: z.object({ calendars: keys.default([]) }).default({}),
|
|
10
10
|
/** "starting soon" reminders, sent `minutes` before each occurrence */
|
|
11
11
|
startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
|
|
12
|
+
/** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
|
|
13
|
+
live: z.object({ enabled: z.boolean().default(false) }).default({}),
|
|
12
14
|
});
|
|
13
15
|
export type PushAuto = z.infer<typeof PushAuto>;
|
|
14
16
|
export const PushSettings = z.object({ auto: PushAuto.default({}) });
|
|
@@ -19,7 +21,7 @@ export interface PushDevice {
|
|
|
19
21
|
id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
|
|
20
22
|
createdAt: number; updatedAt: number; lastSeenAt: number;
|
|
21
23
|
}
|
|
22
|
-
export type PushSource = 'manual' | 'post' | 'event' | 'soon';
|
|
24
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live';
|
|
23
25
|
export interface PushNotification {
|
|
24
26
|
id: string; title: string; body: string; url: string;
|
|
25
27
|
/** ['*'] = everyone */
|
|
@@ -31,7 +33,7 @@ export interface PushNotification {
|
|
|
31
33
|
removed: number;
|
|
32
34
|
error: string; author: string; createdAt: number; finishedAt: number | null;
|
|
33
35
|
}
|
|
34
|
-
export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon'; docId: string; occurrence: number | null; status: string; createdAt: number }
|
|
36
|
+
export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live'; docId: string; occurrence: number | null; status: string; createdAt: number }
|
|
35
37
|
export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
|
|
36
38
|
export interface PushStats {
|
|
37
39
|
devices: number;
|
package/src/lib/themeTokens.ts
CHANGED
|
@@ -138,16 +138,20 @@ export function paletteVars(p: Palette, t: ThemeTokens, dark: boolean): string[]
|
|
|
138
138
|
`--btn:${t.button === 'accent' ? 'var(--accent)' : 'var(--ink)'}`, `--btn-ink:${btnInk}`,
|
|
139
139
|
`--code-bg:${dark ? '#0d0e11' : '#1b1c21'}`,
|
|
140
140
|
`--success:${dark ? '#5cc98e' : '#127442'}`, `--danger:${dark ? '#f07b7b' : '#b42f2f'}`, `--warning:${dark ? '#e3b456' : '#8a5f0a'}`,
|
|
141
|
-
...liveVars(
|
|
141
|
+
...liveVars(liveColor(t, p, dark)),
|
|
142
142
|
`--sh-1:${s1}`, `--sh-2:${s2}`, `--sh-3:${s3}`,
|
|
143
143
|
`color-scheme:${dark ? 'dark' : 'light'}`,
|
|
144
144
|
];
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
// The live signal color stays visible on the canvas (3:1, it's a marker, not
|
|
148
|
-
// body text)
|
|
149
|
-
function
|
|
150
|
-
const
|
|
148
|
+
// body text). The app API sends the same value as colors.<scheme>.live.
|
|
149
|
+
export function liveColor(t: ThemeTokens, p: Palette, dark: boolean): string {
|
|
150
|
+
const raw = dark ? t.dark?.live || (t.live ? ensureContrast(t.live, p.bg, 3, '#ffffff') : '#ff5a4e') : t.live || '#d42f25';
|
|
151
|
+
return ensureContrast(raw, p.bg, 3);
|
|
152
|
+
}
|
|
153
|
+
// …and gets whichever text color reads on it.
|
|
154
|
+
function liveVars(c: string): string[] {
|
|
151
155
|
const on = contrast('#ffffff', c) >= 4.5 || contrast('#ffffff', c) >= contrast('#111214', c) ? '#ffffff' : '#111214';
|
|
152
156
|
return [`--live:${c}`, `--live-on:${on}`];
|
|
153
157
|
}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../../../lib/repo';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { receiveOAuthCallback } from '../../../../../lib/live/service';
|
|
4
|
+
// Google's redirect back is a cross-site GET that may carry no usable sign-in (Clerk development
|
|
5
|
+
// instances, a session token that expired on Google's screens), so it doesn't check one: it parks
|
|
6
|
+
// the code on its one-use pending state, and the signed-in admin page finishes with a same-origin
|
|
7
|
+
// POST (finishAuthorize) bound to the session that began it. The code never enters a URL again.
|
|
8
|
+
export const GET:APIRoute=async({locals,url})=>{
|
|
8
9
|
const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
|
|
9
10
|
try{
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const access=await accessFor(request,cookies,repo);
|
|
13
|
-
if(!session||!access?.can('services.manage'))throw new ApiError(403,'Sign in as someone who manages connected services and start authorization again.');
|
|
14
|
-
await finishOAuth(repo,session,url,locals);
|
|
15
|
-
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
|
|
11
|
+
await receiveOAuthCallback(await getRepo(locals),url,locals);
|
|
12
|
+
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=finish'}});
|
|
16
13
|
}catch{
|
|
17
14
|
// Never reflect callback codes, tokens or provider error bodies into an HTML page or URL.
|
|
18
15
|
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=error'}});
|
|
@@ -4,16 +4,20 @@ import { getRepo } from '../../../lib/repo';
|
|
|
4
4
|
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { requestSessionBinding } from '../../../lib/auth/request';
|
|
6
6
|
import { Provider } from '../../../lib/live/types';
|
|
7
|
-
import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
7
|
+
import { adminState, beginOAuth, disconnect, finishOAuth, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
8
8
|
import { requirePermission } from '../../../lib/permissions/access';
|
|
9
|
+
import { sendManualLiveNotice } from '../../../lib/push/service';
|
|
9
10
|
const Input=z.discriminatedUnion('action',[
|
|
10
11
|
z.object({action:z.literal('saveConnection'),provider:Provider,clientId:z.string().trim().min(1).max(200),clientSecret:z.string().trim().max(8192).default(''),channelInput:z.string().trim().max(2048).default(''),googleStatus:z.enum(['testing','production']).default('testing')}).strict(),
|
|
11
12
|
z.object({action:z.literal('test'),provider:Provider}).strict(),
|
|
12
13
|
z.object({action:z.literal('selectChannel'),provider:z.literal('youtube'),channelId:z.string().min(1).max(200)}).strict(),
|
|
13
14
|
z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
|
|
14
15
|
z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
|
|
16
|
+
// after Google's redirect (?live=finish): exchange the parked code for this signed-in session
|
|
17
|
+
z.object({action:z.literal('finishAuthorize'),provider:z.literal('youtube')}).strict(),
|
|
15
18
|
z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
|
|
16
|
-
|
|
19
|
+
// notify: "Notify app users" on a manual link (sends once per link; needs push)
|
|
20
|
+
z.object({action:z.literal('saveConfig'),config:z.unknown(),notify:z.boolean().default(false)}).strict(),
|
|
17
21
|
]);
|
|
18
22
|
export const GET:APIRoute=async({request,cookies,locals,url})=>{
|
|
19
23
|
try{const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'services.manage');return json(await adminState(repo,url,locals));}catch(e){return apiError(e);}
|
|
@@ -28,8 +32,16 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
|
|
|
28
32
|
case 'saveConnection':await saveConnection(repo,input,locals);break;
|
|
29
33
|
case 'test':await syncConnection(repo,input.provider,locals,true);break;
|
|
30
34
|
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
|
|
35
|
+
case 'finishAuthorize':await finishOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals);break;
|
|
31
36
|
case 'disconnect':await disconnect(repo,input.provider,locals);break;
|
|
32
|
-
case 'saveConfig':
|
|
37
|
+
case 'saveConfig':{
|
|
38
|
+
await repo.updateSettings({live:validateConfig(input.config)});
|
|
39
|
+
if(!input.notify)break;
|
|
40
|
+
// the link is saved either way; report whether the notice went out
|
|
41
|
+
let notified:{recipients:number}|null=null,notifyError='';
|
|
42
|
+
try{const n=await sendManualLiveNotice(repo,{locals,fallbackOrigin:url.origin});notified=n&&{recipients:n.recipients};}catch{notifyError='Saved, but the notification could not be sent. Try again from Mobile app.';}
|
|
43
|
+
return json({...await adminState(repo,url,locals),notified,notifyError});
|
|
44
|
+
}
|
|
33
45
|
case 'selectChannel':{
|
|
34
46
|
const conn=await repo.live.get(input.provider);const selected=conn?.channels.find(c=>c.id===input.channelId);
|
|
35
47
|
if(!conn||!selected)throw new ApiError(400,'Choose a channel from the authorized account.');
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getLive } from '../../../lib/mobile/live';
|
|
2
|
+
import { readRoute } from '../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../lib/mobile/http';
|
|
5
|
+
// no-store: the status changes by the minute and must never be served stale
|
|
6
|
+
export const GET = readRoute(getLive, false);
|
|
7
|
+
export const HEAD = GET;
|
|
8
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
.ma-rule:first-child { border-top: 0; padding-top: 0; }
|
|
75
75
|
.ma-rule-head b { display: block; font-size: 13.5px; }
|
|
76
76
|
.ma-rule-head small { display: block; color: var(--muted); font-size: 12px; margin-top: 1px; }
|
|
77
|
+
.ma-rule .hint { margin: 8px 0 0; }
|
|
77
78
|
.ma-choices { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 8px; }
|
|
78
79
|
.ma-check { display: inline-flex; align-items: center; gap: 8px; min-height: 30px; font-size: 13px; color: var(--text-2); cursor: pointer; }
|
|
79
80
|
.ma-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
|