Publish and read RSVPs through the CalendarRsvp domain kind, and read calendar times and titles through TimeEvent
This commit is contained in:
parent
ba03406795
commit
e7c7fac112
5 changed files with 62 additions and 72 deletions
|
|
@ -6,15 +6,17 @@ import {
|
||||||
pushToMapKey,
|
pushToMapKey,
|
||||||
secondsToDate,
|
secondsToDate,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
spec,
|
||||||
uniqBy,
|
uniqBy,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import {EVENT_RSVP, getAddress, makeEvent, tagSpec, tagValue} from "@welshman/util"
|
import {EVENT_RSVP, getAddress, tagSpec, tagValue} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {TimeEvent} from "@welshman/domain"
|
import {CalendarRsvp, TimeEvent} from "@welshman/domain"
|
||||||
|
import type {RsvpStatus} from "@welshman/domain"
|
||||||
import {synced} from "@welshman/store"
|
import {synced} from "@welshman/store"
|
||||||
import {deletes, reader, relays, thunks} from "@app/core"
|
import {deletes, reader, relays, thunks, writer} from "@app/core"
|
||||||
import {deriveEvents} from "@app/repository"
|
import {deriveEvents} from "@app/repository"
|
||||||
import {PROTECTED} from "@app/rooms"
|
import {toSpace} from "@app/rooms"
|
||||||
import {kv} from "@app/storage"
|
import {kv} from "@app/storage"
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
|
|
@ -230,12 +232,6 @@ export const layoutMultiDayBars = (
|
||||||
|
|
||||||
// RSVPs (NIP-52)
|
// RSVPs (NIP-52)
|
||||||
|
|
||||||
export enum RsvpStatus {
|
|
||||||
Accepted = "accepted",
|
|
||||||
Declined = "declined",
|
|
||||||
Tentative = "tentative",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeRsvpFilter = (event: TrustedEvent) => ({
|
export const makeRsvpFilter = (event: TrustedEvent) => ({
|
||||||
kinds: [EVENT_RSVP],
|
kinds: [EVENT_RSVP],
|
||||||
"#a": [getAddress(event)],
|
"#a": [getAddress(event)],
|
||||||
|
|
@ -243,10 +239,7 @@ export const makeRsvpFilter = (event: TrustedEvent) => ({
|
||||||
|
|
||||||
export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter(event)])
|
export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter(event)])
|
||||||
|
|
||||||
export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags)
|
export const isAttending = (status?: RsvpStatus) => status === "accepted" || status === "tentative"
|
||||||
|
|
||||||
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[]) => {
|
||||||
|
|
@ -256,37 +249,32 @@ export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const byStatus = (status: RsvpStatus) =>
|
const byStatus = (status: RsvpStatus) =>
|
||||||
latest.filter(rsvp => getRsvpStatus(rsvp) === status).map(rsvp => rsvp.pubkey)
|
latest.filter(rsvp => reader(CalendarRsvp)(rsvp).status() === status).map(rsvp => rsvp.pubkey)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
latest,
|
latest,
|
||||||
accepted: byStatus(RsvpStatus.Accepted),
|
accepted: byStatus("accepted"),
|
||||||
tentative: byStatus(RsvpStatus.Tentative),
|
tentative: byStatus("tentative"),
|
||||||
declined: byStatus(RsvpStatus.Declined),
|
declined: byStatus("declined"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An RSVP is addressable, so identifying it by its target makes a new one supersede the old.
|
// An RSVP is addressable, so identifying it by its target makes a new one supersede the old.
|
||||||
export const publishRsvp = async (url: string, event: TrustedEvent, status: RsvpStatus) => {
|
export const publishRsvp = async (url: string, event: TrustedEvent, status: RsvpStatus) => {
|
||||||
const address = getAddress(event)
|
const eventWriter = await toSpace(
|
||||||
const h = tagValue(tagSpec("h"), event.tags)
|
writer(CalendarRsvp)
|
||||||
const tags = [
|
.setIdentifier(getAddress(event))
|
||||||
["a", address, url],
|
.setCalendarEventFromEvent(event)
|
||||||
["e", event.id, url],
|
.setStatus(status)
|
||||||
["d", address],
|
// The writer hints the organizer's outbox, but a space's event lives on the space relay.
|
||||||
["status", status],
|
.dropTags(spec(["a"]))
|
||||||
["p", event.pubkey],
|
.dropTags(spec(["e"]))
|
||||||
]
|
.addTags(["a", getAddress(event), url], ["e", event.id, url]),
|
||||||
|
url,
|
||||||
|
reader(TimeEvent)(event).room(),
|
||||||
|
)
|
||||||
|
|
||||||
if (await relays.get().hasNip(url, 70)) {
|
return thunks.get().publish({relays: [url], event: await eventWriter.renderTemplate()})
|
||||||
tags.push(PROTECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (h) {
|
|
||||||
tags.push(["h", h])
|
|
||||||
}
|
|
||||||
|
|
||||||
return thunks.get().publish({relays: [url], event: makeEvent(EVENT_RSVP, {tags})})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {
|
export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {CalendarRsvp} from "@welshman/domain"
|
||||||
|
import type {RsvpStatus} from "@welshman/domain"
|
||||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
import QuestionCircle from "@assets/icons/question-circle.svg?dataurl"
|
import QuestionCircle from "@assets/icons/question-circle.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
|
@ -8,15 +10,8 @@
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {user} from "@app/core"
|
import {reader, user} from "@app/core"
|
||||||
import {
|
import {getRsvpsByStatus, isAttending, publishRsvp, retractRsvp} from "@app/calendar"
|
||||||
RsvpStatus,
|
|
||||||
getRsvpStatus,
|
|
||||||
getRsvpsByStatus,
|
|
||||||
isAttending,
|
|
||||||
publishRsvp,
|
|
||||||
retractRsvp,
|
|
||||||
} from "@app/calendar"
|
|
||||||
import {setEventReminders} from "@app/reminders"
|
import {setEventReminders} from "@app/reminders"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -35,7 +30,7 @@
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
const retracting = ownRsvp && getRsvpStatus(ownRsvp) === status
|
const retracting = ownRsvp && ownStatus === status
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (retracting) {
|
if (retracting) {
|
||||||
|
|
@ -50,11 +45,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accept = () => setStatus(RsvpStatus.Accepted)
|
const accept = () => setStatus("accepted")
|
||||||
|
|
||||||
const maybe = () => setStatus(RsvpStatus.Tentative)
|
const maybe = () => setStatus("tentative")
|
||||||
|
|
||||||
const decline = () => setStatus(RsvpStatus.Declined)
|
const decline = () => setStatus("declined")
|
||||||
|
|
||||||
const statusClass = (status: RsvpStatus) =>
|
const statusClass = (status: RsvpStatus) =>
|
||||||
cx("button button-xs join-item", ownStatus === status ? "button-primary" : "button-neutral")
|
cx("button button-xs join-item", ownStatus === status ? "button-primary" : "button-neutral")
|
||||||
|
|
@ -63,7 +58,7 @@
|
||||||
|
|
||||||
const people = $derived(getRsvpsByStatus(rsvps))
|
const people = $derived(getRsvpsByStatus(rsvps))
|
||||||
const ownRsvp = $derived(people.latest.find(rsvp => rsvp.pubkey === $user.pubkey))
|
const ownRsvp = $derived(people.latest.find(rsvp => rsvp.pubkey === $user.pubkey))
|
||||||
const ownStatus = $derived(ownRsvp ? getRsvpStatus(ownRsvp) : undefined)
|
const ownStatus = $derived(ownRsvp ? reader(CalendarRsvp)(ownRsvp).status() : undefined)
|
||||||
const attending = $derived([...people.accepted, ...people.tentative])
|
const attending = $derived([...people.accepted, ...people.tentative])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -71,15 +66,15 @@
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm opacity-75">Are you going?</span>
|
<span class="text-sm opacity-75">Are you going?</span>
|
||||||
<div class="join">
|
<div class="join">
|
||||||
<Button class={statusClass(RsvpStatus.Accepted)} disabled={loading} onclick={accept}>
|
<Button class={statusClass("accepted")} disabled={loading} onclick={accept}>
|
||||||
<Icon icon={CheckCircle} size={4} />
|
<Icon icon={CheckCircle} size={4} />
|
||||||
Going
|
Going
|
||||||
</Button>
|
</Button>
|
||||||
<Button class={statusClass(RsvpStatus.Tentative)} disabled={loading} onclick={maybe}>
|
<Button class={statusClass("tentative")} disabled={loading} onclick={maybe}>
|
||||||
<Icon icon={QuestionCircle} size={4} />
|
<Icon icon={QuestionCircle} size={4} />
|
||||||
Maybe
|
Maybe
|
||||||
</Button>
|
</Button>
|
||||||
<Button class={statusClass(RsvpStatus.Declined)} disabled={loading} onclick={decline}>
|
<Button class={statusClass("declined")} disabled={loading} onclick={decline}>
|
||||||
<Icon icon={CloseCircle} size={4} />
|
<Icon icon={CloseCircle} size={4} />
|
||||||
Can't go
|
Can't go
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,9 @@ import {
|
||||||
hexTags,
|
hexTags,
|
||||||
isReplaceableKind,
|
isReplaceableKind,
|
||||||
matchFilters,
|
matchFilters,
|
||||||
tagSpec,
|
|
||||||
tagValue,
|
|
||||||
tagValues,
|
tagValues,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import type {Filter, TrustedEvent} from "@welshman/util"
|
import type {Filter, TrustedEvent} from "@welshman/util"
|
||||||
import {mergeRepositoryUpdates} from "@welshman/net"
|
import {mergeRepositoryUpdates} from "@welshman/net"
|
||||||
|
|
@ -27,7 +26,7 @@ import {createScroller} from "@lib/html"
|
||||||
import type {ScrollerOpts} from "@lib/html"
|
import type {ScrollerOpts} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
import {EVENT_CONTEXT_KINDS, REACTION_KINDS} from "@app/content"
|
import {EVENT_CONTEXT_KINDS, REACTION_KINDS} from "@app/content"
|
||||||
import {app, network} from "@app/core"
|
import {app, network, reader} from "@app/core"
|
||||||
import {getEventsForUrl} from "@app/repository"
|
import {getEventsForUrl} from "@app/repository"
|
||||||
|
|
||||||
const noEvents: TrustedEvent[] = []
|
const noEvents: TrustedEvent[] = []
|
||||||
|
|
@ -682,13 +681,17 @@ export const makeCalendarFeed = ({
|
||||||
let oldest = now()
|
let oldest = now()
|
||||||
let newest = now()
|
let newest = now()
|
||||||
|
|
||||||
const getStart = (event: TrustedEvent) => parseInt(tagValue(tagSpec("start"), event.tags) || "")
|
const getStart = (event: TrustedEvent) => reader(TimeEvent)(event).start() ?? 0
|
||||||
|
|
||||||
const getEnd = (event: TrustedEvent) => parseInt(tagValue(tagSpec("end"), event.tags) || "")
|
|
||||||
|
|
||||||
const compareByStart = (a: TrustedEvent, b: TrustedEvent) =>
|
const compareByStart = (a: TrustedEvent, b: TrustedEvent) =>
|
||||||
getStart(a) - getStart(b) || compareEventsAsc(a, b)
|
getStart(a) - getStart(b) || compareEventsAsc(a, b)
|
||||||
|
|
||||||
|
const isValid = (event: TrustedEvent) => {
|
||||||
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
|
|
||||||
|
return timeEvent.start() && timeEvent.end() && !seen.has(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
const events = writable(
|
const events = writable(
|
||||||
uniqBy(
|
uniqBy(
|
||||||
e => e.id,
|
e => e.id,
|
||||||
|
|
@ -697,7 +700,7 @@ export const makeCalendarFeed = ({
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertEvents = (newEvents: TrustedEvent[]) => {
|
const insertEvents = (newEvents: TrustedEvent[]) => {
|
||||||
const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)) && !seen.has(e.id))
|
const valid = newEvents.filter(isValid)
|
||||||
|
|
||||||
if (valid.length === 0) {
|
if (valid.length === 0) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import {MINUTE, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib"
|
import {MINUTE, first, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib"
|
||||||
import {EVENT_RSVP, addressTags, getIdFilters, isSignedEvent, matchTag} from "@welshman/util"
|
import {EVENT_RSVP, getIdFilters, isSignedEvent} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {CalendarRsvp} from "@welshman/domain"
|
||||||
import {buildUrl} from "@lib/util"
|
import {buildUrl} from "@lib/util"
|
||||||
import {getEventStart, getEventTitle, getRsvpStatus, isAttending} from "@app/calendar"
|
import {getEventStart, getEventTitle, isAttending} from "@app/calendar"
|
||||||
import {app, network, roomLists} from "@app/core"
|
import {app, network, reader, roomLists} from "@app/core"
|
||||||
import {PUSH_SERVER} from "@app/env"
|
import {PUSH_SERVER} from "@app/env"
|
||||||
import {pushState} from "@app/push/adapters/common"
|
import {pushState} from "@app/push/adapters/common"
|
||||||
import {notificationSettings} from "@app/settings"
|
import {notificationSettings} from "@app/settings"
|
||||||
|
|
@ -70,13 +71,15 @@ const loadAttendedEvents = async (pubkey: string) => {
|
||||||
|
|
||||||
await $network.request({signal, filters, relays, autoClose: true})
|
await $network.request({signal, filters, relays, autoClose: true})
|
||||||
|
|
||||||
const attended = app
|
const {repository, tracker} = app.get()
|
||||||
.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]}] : []
|
// An RSVP only goes to the space holding its event, so where it was seen is where the event is.
|
||||||
|
const attended = repository.query(filters).flatMap(rsvp => {
|
||||||
|
const rsvpReader = reader(CalendarRsvp)(rsvp)
|
||||||
|
const address = rsvpReader.calendarEvent()
|
||||||
|
const relay = first(Array.from(tracker.getRelays(rsvp.id)))
|
||||||
|
|
||||||
|
return address && relay && isAttending(rsvpReader.status()) ? [{address, relay}] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
await $network.request({
|
await $network.request({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import {displayPubkey} from "@welshman/domain"
|
import {displayPubkey} from "@welshman/domain"
|
||||||
import {makeRoomKey} from "@welshman/app"
|
import {makeRoomKey} from "@welshman/app"
|
||||||
import {decodePubkey} from "@lib/util"
|
import {decodePubkey} from "@lib/util"
|
||||||
|
import {getEventTitle} from "@app/calendar"
|
||||||
import {app, profiles, rooms} from "@app/core"
|
import {app, profiles, rooms} from "@app/core"
|
||||||
import {PLATFORM_NAME} from "@app/env"
|
import {PLATFORM_NAME} from "@app/env"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
|
|
@ -137,7 +138,7 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/calendar/[address]") {
|
if (routeId === "/spaces/[relay]/calendar/[address]") {
|
||||||
return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Event")
|
return makeTitle((event && getEventTitle(event)) || "Event")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/classifieds/[address]") {
|
if (routeId === "/spaces/[relay]/classifieds/[address]") {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue