11 changed files with 11 additions and 714 deletions
@ -0,0 +1,7 @@ |
|||||||
|
{ |
||||||
|
"$schema": "https://unpkg.com/knip@5/schema.json", |
||||||
|
"entry": ["electron/preload.cjs!", "nip66-cron/index.mjs!"], |
||||||
|
"ignore": ["src/global-polyfill-types.d.ts", "src/types/**/*.d.ts"], |
||||||
|
"ignoreBinaries": ["tsx", "electron", "electron-builder"], |
||||||
|
"ignoreDependencies": ["ws"] |
||||||
|
} |
||||||
@ -1,383 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
Dialog, |
|
||||||
DialogContent, |
|
||||||
DialogDescription, |
|
||||||
DialogFooter, |
|
||||||
DialogHeader, |
|
||||||
DialogTitle |
|
||||||
} from '@/components/ui/dialog' |
|
||||||
import { DateTimePicker } from '@/components/ui/DateTimePicker' |
|
||||||
import { Input } from '@/components/ui/input' |
|
||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' |
|
||||||
import { Textarea } from '@/components/ui/textarea' |
|
||||||
import { |
|
||||||
createInPersonCalendarEventDraftEvent, |
|
||||||
createInPersonDateBasedCalendarEventDraftEvent, |
|
||||||
createPublicMessageDraftEvent |
|
||||||
} from '@/lib/draft-event' |
|
||||||
import { getNoteBech32Id } from '@/lib/event' |
|
||||||
import { randomString } from '@/lib/random' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { MapPin } from 'lucide-react' |
|
||||||
import { useMemo, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
import { CalendarEventPreview } from './CalendarEventPreview' |
|
||||||
|
|
||||||
function parseTopicTags(value: string): string[] { |
|
||||||
return [ |
|
||||||
...new Set( |
|
||||||
value |
|
||||||
.trim() |
|
||||||
.split(/[\s,]+/) |
|
||||||
.map((s) => s.replace(/^#+/, '').trim()) |
|
||||||
.filter(Boolean) |
|
||||||
) |
|
||||||
] |
|
||||||
} |
|
||||||
|
|
||||||
export function ScheduleInPersonMeetingSingleDialog({ |
|
||||||
inviteePubkey, |
|
||||||
open, |
|
||||||
onOpenChange |
|
||||||
}: { |
|
||||||
inviteePubkey: string |
|
||||||
open: boolean |
|
||||||
onOpenChange: (open: boolean) => void |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { publish } = useNostr() |
|
||||||
|
|
||||||
const [eventType, setEventType] = useState<'time' | 'date'>('time') |
|
||||||
const [title, setTitle] = useState('') |
|
||||||
const [startDatetime, setStartDatetime] = useState('') |
|
||||||
const [endDatetime, setEndDatetime] = useState('') |
|
||||||
const [startDateStr, setStartDateStr] = useState('') |
|
||||||
const [endDateStr, setEndDateStr] = useState('') |
|
||||||
const [location, setLocation] = useState('') |
|
||||||
const [summary, setSummary] = useState('') |
|
||||||
const [topics, setTopics] = useState('') |
|
||||||
const [image, setImage] = useState('') |
|
||||||
const [submitting, setSubmitting] = useState(false) |
|
||||||
|
|
||||||
const formValid = useMemo(() => { |
|
||||||
if (eventType === 'date') { |
|
||||||
if (!startDateStr.trim()) return false |
|
||||||
if (endDateStr.trim() && endDateStr <= startDateStr) return false |
|
||||||
return true |
|
||||||
} |
|
||||||
if (!startDatetime.trim()) return false |
|
||||||
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000) |
|
||||||
const endUnix = endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined |
|
||||||
if (endUnix != null && endUnix <= startUnix) return false |
|
||||||
return true |
|
||||||
}, [eventType, startDatetime, endDatetime, startDateStr, endDateStr]) |
|
||||||
|
|
||||||
const previewDraft = useMemo(() => { |
|
||||||
if (!formValid) return null |
|
||||||
const d = 'preview' |
|
||||||
if (eventType === 'date') { |
|
||||||
if (!startDateStr.trim()) return null |
|
||||||
if (endDateStr.trim() && endDateStr <= startDateStr) return null |
|
||||||
return createInPersonDateBasedCalendarEventDraftEvent({ |
|
||||||
d, |
|
||||||
title: title.trim() || t('In-person meeting'), |
|
||||||
start: startDateStr, |
|
||||||
end: endDateStr.trim() || undefined, |
|
||||||
location: location.trim() || undefined, |
|
||||||
summary: summary.trim() || undefined, |
|
||||||
image: image.trim() || undefined, |
|
||||||
topics: parseTopicTags(topics), |
|
||||||
participants: [inviteePubkey] |
|
||||||
}) |
|
||||||
} |
|
||||||
if (!startDatetime.trim()) return null |
|
||||||
const startDate = new Date(startDatetime) |
|
||||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
|
||||||
const endUnix = endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined |
|
||||||
if (endUnix != null && endUnix <= startUnix) return null |
|
||||||
return createInPersonCalendarEventDraftEvent({ |
|
||||||
d, |
|
||||||
title: title.trim() || t('In-person meeting'), |
|
||||||
start: startUnix, |
|
||||||
end: endUnix, |
|
||||||
location: location.trim() || undefined, |
|
||||||
summary: summary.trim() || undefined, |
|
||||||
image: image.trim() || undefined, |
|
||||||
topics: parseTopicTags(topics), |
|
||||||
participants: [inviteePubkey] |
|
||||||
}) |
|
||||||
}, [ |
|
||||||
eventType, |
|
||||||
title, |
|
||||||
startDatetime, |
|
||||||
endDatetime, |
|
||||||
startDateStr, |
|
||||||
endDateStr, |
|
||||||
location, |
|
||||||
summary, |
|
||||||
topics, |
|
||||||
image, |
|
||||||
inviteePubkey, |
|
||||||
t, |
|
||||||
formValid |
|
||||||
]) |
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => { |
|
||||||
e.preventDefault() |
|
||||||
if (!formValid) return |
|
||||||
if (eventType === 'date') { |
|
||||||
if (!startDateStr.trim()) { |
|
||||||
toast.error(t('Please set a start date')) |
|
||||||
return |
|
||||||
} |
|
||||||
if (endDateStr.trim() && endDateStr <= startDateStr) { |
|
||||||
toast.error(t('End date must be after start date')) |
|
||||||
return |
|
||||||
} |
|
||||||
} else { |
|
||||||
if (!startDatetime.trim()) { |
|
||||||
toast.error(t('Please set a start time')) |
|
||||||
return |
|
||||||
} |
|
||||||
const startDate = new Date(startDatetime) |
|
||||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
|
||||||
const endUnix = endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined |
|
||||||
if (endUnix != null && endUnix <= startUnix) { |
|
||||||
toast.error(t('End time must be after start time')) |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
setSubmitting(true) |
|
||||||
try { |
|
||||||
const d = `jumble-inperson-${randomString(12)}` |
|
||||||
const calendarDraft = |
|
||||||
eventType === 'date' |
|
||||||
? createInPersonDateBasedCalendarEventDraftEvent({ |
|
||||||
d, |
|
||||||
title: title.trim() || t('In-person meeting'), |
|
||||||
start: startDateStr, |
|
||||||
end: endDateStr.trim() || undefined, |
|
||||||
location: location.trim() || undefined, |
|
||||||
summary: summary.trim() || undefined, |
|
||||||
image: image.trim() || undefined, |
|
||||||
topics: parseTopicTags(topics), |
|
||||||
participants: [inviteePubkey] |
|
||||||
}) |
|
||||||
: createInPersonCalendarEventDraftEvent({ |
|
||||||
d, |
|
||||||
title: title.trim() || t('In-person meeting'), |
|
||||||
start: Math.floor(new Date(startDatetime).getTime() / 1000), |
|
||||||
end: endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined, |
|
||||||
location: location.trim() || undefined, |
|
||||||
summary: summary.trim() || undefined, |
|
||||||
image: image.trim() || undefined, |
|
||||||
topics: parseTopicTags(topics), |
|
||||||
participants: [inviteePubkey] |
|
||||||
}) |
|
||||||
|
|
||||||
const calendarEvent = await publish(calendarDraft) |
|
||||||
const naddr = getNoteBech32Id(calendarEvent) |
|
||||||
const messageContent = `${t("You're invited to an in-person meeting.")} nostr:${naddr}` |
|
||||||
|
|
||||||
const pmDraft = await createPublicMessageDraftEvent( |
|
||||||
messageContent, |
|
||||||
[inviteePubkey], |
|
||||||
{ addClientTag: true } |
|
||||||
) |
|
||||||
await publish(pmDraft) |
|
||||||
|
|
||||||
toast.success(t('Meeting created and invite sent')) |
|
||||||
onOpenChange(false) |
|
||||||
setEventType('time') |
|
||||||
setTitle('') |
|
||||||
setStartDatetime('') |
|
||||||
setEndDatetime('') |
|
||||||
setStartDateStr('') |
|
||||||
setEndDateStr('') |
|
||||||
setLocation('') |
|
||||||
setSummary('') |
|
||||||
setTopics('') |
|
||||||
setImage('') |
|
||||||
} catch (err) { |
|
||||||
toast.error(err instanceof Error ? err.message : t('Failed to create meeting')) |
|
||||||
} finally { |
|
||||||
setSubmitting(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||||
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-6"> |
|
||||||
<DialogHeader className="shrink-0"> |
|
||||||
<DialogTitle className="flex items-center gap-2"> |
|
||||||
<MapPin className="size-5" /> |
|
||||||
{t('Schedule in-person meeting')} |
|
||||||
</DialogTitle> |
|
||||||
<DialogDescription> |
|
||||||
{t('Required: start time or start date. Optional: title, end, location, summary, topics, image.')} |
|
||||||
</DialogDescription> |
|
||||||
</DialogHeader> |
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1"> |
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pr-1"> |
|
||||||
<div> |
|
||||||
<Label>{t('Event type')}</Label> |
|
||||||
<RadioGroup |
|
||||||
value={eventType} |
|
||||||
onValueChange={(v) => setEventType(v as 'time' | 'date')} |
|
||||||
className="mt-2 flex gap-4" |
|
||||||
> |
|
||||||
<label className="flex items-center gap-2 cursor-pointer"> |
|
||||||
<RadioGroupItem value="time" id="inperson-type-time" /> |
|
||||||
<span className="text-sm">{t('Time-based')}</span> |
|
||||||
</label> |
|
||||||
<label className="flex items-center gap-2 cursor-pointer"> |
|
||||||
<RadioGroupItem value="date" id="inperson-type-date" /> |
|
||||||
<span className="text-sm">{t('Date-based (all-day)')}</span> |
|
||||||
</label> |
|
||||||
</RadioGroup> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-title"> |
|
||||||
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="inperson-title" |
|
||||||
value={title} |
|
||||||
onChange={(e) => setTitle(e.target.value)} |
|
||||||
placeholder={t('In-person meeting')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{eventType === 'date' ? ( |
|
||||||
<> |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-start-date">{t('Start date')} *</Label> |
|
||||||
<Input |
|
||||||
id="inperson-start-date" |
|
||||||
type="date" |
|
||||||
value={startDateStr} |
|
||||||
onChange={(e) => setStartDateStr(e.target.value)} |
|
||||||
className="mt-1" |
|
||||||
required={eventType === 'date'} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-end-date"> |
|
||||||
{t('End date')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="inperson-end-date" |
|
||||||
type="date" |
|
||||||
value={endDateStr} |
|
||||||
onChange={(e) => setEndDateStr(e.target.value)} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</> |
|
||||||
) : ( |
|
||||||
<> |
|
||||||
<DateTimePicker |
|
||||||
id="inperson-start" |
|
||||||
value={startDatetime} |
|
||||||
onChange={setStartDatetime} |
|
||||||
label={t('Start')} |
|
||||||
labelSuffix="*" |
|
||||||
required={eventType === 'time'} |
|
||||||
/> |
|
||||||
<DateTimePicker |
|
||||||
id="inperson-end" |
|
||||||
value={endDatetime} |
|
||||||
onChange={setEndDatetime} |
|
||||||
label={t('End')} |
|
||||||
labelSuffix={ |
|
||||||
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
} |
|
||||||
/> |
|
||||||
</> |
|
||||||
)} |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-location"> |
|
||||||
{t('Location')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="inperson-location" |
|
||||||
value={location} |
|
||||||
onChange={(e) => setLocation(e.target.value)} |
|
||||||
placeholder={t('Address, venue, or place')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-summary"> |
|
||||||
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Textarea |
|
||||||
id="inperson-summary" |
|
||||||
value={summary} |
|
||||||
onChange={(e) => setSummary(e.target.value)} |
|
||||||
placeholder={t('Brief description of the event')} |
|
||||||
className="mt-1 min-h-[60px]" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-topics"> |
|
||||||
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="inperson-topics" |
|
||||||
value={topics} |
|
||||||
onChange={(e) => setTopics(e.target.value)} |
|
||||||
placeholder={t('e.g. meetup, conference')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="inperson-image"> |
|
||||||
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="inperson-image" |
|
||||||
type="url" |
|
||||||
value={image} |
|
||||||
onChange={(e) => setImage(e.target.value)} |
|
||||||
placeholder={t('Optional image for the event')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{formValid && previewDraft && ( |
|
||||||
<div className="min-h-0 shrink-0"> |
|
||||||
<Label className="mb-1 block">{t('Preview')}</Label> |
|
||||||
<CalendarEventPreview draft={previewDraft} /> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
<DialogFooter className="shrink-0 pt-2 border-t mt-2"> |
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="outline" |
|
||||||
onClick={() => onOpenChange(false)} |
|
||||||
disabled={submitting} |
|
||||||
> |
|
||||||
{t('Cancel')} |
|
||||||
</Button> |
|
||||||
<Button type="submit" disabled={submitting || !formValid}> |
|
||||||
{submitting ? t('Creating…') : t('Create and send invite')} |
|
||||||
</Button> |
|
||||||
</DialogFooter> |
|
||||||
</form> |
|
||||||
</DialogContent> |
|
||||||
</Dialog> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,294 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
Dialog, |
|
||||||
DialogContent, |
|
||||||
DialogDescription, |
|
||||||
DialogFooter, |
|
||||||
DialogHeader, |
|
||||||
DialogTitle |
|
||||||
} from '@/components/ui/dialog' |
|
||||||
import { DateTimePicker } from '@/components/ui/DateTimePicker' |
|
||||||
import { Input } from '@/components/ui/input' |
|
||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { Textarea } from '@/components/ui/textarea' |
|
||||||
import { |
|
||||||
createCalendarEventDraftEvent, |
|
||||||
createPublicMessageDraftEvent |
|
||||||
} from '@/lib/draft-event' |
|
||||||
import { getNoteBech32Id } from '@/lib/event' |
|
||||||
import { buildHiveTalkJoinUrl, roomIdForScheduledCall } from '@/lib/hivetalk' |
|
||||||
import { randomString } from '@/lib/random' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { Calendar } from 'lucide-react' |
|
||||||
import { useMemo, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
import { CalendarEventPreview } from './CalendarEventPreview' |
|
||||||
|
|
||||||
function parseTopicTags(value: string): string[] { |
|
||||||
return [ |
|
||||||
...new Set( |
|
||||||
value |
|
||||||
.trim() |
|
||||||
.split(/[\s,]+/) |
|
||||||
.map((s) => s.replace(/^#+/, '').trim()) |
|
||||||
.filter(Boolean) |
|
||||||
) |
|
||||||
] |
|
||||||
} |
|
||||||
|
|
||||||
export function ScheduleVideoCallSingleDialog({ |
|
||||||
inviteePubkey, |
|
||||||
open, |
|
||||||
onOpenChange |
|
||||||
}: { |
|
||||||
inviteePubkey: string |
|
||||||
open: boolean |
|
||||||
onOpenChange: (open: boolean) => void |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { publish } = useNostr() |
|
||||||
const joinAsName = 'Guest' |
|
||||||
|
|
||||||
const [title, setTitle] = useState('') |
|
||||||
const [startDatetime, setStartDatetime] = useState('') |
|
||||||
const [endDatetime, setEndDatetime] = useState('') |
|
||||||
const [locationUrl, setLocationUrl] = useState('') |
|
||||||
const [summary, setSummary] = useState('') |
|
||||||
const [topics, setTopics] = useState('') |
|
||||||
const [image, setImage] = useState('') |
|
||||||
const [submitting, setSubmitting] = useState(false) |
|
||||||
|
|
||||||
const formValid = useMemo(() => { |
|
||||||
if (!startDatetime.trim()) return false |
|
||||||
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000) |
|
||||||
const endUnix = endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined |
|
||||||
if (endUnix != null && endUnix <= startUnix) return false |
|
||||||
return true |
|
||||||
}, [startDatetime, endDatetime]) |
|
||||||
|
|
||||||
const previewDraft = useMemo(() => { |
|
||||||
if (!formValid) return null |
|
||||||
if (!startDatetime.trim()) return null |
|
||||||
const startDate = new Date(startDatetime) |
|
||||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
|
||||||
const endUnix = endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined |
|
||||||
if (endUnix != null && endUnix <= startUnix) return null |
|
||||||
const d = 'preview' |
|
||||||
const roomId = roomIdForScheduledCall(d) |
|
||||||
const defaultJoinUrl = buildHiveTalkJoinUrl({ room: roomId, name: joinAsName }) |
|
||||||
const joinUrl = locationUrl.trim() || defaultJoinUrl |
|
||||||
return createCalendarEventDraftEvent({ |
|
||||||
d, |
|
||||||
title: title.trim() || t('Video call'), |
|
||||||
start: startUnix, |
|
||||||
end: endUnix, |
|
||||||
locationUrl: joinUrl, |
|
||||||
summary: summary.trim() || undefined, |
|
||||||
image: image.trim() || undefined, |
|
||||||
topics: parseTopicTags(topics), |
|
||||||
participants: [inviteePubkey] |
|
||||||
}) |
|
||||||
}, [ |
|
||||||
title, |
|
||||||
startDatetime, |
|
||||||
endDatetime, |
|
||||||
locationUrl, |
|
||||||
summary, |
|
||||||
topics, |
|
||||||
image, |
|
||||||
inviteePubkey, |
|
||||||
joinAsName, |
|
||||||
t, |
|
||||||
formValid |
|
||||||
]) |
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => { |
|
||||||
e.preventDefault() |
|
||||||
if (!formValid) return |
|
||||||
if (!startDatetime.trim()) { |
|
||||||
toast.error(t('Please set a start time')) |
|
||||||
return |
|
||||||
} |
|
||||||
setSubmitting(true) |
|
||||||
try { |
|
||||||
const startDate = new Date(startDatetime) |
|
||||||
const startUnix = Math.floor(startDate.getTime() / 1000) |
|
||||||
const endUnix = endDatetime.trim() |
|
||||||
? Math.floor(new Date(endDatetime).getTime() / 1000) |
|
||||||
: undefined |
|
||||||
if (endUnix != null && endUnix <= startUnix) { |
|
||||||
toast.error(t('End time must be after start time')) |
|
||||||
setSubmitting(false) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const d = `jumble-cal-${randomString(12)}` |
|
||||||
const roomId = roomIdForScheduledCall(d) |
|
||||||
const defaultJoinUrl = buildHiveTalkJoinUrl({ |
|
||||||
room: roomId, |
|
||||||
name: joinAsName |
|
||||||
}) |
|
||||||
const joinUrl = locationUrl.trim() || defaultJoinUrl |
|
||||||
|
|
||||||
const calendarDraft = createCalendarEventDraftEvent({ |
|
||||||
d, |
|
||||||
title: title.trim() || t('Video call'), |
|
||||||
start: startUnix, |
|
||||||
end: endUnix, |
|
||||||
locationUrl: joinUrl, |
|
||||||
summary: summary.trim() || undefined, |
|
||||||
image: image.trim() || undefined, |
|
||||||
topics: parseTopicTags(topics), |
|
||||||
participants: [inviteePubkey] |
|
||||||
}) |
|
||||||
|
|
||||||
const calendarEvent = await publish(calendarDraft) |
|
||||||
const naddr = getNoteBech32Id(calendarEvent) |
|
||||||
const messageContent = `${t("You're invited to a scheduled video call.")} nostr:${naddr}` |
|
||||||
|
|
||||||
const pmDraft = await createPublicMessageDraftEvent( |
|
||||||
messageContent, |
|
||||||
[inviteePubkey], |
|
||||||
{ addClientTag: true } |
|
||||||
) |
|
||||||
await publish(pmDraft) |
|
||||||
|
|
||||||
toast.success(t('Scheduled call created and invite sent')) |
|
||||||
onOpenChange(false) |
|
||||||
setTitle('') |
|
||||||
setStartDatetime('') |
|
||||||
setEndDatetime('') |
|
||||||
setLocationUrl('') |
|
||||||
setSummary('') |
|
||||||
setTopics('') |
|
||||||
setImage('') |
|
||||||
} catch (err) { |
|
||||||
toast.error(err instanceof Error ? err.message : t('Failed to schedule call')) |
|
||||||
} finally { |
|
||||||
setSubmitting(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||||
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-6"> |
|
||||||
<DialogHeader className="shrink-0"> |
|
||||||
<DialogTitle className="flex items-center gap-2"> |
|
||||||
<Calendar className="size-5" /> |
|
||||||
{t('Schedule video call')} |
|
||||||
</DialogTitle> |
|
||||||
<DialogDescription> |
|
||||||
{t('Required: start time. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')} |
|
||||||
</DialogDescription> |
|
||||||
</DialogHeader> |
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1"> |
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pr-1"> |
|
||||||
<div> |
|
||||||
<Label htmlFor="schedule-call-title"> |
|
||||||
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="schedule-call-title" |
|
||||||
value={title} |
|
||||||
onChange={(e) => setTitle(e.target.value)} |
|
||||||
placeholder={t('Video call')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<DateTimePicker |
|
||||||
id="schedule-call-start" |
|
||||||
value={startDatetime} |
|
||||||
onChange={setStartDatetime} |
|
||||||
label={t('Start')} |
|
||||||
labelSuffix="*" |
|
||||||
required |
|
||||||
/> |
|
||||||
<DateTimePicker |
|
||||||
id="schedule-call-end" |
|
||||||
value={endDatetime} |
|
||||||
onChange={setEndDatetime} |
|
||||||
label={t('End')} |
|
||||||
labelSuffix={ |
|
||||||
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
} |
|
||||||
/> |
|
||||||
<div> |
|
||||||
<Label htmlFor="schedule-call-location"> |
|
||||||
{t('Join link')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="schedule-call-location" |
|
||||||
type="url" |
|
||||||
value={locationUrl} |
|
||||||
onChange={(e) => setLocationUrl(e.target.value)} |
|
||||||
placeholder={t('Leave empty for HiveTalk, or paste Zoom / Teams / other link')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="schedule-call-summary"> |
|
||||||
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Textarea |
|
||||||
id="schedule-call-summary" |
|
||||||
value={summary} |
|
||||||
onChange={(e) => setSummary(e.target.value)} |
|
||||||
placeholder={t('Brief description of the event')} |
|
||||||
className="mt-1 min-h-[60px]" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="schedule-call-topics"> |
|
||||||
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="schedule-call-topics" |
|
||||||
value={topics} |
|
||||||
onChange={(e) => setTopics(e.target.value)} |
|
||||||
placeholder={t('e.g. meetup, conference')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<Label htmlFor="schedule-call-image"> |
|
||||||
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
|
||||||
</Label> |
|
||||||
<Input |
|
||||||
id="schedule-call-image" |
|
||||||
type="url" |
|
||||||
value={image} |
|
||||||
onChange={(e) => setImage(e.target.value)} |
|
||||||
placeholder={t('Optional image for the event')} |
|
||||||
className="mt-1" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{formValid && previewDraft && ( |
|
||||||
<div className="min-h-0 shrink-0"> |
|
||||||
<Label className="mb-1 block">{t('Preview')}</Label> |
|
||||||
<CalendarEventPreview draft={previewDraft} /> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
<DialogFooter className="shrink-0 pt-2 border-t mt-2"> |
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="outline" |
|
||||||
onClick={() => onOpenChange(false)} |
|
||||||
disabled={submitting} |
|
||||||
> |
|
||||||
{t('Cancel')} |
|
||||||
</Button> |
|
||||||
<Button type="submit" disabled={submitting || !formValid}> |
|
||||||
{submitting ? t('Scheduling…') : t('Schedule and send invite')} |
|
||||||
</Button> |
|
||||||
</DialogFooter> |
|
||||||
</form> |
|
||||||
</DialogContent> |
|
||||||
</Dialog> |
|
||||||
) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue