Remind people about the calendar events they are going to (#646)

This commit is contained in:
Coracle-Bot 2026-09-25 21:11:23 +00:00 committed by hodlbod
parent 653664751f
commit fecee114e7
8 changed files with 219 additions and 2 deletions

View file

@ -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

View file

@ -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}) => {

View file

@ -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(

View file

@ -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
}

View file

@ -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)
}

114
src/app/reminders.ts Normal file
View file

@ -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)
}
}
}
}

View file

@ -167,6 +167,8 @@ export const notificationSettings = withGetter(
spaces: true,
mentions: true,
messages: true,
events: true,
reminder_minutes: [5],
}),
)

View file

@ -1,9 +1,11 @@
<script lang="ts">
import cx from "classnames"
import {sleep} from "@welshman/lib"
import {removeAt, replaceAt, sleep} from "@welshman/lib"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import Bell from "@assets/icons/bell.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import ToggleInput from "@lib/components/ToggleInput.svelte"
@ -14,16 +16,39 @@
import {pushToast} from "@app/toast"
import {clearBadges} from "@app/notifications"
import {Push} from "@app/push"
import {syncEventReminders} from "@app/reminders"
import {notificationSettings} from "@app/settings"
const reset = () => {
settings = {...notificationSettings.get()}
}
const addReminder = () => {
settings.reminder_minutes = [...settings.reminder_minutes, 60]
}
const removeReminder = (index: number) => {
settings.reminder_minutes = removeAt(index, settings.reminder_minutes)
}
const onReminderInput = (e: Event) => {
const target = e.currentTarget as HTMLInputElement
const index = Number(target.dataset.index)
settings.reminder_minutes = replaceAt(index, Number(target.value), settings.reminder_minutes)
}
const onsubmit = preventDefault(async () => {
loading = true
try {
if (settings.events && settings.reminder_minutes.some(minutes => minutes < 1)) {
return pushToast({
theme: "error",
message: "A reminder must come at least a minute before the event starts.",
})
}
if (!settings.badge) {
clearBadges()
}
@ -47,6 +72,8 @@
notificationSettings.set(settings)
syncEventReminders()
pushToast({message: "Your settings have been saved!"})
} finally {
loading = false
@ -126,6 +153,42 @@
<ToggleInput bind:checked={settings.messages} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Remind me about events I'm going to</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.events} />
{/snippet}
</FieldInline>
{#if settings.events}
<div class="flex flex-col gap-2">
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each settings.reminder_minutes as minutes, index}
<div class="flex items-center gap-2">
<Button
class="button button-ghost button-sm"
type="button"
onclick={() => removeReminder(index)}>
<Icon icon={TrashBin2} />
</Button>
<label class="input flex grow items-center gap-2">
<input
type="number"
class="w-16"
data-index={index}
value={minutes}
oninput={onReminderInput} />
<span class="text-sm opacity-75">minutes before it starts</span>
</label>
</div>
{/each}
<Button class="button button-link w-fit px-0" type="button" onclick={addReminder}>
<Icon icon={AddCircle} size={5} />
Add reminder
</Button>
</div>
{/if}
</div>
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
<Button class="button button-neutral" onclick={reset} disabled={loading}