@acms-subir/core 0.3.28 → 0.3.29
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 +10 -0
- package/cli/mobile-doctor.mjs +4 -1
- package/docs/live-video.md +10 -0
- package/docs/mobile.md +25 -4
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/components/LiveManager.tsx +14 -3
- package/src/components/MobileApp.tsx +11 -2
- package/src/lib/live/service.ts +2 -1
- 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/settings.ts +11 -2
- package/src/pages/api/v1/live.ts +8 -0
- package/src/styles/mobile-app.css +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.29
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
"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.
|
|
10
|
+
|
|
11
|
+
`/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.
|
|
12
|
+
|
|
3
13
|
## 0.3.28
|
|
4
14
|
|
|
5
15
|
### 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
|
@@ -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) {
|
|
@@ -256,13 +259,15 @@ export default function LiveManager() {
|
|
|
256
259
|
}
|
|
257
260
|
await run('config', async () => {
|
|
258
261
|
try {
|
|
259
|
-
const
|
|
262
|
+
const notify = f.mode === 'manual' && f.notify && state.push;
|
|
263
|
+
const s = await api<LiveAdminState & { notified?: { recipients: number } | null; notifyError?: string }>({ action: 'saveConfig', config: configBody(f), notify });
|
|
260
264
|
adopt(s, { config: true });
|
|
265
|
+
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
266
|
setCfgMsg({
|
|
262
|
-
ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}
|
|
267
|
+
ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}.${told}`
|
|
263
268
|
: s.config.mode === 'automatic' ? 'Saved. Your site will show broadcasts from your connected channels.'
|
|
264
269
|
: 'Saved. Live video is hidden on your site.',
|
|
265
|
-
err: '',
|
|
270
|
+
err: s.notifyError ?? '',
|
|
266
271
|
});
|
|
267
272
|
} catch (err) {
|
|
268
273
|
setCfgMsg({ ok: '', err: errText(err) });
|
|
@@ -815,6 +820,12 @@ export default function LiveManager() {
|
|
|
815
820
|
</select>
|
|
816
821
|
</label>
|
|
817
822
|
</div>
|
|
823
|
+
{state.push && (
|
|
824
|
+
<label className="check-row">
|
|
825
|
+
<input type="checkbox" checked={cfg.notify} onChange={(e) => setCfgField('notify', e.target.checked)} />
|
|
826
|
+
<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>
|
|
827
|
+
</label>
|
|
828
|
+
)}
|
|
818
829
|
</fieldset>
|
|
819
830
|
)}
|
|
820
831
|
|
|
@@ -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> {
|
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
|
}
|
|
@@ -6,6 +6,7 @@ import { requestSessionBinding } from '../../../lib/auth/request';
|
|
|
6
6
|
import { Provider } from '../../../lib/live/types';
|
|
7
7
|
import { adminState, beginOAuth, disconnect, 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(),
|
|
@@ -13,7 +14,8 @@ const Input=z.discriminatedUnion('action',[
|
|
|
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(),
|
|
15
16
|
z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
|
|
16
|
-
|
|
17
|
+
// notify: "Notify app users" on a manual link (sends once per link; needs push)
|
|
18
|
+
z.object({action:z.literal('saveConfig'),config:z.unknown(),notify:z.boolean().default(false)}).strict(),
|
|
17
19
|
]);
|
|
18
20
|
export const GET:APIRoute=async({request,cookies,locals,url})=>{
|
|
19
21
|
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);}
|
|
@@ -29,7 +31,14 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
|
|
|
29
31
|
case 'test':await syncConnection(repo,input.provider,locals,true);break;
|
|
30
32
|
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
|
|
31
33
|
case 'disconnect':await disconnect(repo,input.provider,locals);break;
|
|
32
|
-
case 'saveConfig':
|
|
34
|
+
case 'saveConfig':{
|
|
35
|
+
await repo.updateSettings({live:validateConfig(input.config)});
|
|
36
|
+
if(!input.notify)break;
|
|
37
|
+
// the link is saved either way; report whether the notice went out
|
|
38
|
+
let notified:{recipients:number}|null=null,notifyError='';
|
|
39
|
+
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.';}
|
|
40
|
+
return json({...await adminState(repo,url,locals),notified,notifyError});
|
|
41
|
+
}
|
|
33
42
|
case 'selectChannel':{
|
|
34
43
|
const conn=await repo.live.get(input.provider);const selected=conn?.channels.find(c=>c.id===input.channelId);
|
|
35
44
|
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; }
|