@acms-subir/core 0.3.31 → 0.3.32
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 +12 -0
- package/docs/mobile.md +5 -3
- package/integration.mjs +5 -2
- package/package.json +1 -1
- package/src/lib/mobile/config.mjs +18 -2
- package/src/lib/mobile/locale.ts +2 -0
- package/src/lib/mobile/openapi.ts +3 -2
- package/src/lib/mobile/schemas.ts +1 -1
- package/src/lib/mobile/site.ts +5 -3
- package/src/lib/push/api.ts +3 -2
- package/src/lib/push/config.ts +3 -1
- package/src/lib/push/service.ts +32 -11
- package/src/lib/push/types.ts +1 -1
- package/src/virtual.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.32
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f1c7635: Sites can add their own push topics, and topic names come in the app's language.
|
|
8
|
+
|
|
9
|
+
- **Site topics.** `acms({ mobile: { push: { topics: [{ id: 'help', label: { en: 'Help needed', es: 'Se necesita ayuda' }, description?: … }] } } })`. A single string is used for every language. They're listed after the built-in topics with `kind: 'site'` (and their `description`), devices may choose them, and staff send to them from Admin → Mobile app. Ids are lowercase letters, numbers, `-` and `_`. `news`, `events` and `live` are reserved, and there can be up to 20. Like every topic, they never reach devices that didn't choose them.
|
|
10
|
+
- **Names in the app's language.** `GET /api/v1/push/topics?locale=es` names the built-in topics in that language (News/Noticias, Events/Eventos, Live video/Video en vivo; also French, German and Portuguese) and uses the site's words for its own. Missing words fall back to the main language, and the response says which `locale` it used.
|
|
11
|
+
- **Legal links in the app's language.** `GET /api/v1/site?locale=es` returns `legal.privacy` / `legal.terms` for the Spanish pages (`/es/privacy`), so apps no longer add `/es` themselves.
|
|
12
|
+
|
|
13
|
+
Unknown locales return 400 on both routes, as on other localized routes.
|
|
14
|
+
|
|
3
15
|
## 0.3.31
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/docs/mobile.md
CHANGED
|
@@ -125,7 +125,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
125
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>` |
|
|
126
126
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
127
127
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
128
|
-
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
|
|
128
|
+
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
|
|
129
129
|
| `useAcms()` | the typed client (e.g. `calendars()`) for your own `useQuery` |
|
|
130
130
|
|
|
131
131
|
Components: `<Sections>`, `<RichText html>`, `<EventDate start end allDay timezone>`,
|
|
@@ -301,8 +301,10 @@ a pre-ticked choice doesn't count. So:
|
|
|
301
301
|
- A new topic (for example `live`) never reaches existing devices by itself.
|
|
302
302
|
To offer it to people who installed the app before it existed, show a
|
|
303
303
|
one-time in-app prompt ("Get notified when we're live?") and call
|
|
304
|
-
`setTopics([...topics, 'live'])` only on yes.
|
|
305
|
-
|
|
304
|
+
`setTopics([...topics, 'live'])` only on yes. `useNewPushTopics({ baseline })`
|
|
305
|
+
lists the topics added since the person last saw the list (pass `baseline`:
|
|
306
|
+
the topic ids your older app version offered) and `markSeen()` remembers
|
|
307
|
+
the answer on the device, so you ask once.
|
|
306
308
|
- Starting topics for new installs (`usePushRegistration({ topics })`) are
|
|
307
309
|
fine once the person has allowed notifications from your explained prompt.
|
|
308
310
|
List the topics they'll get on that screen.
|
package/integration.mjs
CHANGED
|
@@ -595,10 +595,13 @@ function acmsVitePlugin(userConfig) {
|
|
|
595
595
|
* Home page content per language is authored in /admin as a normal page
|
|
596
596
|
* marked "home" with that language selected. Omit for monolingual sites —
|
|
597
597
|
* nothing changes.
|
|
598
|
-
* @param {{ios?: {teamId: string, bundleId: string, paths?: string[]}, android?: {package: string, sha256CertFingerprints: string[]}, push?: boolean | {enabled?: boolean, retentionDays?: number}, editor?: {redirectUris: string[], name?: string, accessTokenMinutes?: number, refreshTokenDays?: number}}} [options.mobile]
|
|
598
|
+
* @param {{ios?: {teamId: string, bundleId: string, paths?: string[]}, android?: {package: string, sha256CertFingerprints: string[]}, push?: boolean | {enabled?: boolean, retentionDays?: number, topics?: Array<{id: string, label: string | Record<string, string>, description?: string | Record<string, string>}>}, editor?: {redirectUris: string[], name?: string, accessTokenMinutes?: number, refreshTokenDays?: number}}} [options.mobile]
|
|
599
599
|
* Optional iOS Universal Links and Android App Links associations, and push
|
|
600
600
|
* notifications for the site's own Expo apps (`push: true`; off by default):
|
|
601
|
-
* /api/v1/devices, /api/v1/push/topics and Admin → Mobile app. `
|
|
601
|
+
* /api/v1/devices, /api/v1/push/topics and Admin → Mobile app. `topics` adds
|
|
602
|
+
* the site's own topics (e.g. `{ id: 'help', label: { en: 'Help needed', es:
|
|
603
|
+
* 'Se necesita ayuda' } }`): apps offer them, staff send to them; they never
|
|
604
|
+
* reach devices that didn't choose them. `editor:
|
|
602
605
|
* { redirectUris, name? }` turns on editor apps: staff sign in through the
|
|
603
606
|
* browser (/admin/app-auth, OAuth 2.0 + PKCE; only the listed redirect URIs
|
|
604
607
|
* receive codes) and edit through the bearer-token /api/v1/admin/* API.
|
package/package.json
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
// words in each site language ({ en: 'Help needed', es: 'Se necesita ayuda' }), or one string for all
|
|
4
|
+
const words = (max) => z.union([z.string().trim().min(1).max(max), z.record(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/, 'Use language codes such as en or es-MX.'), z.string().trim().min(1).max(max))
|
|
5
|
+
.refine((o) => Object.keys(o).length > 0, 'Give at least one language.')]);
|
|
6
|
+
const RESERVED_TOPICS = new Set(['news', 'events', 'live']);
|
|
7
|
+
// a topic the site adds (registrations, help…): offered in the app, sent to from Admin → Mobile app
|
|
8
|
+
const SiteTopic = z.object({
|
|
9
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/, 'Use lowercase letters, numbers, - and _ (up to 40), e.g. help or registrations.')
|
|
10
|
+
.refine((id) => !RESERVED_TOPICS.has(id), 'news, events and live are built-in topics.'),
|
|
11
|
+
label: words(60),
|
|
12
|
+
description: words(200).optional(),
|
|
13
|
+
}).strict();
|
|
14
|
+
|
|
3
15
|
export const MobileConfig = z.object({
|
|
4
16
|
ios: z.object({
|
|
5
17
|
teamId: z.string().regex(/^[A-Z0-9]{10}$/, 'Use a 10-character Apple team ID.'),
|
|
@@ -15,6 +27,7 @@ export const MobileConfig = z.object({
|
|
|
15
27
|
push: z.union([z.boolean(), z.object({
|
|
16
28
|
enabled: z.boolean().default(true),
|
|
17
29
|
retentionDays: z.number().int().min(30).max(730).default(270),
|
|
30
|
+
topics: z.array(SiteTopic).max(20).refine((ts) => new Set(ts.map((t) => t.id)).size === ts.length, 'Each topic id once.').default([]),
|
|
18
31
|
}).strict()]).optional(),
|
|
19
32
|
// Editor apps (staff sign in through the browser, OAuth 2.0 + PKCE). Off
|
|
20
33
|
// unless redirect URIs are listed: only these exact URIs receive codes.
|
|
@@ -52,8 +65,11 @@ export function resolveMobile(value) {
|
|
|
52
65
|
const result = MobileConfig.safeParse(value === undefined ? {} : value);
|
|
53
66
|
if (!result.success) throw new Error(`acms: ${result.error.issues.map(i => `mobile.${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
54
67
|
const { push, editor, ...rest } = result.data;
|
|
55
|
-
const options = typeof push === 'object' ? push : { enabled: push === true, retentionDays: 270 };
|
|
56
|
-
|
|
68
|
+
const options = typeof push === 'object' ? push : { enabled: push === true, retentionDays: 270, topics: [] };
|
|
69
|
+
// one string = every language ('*')
|
|
70
|
+
const all = (w) => (w === undefined ? {} : typeof w === 'string' ? { '*': w } : w);
|
|
71
|
+
const topics = options.topics.map((t) => ({ id: t.id, label: all(t.label), description: all(t.description) }));
|
|
72
|
+
return { ...rest, push: { enabled: options.enabled, retentionDays: options.retentionDays, topics },
|
|
57
73
|
editor: editor ? { enabled: true, name: editor.name ?? '', redirectUris: [...new Set(editor.redirectUris)], accessTokenMinutes: editor.accessTokenMinutes, refreshTokenDays: editor.refreshTokenDays }
|
|
58
74
|
: { enabled: false, name: '', redirectUris: [], accessTokenMinutes: 60, refreshTokenDays: 90 } };
|
|
59
75
|
}
|
package/src/lib/mobile/locale.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { ApiError } from '../api';
|
|
|
3
3
|
import { I18N, localePath } from '../i18n';
|
|
4
4
|
|
|
5
5
|
export const localeQuery = z.string().min(1).max(35).optional();
|
|
6
|
+
/** routes whose only option is the language (/site, /push/topics) */
|
|
7
|
+
export const localeOnlyQuery = z.object({ locale: localeQuery }).strict();
|
|
6
8
|
export function mobileLocale(value?: string) {
|
|
7
9
|
if (value && !I18N.locales.includes(value)) throw new ApiError(400, 'Unknown locale.');
|
|
8
10
|
return value ?? (I18N.defaultLocale || null);
|
|
@@ -10,6 +10,7 @@ import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
|
10
10
|
import { formResponseInput } from './forms';
|
|
11
11
|
import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
12
12
|
import { deviceInput, deviceUpdate } from '../push/api';
|
|
13
|
+
import { localeOnlyQuery } from './locale';
|
|
13
14
|
import { pageQuery, queryParams } from './http';
|
|
14
15
|
import * as schemas from './schemas';
|
|
15
16
|
import { editorPaths, editorSchemas, editorSecuritySchemes } from '../editor/openapi';
|
|
@@ -37,7 +38,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
37
38
|
empty.parse(queryParams(url));
|
|
38
39
|
const paths: Record<string, unknown> = {};
|
|
39
40
|
const reads: [string, string, z.AnyZodObject, unknown, boolean?][] = [
|
|
40
|
-
['/site', 'Site identity, navigation, languages and
|
|
41
|
+
['/site', 'Site identity, navigation, languages, features and legal links (locale: links in that language)', localeOnlyQuery, ref('Site')],
|
|
41
42
|
['/theme', 'Active Design tokens including overrides', empty, ref('Theme')],
|
|
42
43
|
['/pages/home', 'Published home page and resolved sections', pagesQuery, ref('Page')],
|
|
43
44
|
['/pages/{slug}', 'Published page and resolved sections (slug may contain slashes)', pagesQuery, ref('Page')],
|
|
@@ -48,7 +49,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
48
49
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
49
50
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
50
51
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
51
|
-
['/push/topics', 'Topics an app can offer as notification toggles; requires push',
|
|
52
|
+
['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
|
|
52
53
|
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
53
54
|
['/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],
|
|
54
55
|
['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
|
|
@@ -105,7 +105,7 @@ export const Device = z.object({
|
|
|
105
105
|
});
|
|
106
106
|
export const DeviceRegistration = z.object({ created: bool, device: Device });
|
|
107
107
|
export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
|
|
108
|
-
export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
|
|
108
|
+
export const PushTopics = z.object({ locale, topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar', 'site']), description: str.optional() })) });
|
|
109
109
|
// times are Unix milliseconds (as on the website); compare them with serverNow, not the device clock
|
|
110
110
|
const ms = z.number().int();
|
|
111
111
|
export const LiveSnapshot = z.object({
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -9,14 +9,16 @@ import { resolveDesign } from '../theme';
|
|
|
9
9
|
import { darkPalette, liveColor } from '../themeTokens';
|
|
10
10
|
import { safeUrl } from './content';
|
|
11
11
|
import { queryParams } from './http';
|
|
12
|
+
import { localeOnlyQuery, localizedUrl, mobileLocale } from './locale';
|
|
12
13
|
import { guestSignInReady } from './guest';
|
|
13
14
|
import { LEGAL } from '../legal/config';
|
|
14
15
|
|
|
15
16
|
export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
|
|
16
|
-
|
|
17
|
+
// locale: the legal links point at that language's pages (/es/privacy)
|
|
18
|
+
const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
17
19
|
const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
|
|
18
20
|
// the privacy notice and terms: the standard pages, or published pages at those slugs
|
|
19
|
-
const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? `${siteOrigin(settings, url)}/${slug}
|
|
21
|
+
const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? localizedUrl(`${siteOrigin(settings, url)}/${slug}`, siteOrigin(settings, url), locale) : null);
|
|
20
22
|
const origin = siteOrigin(settings, url);
|
|
21
23
|
const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
|
|
22
24
|
const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
|
|
@@ -41,7 +43,7 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
41
43
|
// Integrations → Live video; GET /api/v1/live answers 404 while it's off
|
|
42
44
|
live: settings.live.mode !== 'off',
|
|
43
45
|
},
|
|
44
|
-
// in
|
|
46
|
+
// in the requested language (?locale=es → /es/privacy)
|
|
45
47
|
legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
|
|
46
48
|
apiVersion: 1,
|
|
47
49
|
};
|
package/src/lib/push/api.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CONTENT, collectionUrl } from '../content';
|
|
|
8
8
|
import { CALENDAR, eventUrl } from '../calendar/config';
|
|
9
9
|
import { eventIsListed } from '../calendar/events';
|
|
10
10
|
import { queryParams } from '../mobile/http';
|
|
11
|
+
import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
|
|
11
12
|
import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
|
|
12
13
|
import { expoOptions } from './expo';
|
|
13
14
|
import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
|
|
@@ -72,8 +73,8 @@ export async function unregisterDevice(repo: ContentRepository, rawToken: string
|
|
|
72
73
|
}
|
|
73
74
|
export async function listPushTopics(repo: ContentRepository, url: URL) {
|
|
74
75
|
requirePush();
|
|
75
|
-
|
|
76
|
-
return { topics: await pushTopics(repo) };
|
|
76
|
+
const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
77
|
+
return { locale, topics: await pushTopics(repo, locale) };
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
// ---- admin -------------------------------------------------------------------
|
package/src/lib/push/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Push notifications config, set per project via the integration:
|
|
2
|
-
// acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270 } }
|
|
2
|
+
// acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270, topics: [...] } }
|
|
3
3
|
// Off by default. When off, the device/topic endpoints and the admin Mobile
|
|
4
4
|
// app page are not registered at all (like other disabled modules), and the
|
|
5
5
|
// scheduler just marks any queued automatic notifications as skipped.
|
|
@@ -10,6 +10,8 @@ import { ApiError } from '../api';
|
|
|
10
10
|
export const PUSH = {
|
|
11
11
|
enabled: userConfig.mobile?.push?.enabled === true,
|
|
12
12
|
retentionDays: userConfig.mobile?.push?.retentionDays ?? 270,
|
|
13
|
+
/** topics the site adds (config mobile.push.topics); labels per language, '*' = every language */
|
|
14
|
+
topics: [...(userConfig.mobile?.push?.topics ?? [])] as Array<{ id: string; label: Record<string, string>; description: Record<string, string> }>,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export function requirePush() {
|
package/src/lib/push/service.ts
CHANGED
|
@@ -22,30 +22,51 @@ export const RECEIPT_DELAY = 15 * MINUTE;
|
|
|
22
22
|
|
|
23
23
|
// ---- topics ------------------------------------------------------------------
|
|
24
24
|
|
|
25
|
+
// names of the built-in topics; other languages show English
|
|
26
|
+
const TOPIC_WORDS: Record<string, { news: string; events: string; live: string }> = {
|
|
27
|
+
en: { news: 'News', events: 'Events', live: 'Live video' },
|
|
28
|
+
es: { news: 'Noticias', events: 'Eventos', live: 'Video en vivo' },
|
|
29
|
+
fr: { news: 'Actualités', events: 'Événements', live: 'Vidéo en direct' },
|
|
30
|
+
de: { news: 'Neuigkeiten', events: 'Veranstaltungen', live: 'Live-Video' },
|
|
31
|
+
pt: { news: 'Notícias', events: 'Eventos', live: 'Vídeo ao vivo' },
|
|
32
|
+
};
|
|
33
|
+
/** A site topic's words in a language: exact code, its base ("es-MX" → "es"), every language ('*'), the site's main language, then any. */
|
|
34
|
+
export function topicWords(words: Record<string, string>, locale: string): string {
|
|
35
|
+
const base = locale.toLowerCase().split('-')[0];
|
|
36
|
+
return words[locale] ?? words[base] ?? words['*'] ?? (I18N.defaultLocale ? words[I18N.defaultLocale] : undefined) ?? Object.values(words)[0] ?? '';
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/**
|
|
26
|
-
* Topics an app can offer as toggles
|
|
27
|
-
* new
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
40
|
+
* Topics an app can offer as toggles, named in `locale` (default: the site's
|
|
41
|
+
* main language). `news` = every new post, `events` = every new event, `live` =
|
|
42
|
+
* "we're live" (while live video isn't off), then the site's own topics
|
|
43
|
+
* (acms({ mobile: { push: { topics } } })). Per-collection and per-calendar
|
|
44
|
+
* topics appear once there is more than one of them (a single one would just
|
|
45
|
+
* repeat news/events). Devices only get the topics they (or the app, at their
|
|
46
|
+
* first registration) choose: a new topic never reaches existing devices on its own.
|
|
32
47
|
*/
|
|
33
|
-
export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]> {
|
|
48
|
+
export async function pushTopics(repo: ContentRepository, locale?: string | null): Promise<PushTopic[]> {
|
|
49
|
+
const lang = locale || I18N.defaultLocale || (await repo.getSettings()).locale || 'en';
|
|
50
|
+
const w = TOPIC_WORDS[lang.toLowerCase().split('-')[0]] ?? TOPIC_WORDS.en;
|
|
34
51
|
const collections = Object.entries(CONTENT.collections);
|
|
35
|
-
const out: PushTopic[] = [{ id: 'news', label:
|
|
52
|
+
const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
|
|
36
53
|
if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
|
|
37
54
|
if (CALENDAR.enabled) {
|
|
38
|
-
out.push({ id: 'events', label:
|
|
55
|
+
out.push({ id: 'events', label: w.events, kind: 'general' });
|
|
39
56
|
const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
|
|
40
57
|
if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
|
|
41
58
|
}
|
|
42
|
-
if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label:
|
|
59
|
+
if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
|
|
60
|
+
for (const t of PUSH.topics) {
|
|
61
|
+
const description = topicWords(t.description, lang);
|
|
62
|
+
out.push({ id: t.id, label: topicWords(t.label, lang), kind: 'site', ...(description ? { description } : {}) });
|
|
63
|
+
}
|
|
43
64
|
return out;
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
/** Every topic a device may subscribe to (listed or not). `live` stays valid while live video is off, so a saved choice survives. */
|
|
47
68
|
export async function knownTopics(repo: ContentRepository): Promise<Set<string>> {
|
|
48
|
-
const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
|
|
69
|
+
const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`), ...PUSH.topics.map((t) => t.id)]);
|
|
49
70
|
if (CALENDAR.enabled) {
|
|
50
71
|
set.add('events');
|
|
51
72
|
for (const c of await repo.listCalendars()) if (c.visibility === 'public') set.add(`calendar:${c.slug}`);
|
package/src/lib/push/types.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface PushNotification {
|
|
|
34
34
|
error: string; author: string; createdAt: number; finishedAt: number | null;
|
|
35
35
|
}
|
|
36
36
|
export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live'; docId: string; occurrence: number | null; status: string; createdAt: number }
|
|
37
|
-
export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
|
|
37
|
+
export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' | 'site'; description?: string }
|
|
38
38
|
export interface PushStats {
|
|
39
39
|
devices: number;
|
|
40
40
|
platforms: Record<PushPlatform, number>;
|
package/src/virtual.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ declare module 'virtual:acms-config' {
|
|
|
14
14
|
};
|
|
15
15
|
// resolved by integration.mjs; empty locales = i18n not configured
|
|
16
16
|
i18n: { locales: string[]; defaultLocale: string };
|
|
17
|
-
mobile?: { ios?: { teamId: string; bundleId: string; paths: string[] }; android?: { package: string; sha256CertFingerprints: string[] }; push?: { enabled: boolean; retentionDays: number }; editor?: { enabled: boolean; name: string; redirectUris: string[]; accessTokenMinutes: number; refreshTokenDays: number } };
|
|
17
|
+
mobile?: { ios?: { teamId: string; bundleId: string; paths: string[] }; android?: { package: string; sha256CertFingerprints: string[] }; push?: { enabled: boolean; retentionDays: number; topics?: Array<{ id: string; label: Record<string, string>; description: Record<string, string> }> }; editor?: { enabled: boolean; name: string; redirectUris: string[]; accessTokenMinutes: number; refreshTokenDays: number } };
|
|
18
18
|
search?: { enabled: boolean };
|
|
19
19
|
// resolved by integration.mjs; '' = pick from the environment (lib/maps/config.ts)
|
|
20
20
|
maps?: { provider: '' | 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias' };
|