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 permission is refused, an error toast reports it and the push toggle settles
off, leaving badge and sound settings untouched. off, leaving badge and sound settings untouched.
- "Discard Changes" reverts unsaved edits to the last saved values. - "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 ### US-087 — Configure content display

View file

@ -98,6 +98,26 @@ test("US-086 configure alerts", async ({seed, as}) => {
await waitForToastToClear(page) 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 sound.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click() await page.getByRole("button", {name: "Save Changes"}).click()
@ -107,6 +127,8 @@ test("US-086 configure alerts", async ({seed, as}) => {
await page.reload() await page.reload()
await expect(settingToggle(page, "Play sound for new activity")).not.toBeChecked() 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}) => { 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 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. // A relay can still hold a replaced RSVP, so the newest per person is the one that counts.
export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => { export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => {
const latest = uniqBy( const latest = uniqBy(

View file

@ -13,9 +13,11 @@
RsvpStatus, RsvpStatus,
getRsvpStatus, getRsvpStatus,
getRsvpsByStatus, getRsvpsByStatus,
isAttending,
publishRsvp, publishRsvp,
retractRsvp, retractRsvp,
} from "@app/calendar" } from "@app/calendar"
import {setEventReminders} from "@app/reminders"
type Props = { type Props = {
url: string url: string
@ -33,12 +35,16 @@
loading = true loading = true
const retracting = ownRsvp && getRsvpStatus(ownRsvp) === status
try { try {
if (ownRsvp && getRsvpStatus(ownRsvp) === status) { if (retracting) {
await retractRsvp(url, ownRsvp) await retractRsvp(url, ownRsvp)
} else { } else {
await publishRsvp(url, event, status) await publishRsvp(url, event, status)
} }
await setEventReminders(url, event, !retracting && isAttending(status))
} finally { } finally {
loading = false loading = false
} }

View file

@ -11,6 +11,7 @@ import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env"
import {pushState} from "@app/push/adapters/common" import {pushState} from "@app/push/adapters/common"
import type {IPushAdapter} from "@app/push/adapters/common" import type {IPushAdapter} from "@app/push/adapters/common"
import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common" import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common"
import {syncEventReminders} from "@app/reminders"
const requestPermissions = async () => { const requestPermissions = async () => {
let status = await PushNotifications.checkPermissions() let status = await PushNotifications.checkPermissions()
@ -188,6 +189,8 @@ export class CapacitorNotifications implements IPushAdapter {
this._unsyncRelay(url, key) this._unsyncRelay(url, key)
} }
}) })
await syncEventReminders()
} catch (e) { } catch (e) {
console.error(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, spaces: true,
mentions: true, mentions: true,
messages: true, messages: true,
events: true,
reminder_minutes: [5],
}), }),
) )

View file

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {sleep} from "@welshman/lib" import {removeAt, replaceAt, sleep} from "@welshman/lib"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge" import {Badge} from "@capawesome/capacitor-badge"
import Bell from "@assets/icons/bell.svg?dataurl" 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 {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import ToggleInput from "@lib/components/ToggleInput.svelte" import ToggleInput from "@lib/components/ToggleInput.svelte"
@ -14,16 +16,39 @@
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {clearBadges} from "@app/notifications" import {clearBadges} from "@app/notifications"
import {Push} from "@app/push" import {Push} from "@app/push"
import {syncEventReminders} from "@app/reminders"
import {notificationSettings} from "@app/settings" import {notificationSettings} from "@app/settings"
const reset = () => { const reset = () => {
settings = {...notificationSettings.get()} 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 () => { const onsubmit = preventDefault(async () => {
loading = true loading = true
try { 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) { if (!settings.badge) {
clearBadges() clearBadges()
} }
@ -47,6 +72,8 @@
notificationSettings.set(settings) notificationSettings.set(settings)
syncEventReminders()
pushToast({message: "Your settings have been saved!"}) pushToast({message: "Your settings have been saved!"})
} finally { } finally {
loading = false loading = false
@ -126,6 +153,42 @@
<ToggleInput bind:checked={settings.messages} /> <ToggleInput bind:checked={settings.messages} />
{/snippet} {/snippet}
</FieldInline> </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>
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4"> <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} <Button class="button button-neutral" onclick={reset} disabled={loading}