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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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,
|
||||
mentions: true,
|
||||
messages: true,
|
||||
events: true,
|
||||
reminder_minutes: [5],
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue