Discord events: the events row, the events pane, and creating one
A guild with events coming or on shows '1 Event' / 'N Events' at the top
of its channel list, as Discord does, and nothing when there are none.
Pressed, it opens the events pane: each event's time ('Tomorrow at 7:00
PM', or Happening now), who made it, how many are interested, its name,
description and where it is, with a menu to start, end or cancel it or
copy its link, Share, and Interested. The server name's menu gains Create
Event, which opens the create panel: where (stage, voice channel or
somewhere else), topic, start and end, description - saying what is
missing before anything is sent.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: a5eb3b4f92...4ae8541cda
@@ -24,6 +24,7 @@ import { IncomingCallPanel } from './components/IncomingCallPanel'
|
||||
import { CallAudio, CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
|
||||
import { CallWindowHost } from './components/stage/CallWindowHost'
|
||||
import { StreamStage, StreamWindowHost } from './components/StreamStage'
|
||||
import { EventCreatePanel, EventsPane } from './components/DiscordEvents'
|
||||
import { DiscordModal } from './components/DiscordModal'
|
||||
import { FileDrop } from './components/FileDrop'
|
||||
import { TransferPanel } from './components/TransferPanel'
|
||||
@@ -283,6 +284,10 @@ export default function App(): JSX.Element {
|
||||
{/* A form a Discord bot asked for. Over everything, because it is the
|
||||
answer to something just pressed and it expires. */}
|
||||
<DiscordModal />
|
||||
{/* A guild's events, and making one: over everything, opened from
|
||||
its channel list. */}
|
||||
<EventsPane />
|
||||
<EventCreatePanel />
|
||||
{/* Where a call goes when you walk away from it: a corner of its own,
|
||||
the way a phone keeps the picture in the corner. In the conversation
|
||||
it belongs to it sits above the log instead - see MessageList - so
|
||||
|
||||
@@ -7747,3 +7747,264 @@ video.lightbox-media {
|
||||
.call-stage-title.window-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* A Discord guild's events row, at the top of its channel list. */
|
||||
.bufferlist-events {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: calc(100% - 2 * var(--spacing-xs));
|
||||
margin: 2px var(--spacing-xs) 6px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: transparent;
|
||||
color: var(--surface-variant-text, inherit);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bufferlist-events:hover {
|
||||
background: var(--surface-container-highest);
|
||||
color: var(--on-surface, inherit);
|
||||
}
|
||||
|
||||
/* The events pane and the create panel: Discord's dark rounded sheet. */
|
||||
.events-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(600px, 94vw);
|
||||
max-height: 80vh;
|
||||
margin: auto;
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.events-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 20px 14px;
|
||||
}
|
||||
|
||||
.events-pane-count {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events-pane-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--outline-medium);
|
||||
}
|
||||
|
||||
.events-pane-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.events-pane-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 160px;
|
||||
padding: 4px 20px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.events-pane-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
border: 1px solid var(--outline-variant, var(--outline-medium));
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-card.live {
|
||||
border-color: #3ba55d;
|
||||
}
|
||||
|
||||
.event-card-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 180px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.event-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.event-card-when {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.event-card.live .event-card-when {
|
||||
color: #3ba55d;
|
||||
}
|
||||
|
||||
.event-card-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-container-highest);
|
||||
color: var(--on-surface, inherit);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.event-card-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-card-desc {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.event-card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--outline-variant, var(--outline-medium));
|
||||
}
|
||||
|
||||
.event-card-where {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container-highest);
|
||||
color: var(--on-surface, inherit);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-button.icon-only {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.event-button:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.event-button.on {
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
.event-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Creating one. */
|
||||
.event-create {
|
||||
width: min(520px, 94vw);
|
||||
}
|
||||
|
||||
.event-create-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0 20px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-create-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-create-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-where {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-where.on {
|
||||
outline: 1px solid var(--primary);
|
||||
background: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
.event-where.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.event-where > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-where-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-field-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-field textarea {
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.event-create-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--outline-variant, var(--outline-medium));
|
||||
background: var(--surface-container);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,17 @@ export function BufferList(): JSX.Element {
|
||||
}
|
||||
|
||||
const openGroupMenu = (e: React.MouseEvent): void => openGroupMenuAt(e)
|
||||
|
||||
// The Discord guild on screen, if it is one: where events come from.
|
||||
const eventGuild =
|
||||
activeGroup?.kind === 'guild' && activeGroup.accountId?.startsWith('discord:')
|
||||
? { accountId: activeGroup.accountId, guildId: activeGroup.id.split('|guild:')[1] ?? '' }
|
||||
: null
|
||||
const eventCounts = useChat((s) => s.discordEventCounts)
|
||||
const eventCount = eventGuild ? (eventCounts[`${eventGuild.accountId}|${eventGuild.guildId}`] ?? 0) : 0
|
||||
useEffect(() => {
|
||||
if (eventGuild?.guildId) void store.refreshEventCount(eventGuild.accountId, eventGuild.guildId)
|
||||
}, [store, eventGuild?.accountId, eventGuild?.guildId])
|
||||
const openCategoryMenu = (e: React.MouseEvent, section: CategorySection): void => {
|
||||
e.preventDefault()
|
||||
// Only a heading you made is yours to rename or remove; a server's
|
||||
@@ -413,11 +424,11 @@ export function BufferList(): JSX.Element {
|
||||
type="button"
|
||||
className="ellipsis group-title-name"
|
||||
title={`${activeGroup.name} — organise this list`}
|
||||
onClick={grouped ? openGroupMenu : undefined}
|
||||
disabled={!grouped}
|
||||
onClick={grouped || eventGuild ? openGroupMenu : undefined}
|
||||
disabled={!grouped && !eventGuild}
|
||||
>
|
||||
{activeGroup.name}
|
||||
{grouped && <Icon name="expand_more" size={14} />}
|
||||
{(grouped || eventGuild) && <Icon name="expand_more" size={14} />}
|
||||
</button>
|
||||
{/* Only where the page is actually one account's. The direct
|
||||
messages and pinned pages gather several, so a single
|
||||
@@ -427,6 +438,22 @@ export function BufferList(): JSX.Element {
|
||||
{activeGroup.accountId && groupAccount && <ConnectionDot state={groupAccount.state} />}
|
||||
</div>
|
||||
|
||||
{/* A Discord guild's events, at the top of its list as Discord
|
||||
puts them - and only when there are some: a row reading
|
||||
"0 Events" is a row nobody needs. */}
|
||||
{eventGuild && eventCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="bufferlist-events"
|
||||
onClick={() => store.openEvents(eventGuild.accountId, eventGuild.guildId)}
|
||||
>
|
||||
<Icon name="calendar_month" size={18} />
|
||||
<span>
|
||||
{eventCount} Event{eventCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{groupBuffers.length === 0 && (
|
||||
<div className="bufferlist-empty muted small">Nothing here yet.</div>
|
||||
)}
|
||||
@@ -531,7 +558,19 @@ export function BufferList(): JSX.Element {
|
||||
x={groupMenu.x}
|
||||
y={groupMenu.y}
|
||||
entries={[
|
||||
{
|
||||
// Discord's own first item on a server's menu, and the way into
|
||||
// the create panel.
|
||||
...(eventGuild
|
||||
? [
|
||||
{
|
||||
label: 'Create Event',
|
||||
icon: 'calendar_add_on',
|
||||
onClick: () => store.openEventCreate(eventGuild.accountId, eventGuild.guildId)
|
||||
},
|
||||
{ separator: true as const }
|
||||
]
|
||||
: []),
|
||||
...(grouped ? [{
|
||||
label: 'New category',
|
||||
icon: 'create_new_folder',
|
||||
onClick: () => {
|
||||
@@ -551,7 +590,7 @@ export function BufferList(): JSX.Element {
|
||||
// is not one anybody meant to keep.
|
||||
setNaming({ id, name: 'New category' })
|
||||
}
|
||||
}
|
||||
}] : [])
|
||||
]}
|
||||
onClose={closeGroupMenu}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { ContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import type { VoiceChannel } from '../../../shared/wire'
|
||||
|
||||
/** One scheduled event, as the daemon describes it. */
|
||||
interface DiscordEvent {
|
||||
id: string
|
||||
guildId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
/** RFC 3339. */
|
||||
start: string
|
||||
end?: string | null
|
||||
live: boolean
|
||||
where: 'voice' | 'stage' | 'external'
|
||||
channelId?: string | null
|
||||
channelName?: string | null
|
||||
location?: string | null
|
||||
creatorId?: string | null
|
||||
creatorName?: string | null
|
||||
creatorAvatar?: string | null
|
||||
image?: string | null
|
||||
userCount: number
|
||||
interested: boolean
|
||||
link: string
|
||||
}
|
||||
|
||||
/**
|
||||
* When an event is, the way Discord says it: "Today at 7:00 PM",
|
||||
* "Tomorrow at 7:00 PM", "Fri, Oct 9 at 7:00 PM".
|
||||
*/
|
||||
export function eventWhen(iso: string, now = new Date()): string {
|
||||
const at = new Date(iso)
|
||||
const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
const day = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||
const days = Math.round((day(at) - day(now)) / 86_400_000)
|
||||
if (days === 0) return `Today at ${time}`
|
||||
if (days === 1) return `Tomorrow at ${time}`
|
||||
const date = at.toLocaleDateString([], {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(at.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {})
|
||||
})
|
||||
return `${date} at ${time}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A guild's events, in a pane over everything - opened from the events row at
|
||||
* the top of its channel list, as Discord opens it.
|
||||
*/
|
||||
export function EventsPane(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const pane = useChat((s) => s.eventsPane)
|
||||
const counts = useChat((s) => s.discordEventCounts)
|
||||
const [events, setEvents] = useState<DiscordEvent[] | null>(null)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; event: DiscordEvent } | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!pane) return
|
||||
void window.moho
|
||||
.rpc<DiscordEvent[]>('listDiscordEvents', pane)
|
||||
.then(setEvents)
|
||||
.catch((e: Error) => {
|
||||
setEvents([])
|
||||
store.toast('error', `Couldn't read the events: ${e.message}`)
|
||||
})
|
||||
}, [pane, store])
|
||||
|
||||
useEffect(() => {
|
||||
setEvents(null)
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Kept current while open: somebody else marking interest, starting it,
|
||||
// cancelling it - each arrives as a change in the guild's count, and is
|
||||
// read again from the source.
|
||||
const count = pane ? counts[`${pane.accountId}|${pane.guildId}`] : undefined
|
||||
useEffect(() => {
|
||||
if (count !== undefined) load()
|
||||
}, [count, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pane) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && !menu) store.closeEvents()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [pane, menu, store])
|
||||
|
||||
if (!pane) return null
|
||||
|
||||
const act = (event: DiscordEvent, method: string, params: Record<string, unknown>, failed: string): void => {
|
||||
setBusy(event.id)
|
||||
void window.moho
|
||||
.rpc(method, { ...pane, eventId: event.id, ...params })
|
||||
.then(load)
|
||||
.catch((e: Error) => store.toast('error', `${failed}: ${e.message}`))
|
||||
.finally(() => setBusy(''))
|
||||
}
|
||||
|
||||
const copyLink = (event: DiscordEvent): void => {
|
||||
void navigator.clipboard
|
||||
.writeText(event.link)
|
||||
.then(() => store.toast('info', 'Link to the event copied'))
|
||||
.catch(() => store.toast('error', "Couldn't copy the link"))
|
||||
}
|
||||
|
||||
const menuFor = (event: DiscordEvent): MenuEntry[] => [
|
||||
...(event.live
|
||||
? [{ label: 'End event', icon: 'stop_circle', onClick: () => act(event, 'setDiscordEventStatus', { status: 'end' }, "Couldn't end it") }]
|
||||
: [
|
||||
{ label: 'Start event', icon: 'play_circle', onClick: () => act(event, 'setDiscordEventStatus', { status: 'start' }, "Couldn't start it") },
|
||||
{
|
||||
label: 'Cancel event',
|
||||
icon: 'event_busy',
|
||||
danger: true,
|
||||
onClick: () => act(event, 'setDiscordEventStatus', { status: 'cancel' }, "Couldn't cancel it")
|
||||
}
|
||||
]),
|
||||
{ separator: true },
|
||||
{ label: 'Copy event link', icon: 'link', onClick: () => copyLink(event) }
|
||||
]
|
||||
|
||||
const n = events?.length ?? 0
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={() => store.closeEvents()}>
|
||||
<div className="events-pane" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Events">
|
||||
<div className="events-pane-head">
|
||||
<Icon name="calendar_month" size={22} />
|
||||
<span className="events-pane-count">
|
||||
{events === null ? 'Events' : `${n} Event${n === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<span className="events-pane-sep" />
|
||||
<button type="button" className="button primary" onClick={() => store.openEventCreate(pane.accountId, pane.guildId)}>
|
||||
Create Event
|
||||
</button>
|
||||
<span className="events-pane-spacer" />
|
||||
<IconButton name="close" title="Close" onClick={() => store.closeEvents()} />
|
||||
</div>
|
||||
|
||||
<div className="events-pane-list">
|
||||
{events === null && <p className="small muted">Loading…</p>}
|
||||
{events !== null && n === 0 && (
|
||||
<div className="events-pane-empty muted">
|
||||
<Icon name="event" size={32} />
|
||||
<span>Nothing is scheduled. Create an event and it will show here.</span>
|
||||
</div>
|
||||
)}
|
||||
{events?.map((event) => (
|
||||
<div key={event.id} className={`event-card${event.live ? ' live' : ''}`}>
|
||||
{event.image && <img className="event-card-image" src={event.image} alt="" />}
|
||||
<div className="event-card-body">
|
||||
<div className="event-card-when">
|
||||
<Icon name={event.live ? 'radio_button_checked' : 'calendar_month'} size={16} />
|
||||
<span>{event.live ? 'Happening now' : eventWhen(event.start)}</span>
|
||||
<span className="events-pane-spacer" />
|
||||
{event.creatorName && <Avatar name={event.creatorName} url={event.creatorAvatar ?? undefined} size={20} />}
|
||||
<span className="event-card-count" title={`${event.userCount} interested`}>
|
||||
<Icon name="group" size={14} />
|
||||
{event.userCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="event-card-name">{event.name}</div>
|
||||
{event.description && <div className="event-card-desc small muted">{event.description}</div>}
|
||||
</div>
|
||||
<div className="event-card-foot">
|
||||
<span className="event-card-where ellipsis">
|
||||
<Icon name={event.where === 'stage' ? 'podium' : event.where === 'voice' ? 'volume_up' : 'location_on'} size={16} />
|
||||
<span className="ellipsis">
|
||||
{event.where === 'external' ? event.location : event.channelName ?? 'A channel'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="events-pane-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="event-button icon-only"
|
||||
title="More"
|
||||
onClick={(e) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setMenu({ x: r.left, y: r.bottom + 4, event })
|
||||
}}
|
||||
>
|
||||
<Icon name="more_horiz" size={18} />
|
||||
</button>
|
||||
<button type="button" className="event-button" onClick={() => copyLink(event)}>
|
||||
<Icon name="ios_share" size={16} />
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`event-button${event.interested ? ' on' : ''}`}
|
||||
disabled={busy === event.id}
|
||||
onClick={() => act(event, 'setDiscordEventInterest', { on: !event.interested }, "Couldn't change that")}
|
||||
>
|
||||
<Icon name={event.interested ? 'check' : 'notifications'} size={16} />
|
||||
Interested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} entries={menuFor(menu.event)} onClose={() => setMenu(null)} />}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/** A date and a time, as the two inputs hold them, from a Date. */
|
||||
function parts(d: Date): { date: string; time: string } {
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
return {
|
||||
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||||
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
}
|
||||
|
||||
/** The two inputs back to an instant, in this machine's time zone. */
|
||||
function instant(date: string, time: string): Date | null {
|
||||
if (!date || !time) return null
|
||||
const at = new Date(`${date}T${time}`)
|
||||
return Number.isNaN(at.getTime()) ? null : at
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating an event: where it happens, what it is, and when - the three
|
||||
* things Discord's own panel asks, on one page.
|
||||
*/
|
||||
export function EventCreatePanel(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const target = useChat((s) => s.eventCreate)
|
||||
const [channels, setChannels] = useState<VoiceChannel[]>([])
|
||||
const [kind, setKind] = useState<'voice' | 'stage' | 'external'>('voice')
|
||||
const [channelId, setChannelId] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
// An hour from now, on the hour: the commonest answer, and in the future.
|
||||
const initial = useMemo(() => {
|
||||
const d = new Date(Date.now() + 3_600_000)
|
||||
d.setMinutes(0, 0, 0)
|
||||
return parts(d)
|
||||
}, [])
|
||||
const [startDate, setStartDate] = useState(initial.date)
|
||||
const [startTime, setStartTime] = useState(initial.time)
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
void window.moho
|
||||
.rpc<VoiceChannel[]>('listVoiceChannels', target)
|
||||
.then((list) => {
|
||||
setChannels(list)
|
||||
const first = list.find((c) => !c.stage) ?? list[0]
|
||||
if (first) {
|
||||
setChannelId(first.id)
|
||||
setKind(first.stage ? 'stage' : 'voice')
|
||||
} else {
|
||||
setKind('external')
|
||||
}
|
||||
})
|
||||
.catch(() => setChannels([]))
|
||||
}, [target])
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') store.closeEventCreate()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [target, store])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const offered = channels.filter((c) => (kind === 'stage' ? c.stage : !c.stage))
|
||||
const start = instant(startDate, startTime)
|
||||
const end = instant(endDate, endTime)
|
||||
// Said before it is sent, rather than learned from Discord's refusal.
|
||||
const problem = !name.trim()
|
||||
? 'Give it a name'
|
||||
: !start || start.getTime() <= Date.now()
|
||||
? 'It has to start in the future'
|
||||
: end && end <= start
|
||||
? 'It has to end after it starts'
|
||||
: kind === 'external' && !location.trim()
|
||||
? 'Say where it happens'
|
||||
: kind === 'external' && !end
|
||||
? 'An event somewhere else needs an end time'
|
||||
: kind !== 'external' && !channelId
|
||||
? 'Pick the channel it happens in'
|
||||
: ''
|
||||
|
||||
const create = (): void => {
|
||||
if (problem || !start) return
|
||||
setSending(true)
|
||||
void window.moho
|
||||
.rpc('createDiscordEvent', {
|
||||
...target,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
start: start.toISOString(),
|
||||
end: end ? end.toISOString() : undefined,
|
||||
kind,
|
||||
channelId: kind === 'external' ? undefined : channelId,
|
||||
location: kind === 'external' ? location.trim() : undefined
|
||||
})
|
||||
.then(() => {
|
||||
store.toast('info', `${name.trim()} is scheduled`)
|
||||
store.closeEventCreate()
|
||||
store.openEvents(target.accountId, target.guildId)
|
||||
})
|
||||
.catch((e: Error) => store.toast('error', `Couldn't create the event: ${e.message}`))
|
||||
.finally(() => setSending(false))
|
||||
}
|
||||
|
||||
const choice = (value: typeof kind, icon: string, label: string, hint: string, disabled = false): JSX.Element => (
|
||||
<label className={`event-where${kind === value ? ' on' : ''}${disabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="event-where"
|
||||
checked={kind === value}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
setKind(value)
|
||||
const first = channels.find((c) => (value === 'stage' ? c.stage : !c.stage))
|
||||
if (value !== 'external') setChannelId(first?.id ?? '')
|
||||
}}
|
||||
/>
|
||||
<Icon name={icon} size={20} />
|
||||
<span>
|
||||
<span className="event-where-label">{label}</span>
|
||||
<span className="small muted">{hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={() => store.closeEventCreate()}>
|
||||
<div className="events-pane event-create" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Create an event">
|
||||
<div className="events-pane-head">
|
||||
<Icon name="calendar_add_on" size={22} />
|
||||
<span className="events-pane-count">Create an event</span>
|
||||
<span className="events-pane-spacer" />
|
||||
<IconButton name="close" title="Close" onClick={() => store.closeEventCreate()} />
|
||||
</div>
|
||||
|
||||
<div className="event-create-body">
|
||||
<div className="event-create-section">
|
||||
<div className="event-create-title">Where is your event?</div>
|
||||
<div className="event-create-hint small muted">So no one gets lost on where to go.</div>
|
||||
{choice('stage', 'podium', 'Stage Channel', 'For events with an audience', !channels.some((c) => c.stage))}
|
||||
{choice('voice', 'volume_up', 'Voice Channel', 'Hang out with voice, video, screen share', !channels.some((c) => !c.stage))}
|
||||
{choice('external', 'location_on', 'Somewhere Else', 'Text channel, external link, or in-person location')}
|
||||
{kind === 'external' ? (
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="Add a location, link, or something."
|
||||
maxLength={100}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<select className="text-field" value={channelId} onChange={(e) => setChannelId(e.target.value)}>
|
||||
{offered.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="event-create-section">
|
||||
<div className="event-create-title">What's your event about?</div>
|
||||
<label className="event-field">
|
||||
<span className="small muted">Event topic</span>
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="What's your event?"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="event-field-row">
|
||||
<label className="event-field">
|
||||
<span className="small muted">Start date</span>
|
||||
<input className="text-field" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="event-field">
|
||||
<span className="small muted">Start time</span>
|
||||
<input className="text-field" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="event-field-row">
|
||||
<label className="event-field">
|
||||
<span className="small muted">End date{kind === 'external' ? '' : ' (optional)'}</span>
|
||||
<input className="text-field" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="event-field">
|
||||
<span className="small muted">End time{kind === 'external' ? '' : ' (optional)'}</span>
|
||||
<input className="text-field" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="event-field">
|
||||
<span className="small muted">Description</span>
|
||||
<textarea
|
||||
className="text-field"
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
placeholder="Tell people a little more about your event."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="event-create-foot">
|
||||
<span className="small muted">{problem}</span>
|
||||
<span className="events-pane-spacer" />
|
||||
<button type="button" className="button subtle" onClick={() => store.closeEventCreate()}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="button primary" disabled={!!problem || sending} onClick={create}>
|
||||
{sending ? 'Creating…' : 'Create Event'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -538,6 +538,15 @@ export interface ChatState {
|
||||
* second arriving while one is open is a bot talking over itself.
|
||||
*/
|
||||
discordModal: DiscordModal | null
|
||||
/**
|
||||
* How many scheduled events each Discord guild has coming or on, by
|
||||
* `accountId|guildId`. Absent or none means no events row in its list.
|
||||
*/
|
||||
discordEventCounts: Record<string, number>
|
||||
/** The events pane, while open: whose guild. */
|
||||
eventsPane: { accountId: string; guildId: string } | null
|
||||
/** The create-an-event panel, while open. */
|
||||
eventCreate: { accountId: string; guildId: string } | null
|
||||
/** The screens and windows on offer, while somebody is choosing one. */
|
||||
screenSources: { id: string; name: string; thumbnail: string }[] | null
|
||||
/**
|
||||
@@ -680,6 +689,9 @@ const INITIAL: ChatState = {
|
||||
watchMinimized: false,
|
||||
screenSources: null,
|
||||
discordModal: null,
|
||||
discordEventCounts: {},
|
||||
eventsPane: null,
|
||||
eventCreate: null,
|
||||
activeCall: null,
|
||||
discordSharing: null,
|
||||
discordShareStream: null,
|
||||
@@ -1385,6 +1397,33 @@ export class ChatStore {
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/** Asks how many events a guild has, for the row at the top of its list. */
|
||||
async refreshEventCount(accountId: string, guildId: string): Promise<void> {
|
||||
try {
|
||||
const { count } = await window.moho.rpc<{ count: number }>('discordEventCount', { accountId, guildId })
|
||||
this.set({ discordEventCounts: { ...this.state.discordEventCounts, [`${accountId}|${guildId}`]: count } })
|
||||
} catch {
|
||||
/* an older daemon has no events; no row is the right answer */
|
||||
}
|
||||
}
|
||||
|
||||
openEvents(accountId: string, guildId: string): void {
|
||||
this.set({ eventsPane: { accountId, guildId } })
|
||||
}
|
||||
|
||||
closeEvents(): void {
|
||||
this.set({ eventsPane: null })
|
||||
}
|
||||
|
||||
/** The create panel, over the events pane if that is open. */
|
||||
openEventCreate(accountId: string, guildId: string): void {
|
||||
this.set({ eventCreate: { accountId, guildId } })
|
||||
}
|
||||
|
||||
closeEventCreate(): void {
|
||||
this.set({ eventCreate: null })
|
||||
}
|
||||
|
||||
/** Echo cancellation and noise suppression on a Discord call's microphone. */
|
||||
async setVoiceProcessing(change: { echoCancellation?: boolean; noiseSuppression?: boolean }): Promise<void> {
|
||||
try {
|
||||
@@ -2879,6 +2918,13 @@ export class ChatStore {
|
||||
// Somebody joined or left a room's call. Both the offer of a call to
|
||||
// join and, while in one, the signal to meet whoever just arrived.
|
||||
// A bot answering a slash command with a form to fill in.
|
||||
// A guild's calendar changed: the count for its events row.
|
||||
case 'discordEvents': {
|
||||
const d = data as unknown as { accountId: string; guildId: string; count: number }
|
||||
this.set({ discordEventCounts: { ...this.state.discordEventCounts, [`${d.accountId}|${d.guildId}`]: d.count } })
|
||||
break
|
||||
}
|
||||
|
||||
case 'discordModal': {
|
||||
this.set({ discordModal: data as unknown as DiscordModal })
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user