@acms-subir/core 0.3.34 → 0.3.35
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 +14 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +2 -2
- package/package.json +1 -1
- package/src/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +44 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -0
- package/src/lib/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-words.ts +62 -0
- package/src/styles/mobile-app.css +7 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Side effects when an event changes live: a 301 when a published event's
|
|
2
|
-
// slug moves,
|
|
2
|
+
// slug moves, a queued "event changed" app notice when its schedule or place
|
|
3
|
+
// moves, and the matching webhook (event.published / event.updated /
|
|
3
4
|
// event.unpublished). Shared by the editor actions, the list's bulk actions
|
|
4
5
|
// and the agent tools so every path behaves the same.
|
|
5
6
|
import type { ContentRepository } from '../repo/types';
|
|
6
7
|
import type { CalEvent } from '../schema';
|
|
7
8
|
import { emitWebhook } from '../webhooks';
|
|
9
|
+
import { queueEventChange } from '../push/service';
|
|
8
10
|
import { eventToJson } from './api';
|
|
9
11
|
import { eventUrl } from './config';
|
|
10
12
|
|
|
@@ -18,6 +20,8 @@ export async function eventChanged(
|
|
|
18
20
|
await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
|
|
19
21
|
opts.onRedirect?.();
|
|
20
22
|
}
|
|
23
|
+
// the scheduler sends it once the edits settle, if the rule still applies
|
|
24
|
+
await queueEventChange(repo, before, after).catch((e) => console.error('acms push: event change not queued', e));
|
|
21
25
|
let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
|
|
22
26
|
if (before.status !== 'published' && after.status === 'published') type = 'event.published';
|
|
23
27
|
else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
|
package/src/lib/live/view.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { PublicLiveSnapshot } from './types';
|
|
2
|
+
import { fill, liveStrings, type LiveStrings } from './words';
|
|
2
3
|
|
|
3
4
|
// Display text for a live snapshot. Pure and DOM-free: LiveCard renders it on
|
|
4
5
|
// the server and the browser re-applies it on every status change, so both
|
|
5
|
-
// always agree.
|
|
6
|
+
// always agree. Both pass the page's words and language (English by default).
|
|
6
7
|
|
|
7
8
|
export const PROVIDER_NAMES: Record<string, string> = { youtube: 'YouTube', twitch: 'Twitch', facebook: 'Facebook', other: '' };
|
|
8
9
|
|
|
@@ -23,6 +24,7 @@ export interface LiveView {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
|
|
27
|
+
const EN = liveStrings('en');
|
|
26
28
|
|
|
27
29
|
export function compactCount(n: number): string {
|
|
28
30
|
if (n < 1000) return String(n);
|
|
@@ -30,49 +32,51 @@ export function compactCount(n: number): string {
|
|
|
30
32
|
return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${unit}`;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
export function ago(ms: number): string {
|
|
34
|
-
if (ms < MIN) return
|
|
35
|
-
if (ms < HOUR) return `${Math.floor(ms / MIN)} min
|
|
35
|
+
export function ago(ms: number, w: LiveStrings = EN): string {
|
|
36
|
+
if (ms < MIN) return w.justNow;
|
|
37
|
+
if (ms < HOUR) return fill(w.ago, { time: `${Math.floor(ms / MIN)} min` });
|
|
36
38
|
const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
|
|
37
|
-
return m ? `${h} h ${m} min
|
|
39
|
+
return fill(w.ago, { time: m ? `${h} h ${m} min` : `${h} h` });
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
export function until(ms: number): string {
|
|
41
|
-
if (ms < MIN) return
|
|
42
|
-
if (ms < HOUR) return
|
|
43
|
-
if (ms < DAY) return
|
|
42
|
+
export function until(ms: number, w: LiveStrings = EN): string {
|
|
43
|
+
if (ms < MIN) return w.startingNow;
|
|
44
|
+
if (ms < HOUR) return fill(w.in, { time: `${Math.ceil(ms / MIN)} min` });
|
|
45
|
+
if (ms < DAY) return fill(w.in, { time: `${Math.round(ms / HOUR)} h` });
|
|
44
46
|
const d = Math.round(ms / DAY);
|
|
45
|
-
return
|
|
47
|
+
return fill(w.in, { time: fill(d === 1 ? w.day : w.days, { n: String(d) }) });
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// a bad language tag formats like the runtime default rather than failing the page
|
|
51
|
+
export function when(ts: number, now: number, locale?: string): string {
|
|
52
|
+
const format = (o: Intl.DateTimeFormatOptions) => {
|
|
53
|
+
try { return new Intl.DateTimeFormat(locale, o).format(ts); } catch { return new Intl.DateTimeFormat(undefined, o).format(ts); }
|
|
54
|
+
};
|
|
55
|
+
const time = format({ hour: 'numeric', minute: '2-digit' });
|
|
56
|
+
return `${format(ts - now < 6 * DAY ? { weekday: 'short' } : { month: 'short', day: 'numeric' })} ${time}`;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
export function liveView(s: PublicLiveSnapshot | null, now: number): LiveView | null {
|
|
59
|
+
export function liveView(s: PublicLiveSnapshot | null, now: number, w: LiveStrings = EN, locale?: string): LiveView | null {
|
|
56
60
|
if (!s || (s.status !== 'live' && s.status !== 'upcoming' && s.status !== 'offline')) return null;
|
|
57
61
|
const name = PROVIDER_NAMES[s.provider] ?? '';
|
|
58
62
|
const channel = s.channel?.trim() ?? '';
|
|
59
63
|
const base = { status: s.status, provider: s.provider, title: s.title, watchUrl: s.watchUrl, thumbUrl: s.thumbUrl };
|
|
60
64
|
if (s.status === 'offline') {
|
|
61
|
-
return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel:
|
|
65
|
+
return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: w.watchChannel };
|
|
62
66
|
}
|
|
63
|
-
const watchLabel = name ?
|
|
67
|
+
const watchLabel = name ? fill(w.watchOn, { provider: name }) : w.watchNow;
|
|
64
68
|
if (s.status === 'upcoming') {
|
|
65
|
-
const start = s.startedAt > 0 ? `${when(s.startedAt, now)} · ${until(s.startedAt - now)}` : '';
|
|
69
|
+
const start = s.startedAt > 0 ? `${when(s.startedAt, now, locale)} · ${until(s.startedAt - now, w)}` : '';
|
|
66
70
|
return {
|
|
67
|
-
...base, tag: start ||
|
|
68
|
-
meta: [channel, name &&
|
|
71
|
+
...base, tag: start || w.upcoming, kicker: name ? fill(w.upcomingOn, { provider: name.toUpperCase() }) : w.upcoming,
|
|
72
|
+
meta: [channel, name && w.scheduled].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? fill(w.openOn, { provider: name }) : w.open,
|
|
69
73
|
};
|
|
70
74
|
}
|
|
71
|
-
const started = s.startedAt > 0 && s.startedAt <= now ?
|
|
75
|
+
const started = s.startedAt > 0 && s.startedAt <= now ? fill(w.started, { time: ago(now - s.startedAt, w) }) : '';
|
|
72
76
|
return {
|
|
73
|
-
...base, tag:
|
|
77
|
+
...base, tag: w.tag, kicker: name ? fill(w.liveOn, { provider: name.toUpperCase() }) : w.liveNow,
|
|
74
78
|
meta: [channel, started].filter(Boolean).join(' · '),
|
|
75
|
-
viewers: typeof s.viewers === 'number' ?
|
|
79
|
+
viewers: typeof s.viewers === 'number' ? fill(w.watching, { count: compactCount(s.viewers) }) : '',
|
|
76
80
|
watchLabel,
|
|
77
81
|
};
|
|
78
82
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Words the website's live surfaces show: the live card (view.ts, LiveCard)
|
|
2
|
+
// and the site-wide bar, header marker and dock (Site.astro). Plain strings
|
|
3
|
+
// with {provider}, {time}, {count}, {n} and {title} slots, so LiveCard can
|
|
4
|
+
// hand them to its browser script as JSON. Spanish matches the app's words
|
|
5
|
+
// (packages/native strings.ts) where they overlap. Other languages fall back
|
|
6
|
+
// to English.
|
|
7
|
+
export interface LiveStrings {
|
|
8
|
+
/** poster tag while live */
|
|
9
|
+
tag: string;
|
|
10
|
+
/** above the title: without a provider name, then with it (upper-cased) */
|
|
11
|
+
liveNow: string;
|
|
12
|
+
liveOn: string;
|
|
13
|
+
upcoming: string;
|
|
14
|
+
upcomingOn: string;
|
|
15
|
+
watchOn: string;
|
|
16
|
+
watchNow: string;
|
|
17
|
+
openOn: string;
|
|
18
|
+
open: string;
|
|
19
|
+
watchChannel: string;
|
|
20
|
+
/** after the channel name of an upcoming broadcast */
|
|
21
|
+
scheduled: string;
|
|
22
|
+
/** after the channel name while live: "started {time}" with {time} from `ago` */
|
|
23
|
+
started: string;
|
|
24
|
+
justNow: string;
|
|
25
|
+
ago: string;
|
|
26
|
+
startingNow: string;
|
|
27
|
+
in: string;
|
|
28
|
+
day: string;
|
|
29
|
+
days: string;
|
|
30
|
+
watching: string;
|
|
31
|
+
notLive: string;
|
|
32
|
+
/** the card's label and the player's title when the stream has none */
|
|
33
|
+
video: string;
|
|
34
|
+
playHere: string;
|
|
35
|
+
play: string;
|
|
36
|
+
/** under the in-page player button; {provider} falls back to `platform` */
|
|
37
|
+
privacy: string;
|
|
38
|
+
platform: string;
|
|
39
|
+
/** site-wide signals */
|
|
40
|
+
bar: string;
|
|
41
|
+
watch: string;
|
|
42
|
+
dismissBar: string;
|
|
43
|
+
marker: string;
|
|
44
|
+
dismissDock: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const STRINGS: Record<string, LiveStrings> = {
|
|
48
|
+
en: {
|
|
49
|
+
tag: 'LIVE', liveNow: 'LIVE NOW', liveOn: 'LIVE ON {provider}', upcoming: 'UPCOMING', upcomingOn: 'UPCOMING ON {provider}',
|
|
50
|
+
watchOn: 'Watch on {provider} ↗', watchNow: 'Watch now ↗', openOn: 'Open on {provider} ↗', open: 'Open ↗', watchChannel: 'Watch our channel ↗',
|
|
51
|
+
scheduled: 'scheduled', started: 'started {time}', justNow: 'just now', ago: '{time} ago', startingNow: 'starting now', in: 'in {time}', day: '{n} day', days: '{n} days',
|
|
52
|
+
watching: '{count} watching', notLive: 'Not live right now', video: 'Live video', playHere: 'Play here', play: 'Play {title} here',
|
|
53
|
+
privacy: 'Playing loads video from {provider}.', platform: 'the platform',
|
|
54
|
+
bar: 'Live now', watch: 'Watch →', dismissBar: 'Dismiss live notice', marker: 'Live', dismissDock: 'Dismiss live player',
|
|
55
|
+
},
|
|
56
|
+
es: {
|
|
57
|
+
tag: 'EN VIVO', liveNow: 'EN VIVO AHORA', liveOn: 'EN VIVO EN {provider}', upcoming: 'PRÓXIMAMENTE', upcomingOn: 'PRÓXIMAMENTE EN {provider}',
|
|
58
|
+
watchOn: 'Ver en {provider} ↗', watchNow: 'Ver ahora ↗', openOn: 'Abrir en {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nuestro canal ↗',
|
|
59
|
+
scheduled: 'programado', started: 'empezó {time}', justNow: 'hace un momento', ago: 'hace {time}', startingNow: 'empieza ahora', in: 'en {time}', day: '{n} día', days: '{n} días',
|
|
60
|
+
watching: '{count} viendo', notLive: 'No estamos en vivo ahora', video: 'Video en vivo', playHere: 'Reproducir aquí', play: 'Reproducir {title} aquí',
|
|
61
|
+
privacy: 'Al reproducir, el video se carga desde {provider}.', platform: 'la plataforma',
|
|
62
|
+
bar: 'En vivo', watch: 'Ver →', dismissBar: 'Cerrar aviso', marker: 'En vivo', dismissDock: 'Cerrar reproductor',
|
|
63
|
+
},
|
|
64
|
+
fr: {
|
|
65
|
+
tag: 'EN DIRECT', liveNow: 'EN DIRECT', liveOn: 'EN DIRECT SUR {provider}', upcoming: 'À VENIR', upcomingOn: 'BIENTÔT SUR {provider}',
|
|
66
|
+
watchOn: 'Regarder sur {provider} ↗', watchNow: 'Regarder ↗', openOn: 'Ouvrir sur {provider} ↗', open: 'Ouvrir ↗', watchChannel: 'Voir notre chaîne ↗',
|
|
67
|
+
scheduled: 'programmé', started: 'a commencé {time}', justNow: 'à l’instant', ago: 'il y a {time}', startingNow: 'commence maintenant', in: 'dans {time}', day: '{n} jour', days: '{n} jours',
|
|
68
|
+
watching: '{count} spectateurs', notLive: 'Pas en direct pour le moment', video: 'Vidéo en direct', playHere: 'Lire ici', play: 'Lire {title} ici',
|
|
69
|
+
privacy: 'La lecture charge la vidéo depuis {provider}.', platform: 'la plateforme',
|
|
70
|
+
bar: 'En direct', watch: 'Regarder →', dismissBar: 'Fermer l’avis', marker: 'En direct', dismissDock: 'Fermer le lecteur',
|
|
71
|
+
},
|
|
72
|
+
de: {
|
|
73
|
+
tag: 'LIVE', liveNow: 'JETZT LIVE', liveOn: 'LIVE AUF {provider}', upcoming: 'DEMNÄCHST', upcomingOn: 'DEMNÄCHST AUF {provider}',
|
|
74
|
+
watchOn: 'Auf {provider} ansehen ↗', watchNow: 'Jetzt ansehen ↗', openOn: 'Auf {provider} öffnen ↗', open: 'Öffnen ↗', watchChannel: 'Zu unserem Kanal ↗',
|
|
75
|
+
scheduled: 'geplant', started: 'begann {time}', justNow: 'gerade eben', ago: 'vor {time}', startingNow: 'beginnt jetzt', in: 'in {time}', day: '{n} Tag', days: '{n} Tagen',
|
|
76
|
+
watching: '{count} Zuschauer', notLive: 'Gerade nicht live', video: 'Livevideo', playHere: 'Hier abspielen', play: '{title} hier abspielen',
|
|
77
|
+
privacy: 'Beim Abspielen wird das Video von {provider} geladen.', platform: 'der Plattform',
|
|
78
|
+
bar: 'Jetzt live', watch: 'Ansehen →', dismissBar: 'Hinweis schließen', marker: 'Live', dismissDock: 'Player schließen',
|
|
79
|
+
},
|
|
80
|
+
pt: {
|
|
81
|
+
tag: 'AO VIVO', liveNow: 'AO VIVO AGORA', liveOn: 'AO VIVO NO {provider}', upcoming: 'EM BREVE', upcomingOn: 'EM BREVE NO {provider}',
|
|
82
|
+
watchOn: 'Assistir no {provider} ↗', watchNow: 'Assistir agora ↗', openOn: 'Abrir no {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nosso canal ↗',
|
|
83
|
+
scheduled: 'agendado', started: 'começou {time}', justNow: 'agora mesmo', ago: 'há {time}', startingNow: 'começando agora', in: 'em {time}', day: '{n} dia', days: '{n} dias',
|
|
84
|
+
watching: '{count} assistindo', notLive: 'Não estamos ao vivo agora', video: 'Vídeo ao vivo', playHere: 'Reproduzir aqui', play: 'Reproduzir {title} aqui',
|
|
85
|
+
privacy: 'Ao reproduzir, o vídeo é carregado de {provider}.', platform: 'uma plataforma externa',
|
|
86
|
+
bar: 'Ao vivo', watch: 'Assistir →', dismissBar: 'Fechar aviso', marker: 'Ao vivo', dismissDock: 'Fechar player',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const liveStrings = (locale: string | null | undefined): LiveStrings =>
|
|
91
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
92
|
+
|
|
93
|
+
/** "Watch on {provider}" → "Watch on YouTube"; unknown slots stay as written */
|
|
94
|
+
export const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m);
|
package/src/lib/push/api.ts
CHANGED
|
@@ -101,10 +101,11 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
101
101
|
const now = Date.now();
|
|
102
102
|
const settings = await repo.getSettings();
|
|
103
103
|
const expo = expoOptions(locals);
|
|
104
|
-
const [stats, topics, calendars, links, log] = await Promise.all([
|
|
104
|
+
const [stats, topics, calendars, links, log, tags] = await Promise.all([
|
|
105
105
|
repo.push.stats(now), pushTopics(repo),
|
|
106
106
|
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
107
107
|
linkTargets(repo), repo.push.listNotifications(50),
|
|
108
|
+
CALENDAR.enabled ? repo.listTags() : Promise.resolve([]),
|
|
108
109
|
]);
|
|
109
110
|
return {
|
|
110
111
|
enabled: PUSH.enabled, retentionDays: PUSH.retentionDays,
|
|
@@ -115,6 +116,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
|
|
|
115
116
|
// "we're live" needs live video on (Integrations → Live video)
|
|
116
117
|
liveMode: settings.live.mode,
|
|
117
118
|
calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
119
|
+
// suggestions for the event-change rule's "Only events tagged"
|
|
120
|
+
tags: tags.map((t) => t.name),
|
|
118
121
|
expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
|
|
119
122
|
};
|
|
120
123
|
}
|
|
@@ -152,11 +155,20 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
|
|
|
152
155
|
const collections = new Set(Object.keys(CONTENT.collections));
|
|
153
156
|
const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
|
|
154
157
|
const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
|
|
158
|
+
const changes = input.auto.eventChanges;
|
|
159
|
+
const tags = changes?.tags ?? [];
|
|
160
|
+
const known = await knownTopics(repo);
|
|
155
161
|
const auto = {
|
|
156
162
|
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
157
163
|
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
158
164
|
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
159
165
|
live: { enabled: input.auto.live?.enabled === true },
|
|
166
|
+
eventChanges: CALENDAR.enabled ? {
|
|
167
|
+
calendars: cals(changes?.calendars ?? []),
|
|
168
|
+
// one of each tag, whatever its case (matching ignores case too)
|
|
169
|
+
tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
|
|
170
|
+
topics: [...new Set(changes?.topics ?? [])].filter((t) => known.has(t)),
|
|
171
|
+
} : { calendars: [], tags: [], topics: [] },
|
|
160
172
|
};
|
|
161
173
|
await repo.updateSettings({ push: { auto } });
|
|
162
174
|
return { auto };
|
package/src/lib/push/service.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// Push notifications: topics, audiences, the Expo sender pipeline, automatic
|
|
2
|
-
// notices (first publish of a post/event, "starting soon", "we're live"
|
|
2
|
+
// notices (first publish of a post/event, "starting soon", "we're live", event
|
|
3
|
+
// changes) and receipts.
|
|
3
4
|
import type { ContentRepository } from '../repo/types';
|
|
4
5
|
import type { CalEvent, Calendar, Post, Settings } from '../schema';
|
|
5
6
|
import { CONTENT, collectionUrl } from '../content';
|
|
6
7
|
import { CALENDAR, eventUrl } from '../calendar/config';
|
|
7
|
-
import { eventIsListed, eventTimeZone, expandEvents } from '../calendar/events';
|
|
8
|
-
import {
|
|
8
|
+
import { eventIsListed, eventTimeZone, expandEvents, findOccurrence, type Occurrence } from '../calendar/events';
|
|
9
|
+
import { tzParts } from '../calendar/ics';
|
|
10
|
+
import { I18N, stripLocale } from '../i18n';
|
|
9
11
|
import { localizeDoc } from '../translations';
|
|
10
12
|
import { localizedUrl } from '../mobile/locale';
|
|
11
13
|
import { effectiveSnapshot } from '../live/service';
|
|
14
|
+
import { ANNOUNCEMENTS } from '../announcements/config';
|
|
12
15
|
import type { LiveSnapshot } from '../live/types';
|
|
13
16
|
import { PUSH } from './config';
|
|
14
17
|
import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
|
|
15
|
-
import type
|
|
18
|
+
import { EventSnapshot, type PushAuto, type PushNotification, type PushSource, type PushTopic } from './types';
|
|
16
19
|
|
|
17
20
|
const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
|
|
18
21
|
/** automatic notices queued longer ago than this are skipped, never sent late */
|
|
@@ -22,13 +25,14 @@ export const RECEIPT_DELAY = 15 * MINUTE;
|
|
|
22
25
|
|
|
23
26
|
// ---- topics ------------------------------------------------------------------
|
|
24
27
|
|
|
25
|
-
// names of the built-in topics; other languages show English
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// names of the built-in topics; other languages show English. `eventsChanges`
|
|
29
|
+
// names `events` while event-change notices go to it.
|
|
30
|
+
const TOPIC_WORDS: Record<string, { news: string; events: string; eventsChanges: string; live: string }> = {
|
|
31
|
+
en: { news: 'News', events: 'Events', eventsChanges: 'Events and changes', live: 'Live video' },
|
|
32
|
+
es: { news: 'Noticias', events: 'Eventos', eventsChanges: 'Eventos y cambios', live: 'Video en vivo' },
|
|
33
|
+
fr: { news: 'Actualités', events: 'Événements', eventsChanges: 'Événements et changements', live: 'Vidéo en direct' },
|
|
34
|
+
de: { news: 'Neuigkeiten', events: 'Veranstaltungen', eventsChanges: 'Veranstaltungen und Änderungen', live: 'Live-Video' },
|
|
35
|
+
pt: { news: 'Notícias', events: 'Eventos', eventsChanges: 'Eventos e mudanças', live: 'Vídeo ao vivo' },
|
|
32
36
|
};
|
|
33
37
|
/** 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
38
|
export function topicWords(words: Record<string, string>, locale: string): string {
|
|
@@ -46,17 +50,20 @@ export function topicWords(words: Record<string, string>, locale: string): strin
|
|
|
46
50
|
* first registration) choose: a new topic never reaches existing devices on its own.
|
|
47
51
|
*/
|
|
48
52
|
export async function pushTopics(repo: ContentRepository, locale?: string | null): Promise<PushTopic[]> {
|
|
49
|
-
const
|
|
53
|
+
const settings = await repo.getSettings();
|
|
54
|
+
const lang = locale || I18N.defaultLocale || settings.locale || 'en';
|
|
50
55
|
const w = TOPIC_WORDS[lang.toLowerCase().split('-')[0]] ?? TOPIC_WORDS.en;
|
|
51
56
|
const collections = Object.entries(CONTENT.collections);
|
|
52
57
|
const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
|
|
53
58
|
if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
|
|
54
59
|
if (CALENDAR.enabled) {
|
|
55
|
-
|
|
60
|
+
const changes = settings.push.auto.eventChanges;
|
|
61
|
+
// event-change notices go to `events` unless their rule names other topics
|
|
62
|
+
out.push({ id: 'events', label: changes.calendars.length && !changes.topics.length ? w.eventsChanges : w.events, kind: 'general' });
|
|
56
63
|
const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
|
|
57
64
|
if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
|
|
58
65
|
}
|
|
59
|
-
if (
|
|
66
|
+
if (settings.live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
|
|
60
67
|
for (const t of PUSH.topics) {
|
|
61
68
|
const description = topicWords(t.description, lang);
|
|
62
69
|
out.push({ id: t.id, label: topicWords(t.label, lang), kind: 'site', ...(description ? { description } : {}) });
|
|
@@ -84,12 +91,28 @@ export function deviceLocale(raw: string): string | null {
|
|
|
84
91
|
return match && match !== I18N.defaultLocale ? match : null;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
interface Words {
|
|
95
|
+
startsIn: (m: number) => string; now: string; live: string;
|
|
96
|
+
// event changes: dates come formatted, `more` ends a list of cancelled dates
|
|
97
|
+
moved: (to: string, was: string) => string; movedTo: (to: string, place: string) => string; place: (p: string) => string;
|
|
98
|
+
cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
|
|
99
|
+
}
|
|
100
|
+
const STRINGS: Record<string, Words> = {
|
|
101
|
+
en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
|
|
102
|
+
moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
|
|
103
|
+
cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more` },
|
|
104
|
+
es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
|
|
105
|
+
moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
|
|
106
|
+
cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más` },
|
|
107
|
+
fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
|
|
108
|
+
moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
|
|
109
|
+
cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres` },
|
|
110
|
+
de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
|
|
111
|
+
moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
|
|
112
|
+
cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere` },
|
|
113
|
+
pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
|
|
114
|
+
moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
|
|
115
|
+
cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}` },
|
|
93
116
|
};
|
|
94
117
|
export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
|
|
95
118
|
|
|
@@ -97,8 +120,8 @@ const clip = (s: string, n: number) => {
|
|
|
97
120
|
const t = s.replace(/\s+/g, ' ').trim();
|
|
98
121
|
return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t;
|
|
99
122
|
};
|
|
100
|
-
function when(start: number, allDay: boolean, tz: string, locale: string,
|
|
101
|
-
const opts: Intl.DateTimeFormatOptions =
|
|
123
|
+
function when(start: number, allDay: boolean, tz: string, locale: string, only?: 'time' | 'monthDay') {
|
|
124
|
+
const opts: Intl.DateTimeFormatOptions = only === 'time' ? { hour: 'numeric', minute: '2-digit' } : only === 'monthDay' ? { month: 'short', day: 'numeric' }
|
|
102
125
|
: allDay ? { weekday: 'short', month: 'short', day: 'numeric' } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
|
|
103
126
|
try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }).format(start); }
|
|
104
127
|
catch { return new Date(start).toISOString().slice(0, 16).replace('T', ' '); }
|
|
@@ -201,6 +224,11 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
|
|
|
201
224
|
|
|
202
225
|
const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
|
|
203
226
|
const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
|
|
227
|
+
// the change rule: its calendars, and one of its tags when it lists some (any case)
|
|
228
|
+
function inChangeRule(rule: PushAuto['eventChanges'], e: CalEvent) {
|
|
229
|
+
const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
|
|
230
|
+
return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
|
|
231
|
+
}
|
|
204
232
|
function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
|
|
205
233
|
return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
|
|
206
234
|
}
|
|
@@ -224,7 +252,7 @@ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<stri
|
|
|
224
252
|
if (soon) {
|
|
225
253
|
const s = pushStrings(locale);
|
|
226
254
|
const mins = Math.max(0, Math.round((soon.start - now) / MINUTE));
|
|
227
|
-
body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale,
|
|
255
|
+
body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale, 'time')}${place}`;
|
|
228
256
|
} else body = `${when(next.start, next.allDay, tz, locale)}${place}`;
|
|
229
257
|
return { title: clip(ev.title, 120), body: clip(body, 240), url: eventUrl(ev.slug) };
|
|
230
258
|
};
|
|
@@ -234,7 +262,159 @@ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<stri
|
|
|
234
262
|
localize: (l) => words(localizeDoc(e, l), l) };
|
|
235
263
|
}
|
|
236
264
|
|
|
237
|
-
|
|
265
|
+
// ---- event changes ---------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** Edits wait this long after the last one, so a quick fix sends one notice (or none)… */
|
|
268
|
+
export const CHANGE_QUIET = 10 * MINUTE;
|
|
269
|
+
/** …but only this long when the date they touch starts within CHANGE_SOON. */
|
|
270
|
+
export const CHANGE_QUIET_SOON = MINUTE;
|
|
271
|
+
export const CHANGE_SOON = 3 * HOUR;
|
|
272
|
+
|
|
273
|
+
/** The public fields a change notice compares (staged edits live in `draft` and don't count until applied). */
|
|
274
|
+
export function eventSnapshot(e: CalEvent): EventSnapshot {
|
|
275
|
+
return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
|
|
276
|
+
cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() } };
|
|
277
|
+
}
|
|
278
|
+
function readSnapshot(raw: string | null): EventSnapshot | null {
|
|
279
|
+
try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface Slot { start: number; allDay: boolean }
|
|
283
|
+
export interface EventChange {
|
|
284
|
+
/** the next date (or its time) moved */
|
|
285
|
+
moved?: { from: Slot; to: Slot };
|
|
286
|
+
/** a new place name or address */
|
|
287
|
+
place?: { from: EventSnapshot['location']; to: EventSnapshot['location'] };
|
|
288
|
+
/** the whole event was cancelled: its next date */
|
|
289
|
+
cancelled?: Slot;
|
|
290
|
+
/** single dates of a repeating event newly cancelled, soonest first */
|
|
291
|
+
cancelledDates?: Slot[];
|
|
292
|
+
/** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
|
|
293
|
+
at: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// the next occurrence that hasn't ended and the next that hasn't started
|
|
297
|
+
// (cancelled ones count: cancelling a date isn't a move)
|
|
298
|
+
function slots(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): { next: Slot | null; upcoming: Slot | null } {
|
|
299
|
+
const all = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY });
|
|
300
|
+
const slot = (o: Occurrence | undefined) => (o ? { start: o.start, allDay: o.allDay } : null);
|
|
301
|
+
return { next: slot(all[0]), upcoming: slot(all.find((o) => o.start > now)) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What people following an event should hear, from what they last knew (`was`)
|
|
306
|
+
* to the event now: its next date or time moved, a new place, the whole event
|
|
307
|
+
* cancelled, or single dates cancelled. Null when nothing counts: other fields,
|
|
308
|
+
* series edits that leave the next date alone, restored dates, un-cancelling,
|
|
309
|
+
* or it's too late (what it touches has started, or nothing is left to come).
|
|
310
|
+
*/
|
|
311
|
+
export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): EventChange | null {
|
|
312
|
+
if (was.cancelled) return null;
|
|
313
|
+
const { upcoming } = slots(e, settings, now);
|
|
314
|
+
if (!upcoming) return null;
|
|
315
|
+
if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
|
|
316
|
+
const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
|
|
317
|
+
// a date taken out of the series (dragged to its own event) or put back isn't a move
|
|
318
|
+
const exdates = [...new Set([...was.exdates, ...e.exdates])];
|
|
319
|
+
const from = slots({ ...then, exdates }, settings, now).next;
|
|
320
|
+
if (!from) return null;
|
|
321
|
+
const to = slots({ ...e, exdates }, settings, now).next;
|
|
322
|
+
const out: Omit<EventChange, 'at'> = {};
|
|
323
|
+
const starts: number[] = [];
|
|
324
|
+
// a move matters until the new time starts, even when the old one has passed
|
|
325
|
+
if (to && to.start > now && (to.start !== from.start || to.allDay !== from.allDay)) { out.moved = { from, to }; starts.push(from.start, to.start); }
|
|
326
|
+
const place = { name: e.location.name.trim(), address: e.location.address.trim() };
|
|
327
|
+
if ((place.name || place.address) && (place.name !== was.location.name || place.address !== was.location.address)) {
|
|
328
|
+
out.place = { from: was.location, to: place };
|
|
329
|
+
starts.push(upcoming.start);
|
|
330
|
+
}
|
|
331
|
+
// only dates people knew as on: one that was already cancelled and moved with its series isn't news
|
|
332
|
+
const dates = e.cancelledDates.filter((k) => !was.cancelledDates.includes(k) && findOccurrence(then, k, settings)).map((k) => findOccurrence(e, k, settings))
|
|
333
|
+
.filter((o): o is Occurrence => o !== null && o.start > now).sort((a, b) => a.start - b.start);
|
|
334
|
+
if (dates.length) {
|
|
335
|
+
out.cancelledDates = dates.map((o) => ({ start: o.start, allDay: o.allDay }));
|
|
336
|
+
starts.push(dates[0].start);
|
|
337
|
+
}
|
|
338
|
+
return starts.length ? { ...out, at: Math.min(...starts) } : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Called by eventChanged on every path: queues (or postpones) a change notice when a published event's schedule or place differs. */
|
|
342
|
+
export async function queueEventChange(repo: ContentRepository, before: CalEvent, after: CalEvent, now = Date.now()): Promise<boolean> {
|
|
343
|
+
// publishing is the "new event" notice; unpublishing says nothing
|
|
344
|
+
if (!PUSH.enabled || !CALENDAR.enabled || before.status !== 'published' || after.status !== 'published') return false;
|
|
345
|
+
const was = eventSnapshot(before);
|
|
346
|
+
if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
|
|
347
|
+
const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
348
|
+
if (!inChangeRule(settings.push.auto.eventChanges, after) || !eventIsListed(after, listedCalendars(all))) return false;
|
|
349
|
+
const key = `change:${after.id}`;
|
|
350
|
+
// a change to a date that starts soon waits only a minute (judged from what people last knew)
|
|
351
|
+
const row = await repo.push.queuedChange(key);
|
|
352
|
+
const change = eventChange((row?.status === 'pending' && readSnapshot(row.payload)) || was, after, settings, now);
|
|
353
|
+
const quiet = change && change.at - now < CHANGE_SOON ? CHANGE_QUIET_SOON : CHANGE_QUIET;
|
|
354
|
+
await repo.push.queueChange(key, after.id, JSON.stringify(was), now, now + quiet);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The site path a link opens, without the origin, a non-default locale prefix, query or hash ('' = another site). */
|
|
359
|
+
export function sitePath(link: string, origin: string): string {
|
|
360
|
+
try {
|
|
361
|
+
const u = new URL(link, origin || 'https://site.invalid');
|
|
362
|
+
if (origin && u.origin !== new URL(origin).origin) return '';
|
|
363
|
+
return stripLocale(u.pathname.replace(/(.)\/+$/, '$1'));
|
|
364
|
+
} catch { return ''; }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// an announcement pushed in the last hour whose button opens this event already told people
|
|
368
|
+
async function announcedLately(repo: ContentRepository, e: CalEvent, origin: string, now: number): Promise<boolean> {
|
|
369
|
+
if (!ANNOUNCEMENTS.enabled) return false;
|
|
370
|
+
const page = eventUrl(e.slug);
|
|
371
|
+
return (await repo.announcements.pushedSince(now - HOUR)).some((a) => a.link !== '' && sitePath(a.link, origin) === page);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `topics`: the rule's own audience; [] = the event's topics. */
|
|
375
|
+
export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Settings, calendars: Map<string, Calendar>, topics: string[] = []): SendInput {
|
|
376
|
+
const tz = eventTimeZone(e, settings);
|
|
377
|
+
const siteTz = settings.timezone || 'UTC';
|
|
378
|
+
// all-day dates start at midnight in the site's zone (how they were expanded)
|
|
379
|
+
const zone = (x: Slot) => (x.allDay ? siteTz : tz);
|
|
380
|
+
const dates = change.cancelledDates ?? [];
|
|
381
|
+
const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : 'dateCancelled';
|
|
382
|
+
// one cancelled date opens that date (?on= is its day in the site's zone, like calendar links)
|
|
383
|
+
let on = '';
|
|
384
|
+
if (kind === 'dateCancelled' && dates.length === 1) {
|
|
385
|
+
const p = tzParts(dates[0].start, siteTz);
|
|
386
|
+
on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
|
|
387
|
+
}
|
|
388
|
+
const words = (ev: CalEvent, locale: string): Composed => {
|
|
389
|
+
const s = pushStrings(locale);
|
|
390
|
+
const at = (x: Slot) => when(x.start, x.allDay, zone(x), locale);
|
|
391
|
+
const day = (x: Slot) => when(x.start, true, zone(x), locale);
|
|
392
|
+
const parts: string[] = [];
|
|
393
|
+
if (change.cancelled) parts.push(ev.rrule ? s.cancelledFrom(day(change.cancelled)) : s.cancelled(at(change.cancelled)));
|
|
394
|
+
else {
|
|
395
|
+
const loc = { name: ev.location.name.trim(), address: ev.location.address.trim() };
|
|
396
|
+
// a new name is enough; the same name at a new address needs both
|
|
397
|
+
const place = !change.place ? '' : change.place.to.name !== change.place.from.name ? loc.name || loc.address : [loc.name, loc.address].filter(Boolean).join(', ');
|
|
398
|
+
if (change.moved) {
|
|
399
|
+
const { from, to } = change.moved;
|
|
400
|
+
const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
|
|
401
|
+
parts.push(place ? s.movedTo(at(to), place) : s.moved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
|
|
402
|
+
} else if (place) parts.push(s.place(place));
|
|
403
|
+
if (dates.length) {
|
|
404
|
+
const list = dates.length === 1 ? [day(dates[0])] : dates.slice(0, 3).map((x) => when(x.start, true, zone(x), locale, 'monthDay'));
|
|
405
|
+
if (dates.length > 3) list.push(s.more(dates.length - 3));
|
|
406
|
+
let joined: string;
|
|
407
|
+
try { joined = new Intl.ListFormat(locale, { type: 'conjunction' }).format(list); } catch { joined = list.join(', '); }
|
|
408
|
+
parts.push(s.cancelledOn(joined));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { title: clip(ev.title, 120), body: clip(parts.join(' · '), 240), url: `${eventUrl(ev.slug)}${on}` };
|
|
412
|
+
};
|
|
413
|
+
return { ...words(e, displayLocale(settings)), audience: topics.length ? topics : eventTopics(e, calendars), source: 'change', sourceKey: `change:${e.id}`,
|
|
414
|
+
data: { kind: 'event', id: e.id, slug: e.slug, change: kind }, localize: (l) => words(localizeDoc(e, l), l) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Sends (or skips) queued first-publish and event-change notices. Returns how many were sent. */
|
|
238
418
|
export async function processPushQueue(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
|
|
239
419
|
const now = opts.now ?? Date.now();
|
|
240
420
|
if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
|
|
@@ -254,6 +434,13 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
|
|
|
254
434
|
} else if (row.kind === 'event' && CALENDAR.enabled) {
|
|
255
435
|
const e = await repo.getEvent(row.docId);
|
|
256
436
|
if (e && !e.cancelled && eventIsListed(e, calendars) && inRule(auto.newEvent.calendars, e)) notice = eventNotice(e, settings, calendars, now);
|
|
437
|
+
} else if (row.kind === 'change' && CALENDAR.enabled) {
|
|
438
|
+
// compared at send time: edits that cancel out, or come too late, send nothing
|
|
439
|
+
const e = await repo.getEvent(row.docId);
|
|
440
|
+
const was = readSnapshot(row.payload);
|
|
441
|
+
const rule = auto.eventChanges;
|
|
442
|
+
const change = e && was && eventIsListed(e, calendars) && inChangeRule(rule, e) ? eventChange(was, e, settings, now) : null;
|
|
443
|
+
if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, rule.topics);
|
|
257
444
|
}
|
|
258
445
|
if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
|
|
259
446
|
const n = await sendNotification(repo, notice, run);
|