From fecee114e7fbd870175ca2fc1b8a2d1241ce689e Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Fri, 25 Sep 2026 21:11:23 +0000 Subject: [PATCH] Remind people about the calendar events they are going to (#646) --- e2e/USER_STORIES.md | 4 + e2e/specs/settings.spec.ts | 22 +++++ src/app/calendar.ts | 3 + src/app/components/CalendarRsvp.svelte | 8 +- src/app/push/adapters/capacitor.ts | 3 + src/app/reminders.ts | 114 ++++++++++++++++++++++++ src/app/settings.ts | 2 + src/routes/settings/alerts/+page.svelte | 65 +++++++++++++- 8 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src/app/reminders.ts diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 56fc0c7c..0b1c0966 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1346,6 +1346,10 @@ Acceptance: permission is refused, an error toast reports it and the push toggle settles off, leaving badge and sound settings untouched. - "Discard Changes" reverts unsaved edits to the last saved values. +- Events alice is going to are set to remind her once, five minutes ahead. Lead + times are measured in minutes, and she can add and remove them. +- Saving a reminder less than a minute before the event shows an error and does + not save. ### US-087 — Configure content display diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts index 9bf1b9ec..bc2e33c5 100644 --- a/e2e/specs/settings.spec.ts +++ b/e2e/specs/settings.spec.ts @@ -98,6 +98,26 @@ test("US-086 configure alerts", async ({seed, as}) => { await waitForToastToClear(page) + const reminders = alertTypes.locator("div.items-center.gap-2:has(input[type=number])") + + await expect(reminders).toHaveCount(1) + await expect(reminders.first().locator("input")).toHaveValue("5") + + // A reminder has to come before the event, so saving a zero is refused with an error. + await page.getByRole("button", {name: "Add reminder"}).click() + await reminders.nth(1).locator("input").fill("0") + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("at least a minute") + + await waitForToastToClear(page) + + await reminders.nth(1).locator("input").fill("1440") + await page.getByRole("button", {name: "Add reminder"}).click() + await reminders.nth(2).getByRole("button").click() + + await expect(reminders).toHaveCount(2) + await sound.uncheck() await page.getByRole("button", {name: "Save Changes"}).click() @@ -107,6 +127,8 @@ test("US-086 configure alerts", async ({seed, as}) => { await page.reload() await expect(settingToggle(page, "Play sound for new activity")).not.toBeChecked() + await expect(reminders).toHaveCount(2) + await expect(reminders.nth(1).locator("input")).toHaveValue("1440") }) test("US-087 configure content display", async ({seed, as}) => { diff --git a/src/app/calendar.ts b/src/app/calendar.ts index 3471fe9a..979a720d 100644 --- a/src/app/calendar.ts +++ b/src/app/calendar.ts @@ -245,6 +245,9 @@ export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags) +export const isAttending = (status?: string) => + status === RsvpStatus.Accepted || status === RsvpStatus.Tentative + // A relay can still hold a replaced RSVP, so the newest per person is the one that counts. export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => { const latest = uniqBy( diff --git a/src/app/components/CalendarRsvp.svelte b/src/app/components/CalendarRsvp.svelte index cc07d904..fed35ecc 100644 --- a/src/app/components/CalendarRsvp.svelte +++ b/src/app/components/CalendarRsvp.svelte @@ -13,9 +13,11 @@ RsvpStatus, getRsvpStatus, getRsvpsByStatus, + isAttending, publishRsvp, retractRsvp, } from "@app/calendar" + import {setEventReminders} from "@app/reminders" type Props = { url: string @@ -33,12 +35,16 @@ loading = true + const retracting = ownRsvp && getRsvpStatus(ownRsvp) === status + try { - if (ownRsvp && getRsvpStatus(ownRsvp) === status) { + if (retracting) { await retractRsvp(url, ownRsvp) } else { await publishRsvp(url, event, status) } + + await setEventReminders(url, event, !retracting && isAttending(status)) } finally { loading = false } diff --git a/src/app/push/adapters/capacitor.ts b/src/app/push/adapters/capacitor.ts index eecf8e24..f353bbb2 100644 --- a/src/app/push/adapters/capacitor.ts +++ b/src/app/push/adapters/capacitor.ts @@ -11,6 +11,7 @@ import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env" import {pushState} from "@app/push/adapters/common" import type {IPushAdapter} from "@app/push/adapters/common" import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common" +import {syncEventReminders} from "@app/reminders" const requestPermissions = async () => { let status = await PushNotifications.checkPermissions() @@ -188,6 +189,8 @@ export class CapacitorNotifications implements IPushAdapter { this._unsyncRelay(url, key) } }) + + await syncEventReminders() } catch (e) { console.error(e) } diff --git a/src/app/reminders.ts b/src/app/reminders.ts new file mode 100644 index 00000000..4d67f1ed --- /dev/null +++ b/src/app/reminders.ts @@ -0,0 +1,114 @@ +import {MINUTE, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib" +import {EVENT_RSVP, addressTags, getIdFilters, isSignedEvent, matchTag} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {buildUrl} from "@lib/util" +import {getEventStart, getEventTitle, getRsvpStatus, isAttending} from "@app/calendar" +import {app, network, roomLists} from "@app/core" +import {PUSH_SERVER} from "@app/env" +import {pushState} from "@app/push/adapters/common" +import {notificationSettings} from "@app/settings" + +type Reminder = { + at: number + title: string + body: string +} + +const getScheduleUrl = () => { + const {subscription} = pushState.get() + + return subscription && buildUrl(PUSH_SERVER, "subscription", subscription.key, "schedule") +} + +const getLeadTimes = () => { + const {events, reminder_minutes} = notificationSettings.get() + + return events ? uniq(reminder_minutes) : [] +} + +const makeReminders = (event: TrustedEvent, start: number): Reminder[] => + getLeadTimes() + .map(minutes => ({ + at: start - int(minutes, MINUTE), + title: getEventTitle(event) || "Upcoming event", + body: `Starts ${formatTimestampRelative(now() + int(minutes, MINUTE))}`, + })) + .filter(({at}) => at > now()) + +const putSchedule = async (relay: string, event: TrustedEvent, reminders: Reminder[]) => { + const url = getScheduleUrl() + + if (url) { + const res = await fetch(url, { + method: "PUT", + headers: {Accept: "application/json", "Content-Type": "application/json"}, + body: JSON.stringify({ + id: event.id, + relay, + event: isSignedEvent(event) ? event : undefined, + notifications: reminders, + }), + }) + + if (!res.ok) { + console.warn(`Failed to schedule event reminders (status ${res.status})`) + } + } +} + +export const setEventReminders = (relay: string, event: TrustedEvent, attending: boolean) => { + const start = getEventStart(event) + + return putSchedule(relay, event, attending && start ? makeReminders(event, start) : []) +} + +const loadAttendedEvents = async (pubkey: string) => { + const $network = network.get() + const signal = AbortSignal.timeout(ms(10)) + const filters = [{kinds: [EVENT_RSVP], authors: [pubkey]}] + const relays = roomLists.get().urls(pubkey).get() + + await $network.request({signal, filters, relays, autoClose: true}) + + const attended = app + .get() + .repository.query(filters) + .flatMap(rsvp => { + const tag = matchTag(addressTags("a"), rsvp.tags) + + return tag?.[2] && isAttending(getRsvpStatus(rsvp)) ? [{address: tag[1], relay: tag[2]}] : [] + }) + + await $network.request({ + signal, + autoClose: true, + relays: uniq(attended.map(({relay}) => relay)), + filters: getIdFilters(attended.map(({address}) => address)), + }) + + return attended.flatMap(({address, relay}) => { + const event = app.get().repository.getEvent(address) + + return event ? [{event, relay}] : [] + }) +} + +// Every schedule the push server holds was built from these settings, so redo the lot. +export const syncEventReminders = async () => { + const url = getScheduleUrl() + const pubkey = app.get().user?.pubkey + + if (url && pubkey) { + const res = await fetch(url, {method: "DELETE"}) + + if (!res.ok) { + console.warn(`Failed to clear event reminders (status ${res.status})`) + } + + if (getLeadTimes().length > 0) { + for (const {event, relay} of await loadAttendedEvents(pubkey)) { + await setEventReminders(relay, event, true) + } + } + } +} diff --git a/src/app/settings.ts b/src/app/settings.ts index 5aaa06b0..70c656a7 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -167,6 +167,8 @@ export const notificationSettings = withGetter( spaces: true, mentions: true, messages: true, + events: true, + reminder_minutes: [5], }), ) diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 42a0097e..9463ccf4 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -1,9 +1,11 @@