Remind people about the calendar events they are going to (#646)
This commit is contained in:
parent
653664751f
commit
fecee114e7
8 changed files with 219 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}) => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
114
src/app/reminders.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue