29 changed files with 2687 additions and 22 deletions
@ -0,0 +1,164 @@ |
|||||||
|
import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' |
||||||
|
import { |
||||||
|
getCalendarEventMeta, |
||||||
|
formatCalendarTime, |
||||||
|
formatCalendarDate, |
||||||
|
isCalendarEventKind |
||||||
|
} from '@/lib/calendar-event' |
||||||
|
import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { Button } from '../ui/button' |
||||||
|
import { Calendar, Video, CheckCircle, HelpCircle, XCircle } from 'lucide-react' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { |
||||||
|
DropdownMenu, |
||||||
|
DropdownMenuContent, |
||||||
|
DropdownMenuItem, |
||||||
|
DropdownMenuTrigger |
||||||
|
} from '../ui/dropdown-menu' |
||||||
|
import { toast } from 'sonner' |
||||||
|
|
||||||
|
type RsvpStatus = 'accepted' | 'tentative' | 'declined' |
||||||
|
|
||||||
|
export default function CalendarEventContent({ |
||||||
|
event, |
||||||
|
className, |
||||||
|
showRsvp = true |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
showRsvp?: boolean |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey: myPubkey, publish } = useNostr() |
||||||
|
const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event) |
||||||
|
|
||||||
|
if (!isCalendarEventKind(event.kind)) return null |
||||||
|
|
||||||
|
const { title, summary, image, start, end, startDate, endDate, isDateBased, joinUrl, topics } = |
||||||
|
getCalendarEventMeta(event) |
||||||
|
const description = summary || event.content?.trim() || '' |
||||||
|
const myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined |
||||||
|
const myStatus = myRsvp ? getStatus(myRsvp) : undefined |
||||||
|
|
||||||
|
const handleRsvp = async (status: RsvpStatus) => { |
||||||
|
if (!myPubkey) { |
||||||
|
toast.error(t('You need to log in to RSVP')) |
||||||
|
return |
||||||
|
} |
||||||
|
try { |
||||||
|
const draft = createCalendarRsvpDraftEvent(event, status) |
||||||
|
await publish(draft) |
||||||
|
toast.success(t('RSVP updated')) |
||||||
|
} catch (err) { |
||||||
|
toast.error(err instanceof Error ? err.message : t('Failed to update RSVP')) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn('rounded-lg border bg-muted/40 p-3 text-sm min-w-0', className)} |
||||||
|
data-calendar-event-content |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
<div className="flex items-start gap-2 mb-2"> |
||||||
|
{image ? ( |
||||||
|
<img |
||||||
|
src={image} |
||||||
|
alt="" |
||||||
|
className="size-12 shrink-0 rounded object-cover" |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" /> |
||||||
|
)} |
||||||
|
<div className="min-w-0 flex-1"> |
||||||
|
<span className="font-medium text-foreground truncate block"> |
||||||
|
{title || t('Scheduled video call')} |
||||||
|
</span> |
||||||
|
{topics.length > 0 && ( |
||||||
|
<div className="flex flex-wrap gap-1 mt-1"> |
||||||
|
{topics.map((topic) => ( |
||||||
|
<span |
||||||
|
key={topic} |
||||||
|
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" |
||||||
|
> |
||||||
|
#{topic} |
||||||
|
</span> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{isDateBased ? ( |
||||||
|
(startDate || endDate) && ( |
||||||
|
<div className="text-muted-foreground text-xs mb-2"> |
||||||
|
{startDate ? formatCalendarDate(startDate) : ''} |
||||||
|
{endDate && endDate !== startDate && ( |
||||||
|
<> – {formatCalendarDate(endDate)}</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
) : ( |
||||||
|
start != null && |
||||||
|
!isNaN(start) && ( |
||||||
|
<div className="text-muted-foreground text-xs mb-2"> |
||||||
|
{formatCalendarTime(start)} |
||||||
|
{end != null && !isNaN(end) && end > start && ( |
||||||
|
<> – {formatCalendarTime(end)}</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
)} |
||||||
|
{description && ( |
||||||
|
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words"> |
||||||
|
{description} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-2"> |
||||||
|
{joinUrl && ( |
||||||
|
<Button variant="secondary" size="sm" className="gap-2" asChild> |
||||||
|
<a href={joinUrl} target="_blank" rel="noopener noreferrer"> |
||||||
|
<Video className="size-4" /> |
||||||
|
{t('Join video call')} |
||||||
|
</a> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
{showRsvp && myPubkey && ( |
||||||
|
<DropdownMenu> |
||||||
|
<DropdownMenuTrigger asChild> |
||||||
|
<Button |
||||||
|
variant="outline" |
||||||
|
size="sm" |
||||||
|
className="gap-2" |
||||||
|
disabled={isFetching} |
||||||
|
> |
||||||
|
{myStatus === 'accepted' && <CheckCircle className="size-4 text-green-600" />} |
||||||
|
{myStatus === 'tentative' && <HelpCircle className="size-4 text-amber-600" />} |
||||||
|
{myStatus === 'declined' && <XCircle className="size-4 text-muted-foreground" />} |
||||||
|
{myStatus |
||||||
|
? t('RSVP: {{status}}', { status: myStatus }) |
||||||
|
: t('RSVP')} |
||||||
|
</Button> |
||||||
|
</DropdownMenuTrigger> |
||||||
|
<DropdownMenuContent align="start"> |
||||||
|
<DropdownMenuItem onClick={() => handleRsvp('accepted')}> |
||||||
|
<CheckCircle className="size-4 mr-2 text-green-600" /> |
||||||
|
{t('Accepted')} |
||||||
|
</DropdownMenuItem> |
||||||
|
<DropdownMenuItem onClick={() => handleRsvp('tentative')}> |
||||||
|
<HelpCircle className="size-4 mr-2 text-amber-600" /> |
||||||
|
{t('Tentative')} |
||||||
|
</DropdownMenuItem> |
||||||
|
<DropdownMenuItem onClick={() => handleRsvp('declined')}> |
||||||
|
<XCircle className="size-4 mr-2" /> |
||||||
|
{t('Declined')} |
||||||
|
</DropdownMenuItem> |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,104 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
getCalendarEventMeta, |
||||||
|
formatCalendarTime, |
||||||
|
formatCalendarDate, |
||||||
|
isCalendarEventKind |
||||||
|
} from '@/lib/calendar-event' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { Button } from '../ui/button' |
||||||
|
import { Calendar, Video } from 'lucide-react' |
||||||
|
|
||||||
|
export function EmbeddedCalendarEvent({ |
||||||
|
event, |
||||||
|
className |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
if (!isCalendarEventKind(event.kind)) return null |
||||||
|
const { title, summary, image, start, end, startDate, endDate, isDateBased, joinUrl, topics } = |
||||||
|
getCalendarEventMeta(event) |
||||||
|
const description = summary || event.content?.trim() || '' |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'rounded-lg border bg-muted/40 p-3 text-sm min-w-0', |
||||||
|
className |
||||||
|
)} |
||||||
|
data-embedded-calendar-event |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
<div className="flex items-start gap-2 mb-2"> |
||||||
|
{image ? ( |
||||||
|
<img |
||||||
|
src={image} |
||||||
|
alt="" |
||||||
|
className="size-12 shrink-0 rounded object-cover" |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" /> |
||||||
|
)} |
||||||
|
<div className="min-w-0 flex-1"> |
||||||
|
<span className="font-medium text-foreground truncate block"> |
||||||
|
{title || t('Scheduled video call')} |
||||||
|
</span> |
||||||
|
{topics.length > 0 && ( |
||||||
|
<div className="flex flex-wrap gap-1 mt-1"> |
||||||
|
{topics.map((topic) => ( |
||||||
|
<span |
||||||
|
key={topic} |
||||||
|
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" |
||||||
|
> |
||||||
|
#{topic} |
||||||
|
</span> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{isDateBased ? ( |
||||||
|
(startDate || endDate) && ( |
||||||
|
<div className="text-muted-foreground text-xs mb-2"> |
||||||
|
{startDate ? formatCalendarDate(startDate) : ''} |
||||||
|
{endDate && endDate !== startDate && ( |
||||||
|
<> – {formatCalendarDate(endDate)}</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
) : ( |
||||||
|
start != null && |
||||||
|
!isNaN(start) && ( |
||||||
|
<div className="text-muted-foreground text-xs mb-2"> |
||||||
|
{formatCalendarTime(start)} |
||||||
|
{end != null && !isNaN(end) && end > start && ( |
||||||
|
<> – {formatCalendarTime(end)}</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
)} |
||||||
|
{description && ( |
||||||
|
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words"> |
||||||
|
{description} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
{joinUrl && ( |
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
size="sm" |
||||||
|
className="w-full gap-2 mt-1" |
||||||
|
asChild |
||||||
|
> |
||||||
|
<a href={joinUrl} target="_blank" rel="noopener noreferrer"> |
||||||
|
<Video className="size-4" /> |
||||||
|
{t('Join video call')} |
||||||
|
</a> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,135 @@ |
|||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { useSearchProfiles } from '@/hooks' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { X } from 'lucide-react' |
||||||
|
import { useCallback, useEffect, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { SimpleUserAvatar } from '../UserAvatar' |
||||||
|
import { SimpleUsername } from '../Username' |
||||||
|
import Nip05 from '../Nip05' |
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300 |
||||||
|
const SEARCH_LIMIT = 10 |
||||||
|
|
||||||
|
export function InviteePicker({ |
||||||
|
value, |
||||||
|
onChange, |
||||||
|
placeholder, |
||||||
|
className, |
||||||
|
labelId, |
||||||
|
max |
||||||
|
}: { |
||||||
|
value: string[] |
||||||
|
onChange: (pubkeys: string[]) => void |
||||||
|
placeholder?: string |
||||||
|
className?: string |
||||||
|
labelId?: string |
||||||
|
/** Max number of invitees (e.g. MAX_CALENDAR_INVITEES). When reached, adding is disabled. */ |
||||||
|
max?: number |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey: myPubkey } = useNostr() |
||||||
|
const [search, setSearch] = useState('') |
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('') |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const id = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS) |
||||||
|
return () => clearTimeout(id) |
||||||
|
}, [search]) |
||||||
|
|
||||||
|
const { profiles, isFetching } = useSearchProfiles(debouncedSearch, SEARCH_LIMIT) |
||||||
|
const selectedSet = new Set(value) |
||||||
|
const atLimit = max != null && value.length >= max |
||||||
|
const filteredProfiles = profiles.filter((p) => !selectedSet.has(p.pubkey) && p.pubkey !== myPubkey) |
||||||
|
|
||||||
|
const addInvitee = useCallback( |
||||||
|
(pubkey: string) => { |
||||||
|
if (pubkey === myPubkey || selectedSet.has(pubkey)) return |
||||||
|
if (max != null && value.length >= max) return |
||||||
|
onChange([...value, pubkey]) |
||||||
|
setSearch('') |
||||||
|
}, |
||||||
|
[value, onChange, myPubkey, selectedSet, max] |
||||||
|
) |
||||||
|
|
||||||
|
const removeInvitee = useCallback( |
||||||
|
(pubkey: string) => { |
||||||
|
onChange(value.filter((p) => p !== pubkey)) |
||||||
|
}, |
||||||
|
[value, onChange] |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('space-y-2', className)}> |
||||||
|
{value.length > 0 && ( |
||||||
|
<div className="flex flex-wrap gap-1.5"> |
||||||
|
{value.map((pubkey) => ( |
||||||
|
<span |
||||||
|
key={pubkey} |
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm" |
||||||
|
> |
||||||
|
<SimpleUserAvatar userId={pubkey} className="size-5 shrink-0" /> |
||||||
|
<SimpleUsername userId={pubkey} className="max-w-[120px] truncate" /> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => removeInvitee(pubkey)} |
||||||
|
className="rounded-full p-0.5 hover:bg-muted-foreground/20" |
||||||
|
aria-label={t('Remove')} |
||||||
|
> |
||||||
|
<X className="size-3.5" /> |
||||||
|
</button> |
||||||
|
</span> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className="relative"> |
||||||
|
<Input |
||||||
|
id={labelId} |
||||||
|
type="text" |
||||||
|
value={search} |
||||||
|
onChange={(e) => setSearch(e.target.value)} |
||||||
|
placeholder={placeholder ?? t('Search by name or npub…')} |
||||||
|
className="mt-1" |
||||||
|
autoComplete="off" |
||||||
|
/> |
||||||
|
{search.trim() && !atLimit && ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'absolute left-0 right-0 top-full z-10 mt-1 max-h-60 overflow-auto rounded-md border bg-popover shadow-md' |
||||||
|
)} |
||||||
|
> |
||||||
|
{isFetching && filteredProfiles.length === 0 ? ( |
||||||
|
<div className="p-3 text-sm text-muted-foreground">{t('Searching…')}</div> |
||||||
|
) : filteredProfiles.length === 0 ? ( |
||||||
|
<div className="p-3 text-sm text-muted-foreground">{t('No users found')}</div> |
||||||
|
) : ( |
||||||
|
<ul className="py-1"> |
||||||
|
{filteredProfiles.map((profile) => ( |
||||||
|
<li key={profile.pubkey}> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground" |
||||||
|
onClick={() => addInvitee(profile.pubkey)} |
||||||
|
> |
||||||
|
<SimpleUserAvatar userId={profile.pubkey} className="size-8 shrink-0" /> |
||||||
|
<div className="min-w-0 flex-1"> |
||||||
|
<SimpleUsername userId={profile.pubkey} className="font-medium truncate" /> |
||||||
|
<Nip05 pubkey={profile.pubkey} /> |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{atLimit && ( |
||||||
|
<p className="text-xs text-muted-foreground"> |
||||||
|
{t('Maximum {{max}} invitees', { max: max ?? 0 })} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
import { EmbeddedCalendarEvent } from '@/components/Embedded/EmbeddedCalendarEvent' |
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
||||||
|
import { TDraftEvent } from '@/types' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts a draft (no id/pubkey/sig) into an event-like object for preview rendering. |
||||||
|
*/ |
||||||
|
function draftToPreviewEvent(draft: TDraftEvent): Event { |
||||||
|
return { |
||||||
|
id: '', |
||||||
|
pubkey: '', |
||||||
|
sig: '', |
||||||
|
kind: draft.kind, |
||||||
|
created_at: draft.created_at, |
||||||
|
tags: draft.tags, |
||||||
|
content: draft.content |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function CalendarEventPreview({ |
||||||
|
draft, |
||||||
|
className |
||||||
|
}: { |
||||||
|
draft: TDraftEvent |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const previewEvent = draftToPreviewEvent(draft) |
||||||
|
const jsonString = JSON.stringify( |
||||||
|
{ kind: draft.kind, content: draft.content, tags: draft.tags, created_at: draft.created_at }, |
||||||
|
null, |
||||||
|
2 |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('space-y-2', className)}> |
||||||
|
<Tabs defaultValue="rendered" className="w-full"> |
||||||
|
<TabsList className="grid w-full grid-cols-2"> |
||||||
|
<TabsTrigger value="rendered">{t('Rendered')}</TabsTrigger> |
||||||
|
<TabsTrigger value="json">{t('JSON')}</TabsTrigger> |
||||||
|
</TabsList> |
||||||
|
<TabsContent value="rendered" className="mt-2"> |
||||||
|
<div className="rounded-md border bg-muted/20 p-2"> |
||||||
|
<EmbeddedCalendarEvent event={previewEvent} /> |
||||||
|
</div> |
||||||
|
</TabsContent> |
||||||
|
<TabsContent value="json" className="mt-2"> |
||||||
|
<pre className="max-h-[240px] overflow-auto rounded-md border bg-muted/20 p-3 text-xs"> |
||||||
|
{jsonString} |
||||||
|
</pre> |
||||||
|
</TabsContent> |
||||||
|
</Tabs> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,407 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogDescription, |
||||||
|
DialogFooter, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { InviteePicker } from '@/components/InviteePicker' |
||||||
|
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 { MAX_CALENDAR_INVITEES } from '@/constants' |
||||||
|
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 ScheduleInPersonMeetingDialog({ |
||||||
|
open, |
||||||
|
onOpenChange |
||||||
|
}: { |
||||||
|
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 [inviteePubkeys, setInviteePubkeys] = useState<string[]>([]) |
||||||
|
const [submitting, setSubmitting] = useState(false) |
||||||
|
|
||||||
|
const formValid = useMemo(() => { |
||||||
|
if (inviteePubkeys.length === 0 || inviteePubkeys.length > MAX_CALENDAR_INVITEES) return false |
||||||
|
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, inviteePubkeys]) |
||||||
|
|
||||||
|
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: inviteePubkeys |
||||||
|
}) |
||||||
|
} |
||||||
|
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: inviteePubkeys |
||||||
|
}) |
||||||
|
}, [ |
||||||
|
eventType, |
||||||
|
title, |
||||||
|
startDatetime, |
||||||
|
endDatetime, |
||||||
|
startDateStr, |
||||||
|
endDateStr, |
||||||
|
location, |
||||||
|
summary, |
||||||
|
topics, |
||||||
|
image, |
||||||
|
inviteePubkeys, |
||||||
|
t, |
||||||
|
formValid |
||||||
|
]) |
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||||
|
e.preventDefault() |
||||||
|
if (!formValid) return |
||||||
|
if (inviteePubkeys.length === 0) { |
||||||
|
toast.error(t('Add at least one invitee')) |
||||||
|
return |
||||||
|
} |
||||||
|
if (inviteePubkeys.length > MAX_CALENDAR_INVITEES) { |
||||||
|
toast.error(t('Maximum {{max}} invitees allowed', { max: MAX_CALENDAR_INVITEES })) |
||||||
|
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: inviteePubkeys |
||||||
|
}) |
||||||
|
: 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: inviteePubkeys |
||||||
|
}) |
||||||
|
|
||||||
|
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, |
||||||
|
inviteePubkeys, |
||||||
|
{ addClientTag: true } |
||||||
|
) |
||||||
|
await publish(pmDraft) |
||||||
|
|
||||||
|
toast.success( |
||||||
|
t('Meeting created and {{count}} invite(s) sent', { |
||||||
|
count: inviteePubkeys.length |
||||||
|
}) |
||||||
|
) |
||||||
|
onOpenChange(false) |
||||||
|
setEventType('time') |
||||||
|
setTitle('') |
||||||
|
setStartDatetime('') |
||||||
|
setEndDatetime('') |
||||||
|
setStartDateStr('') |
||||||
|
setEndDateStr('') |
||||||
|
setLocation('') |
||||||
|
setSummary('') |
||||||
|
setTopics('') |
||||||
|
setImage('') |
||||||
|
setInviteePubkeys([]) |
||||||
|
} 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"> |
||||||
|
<DialogHeader> |
||||||
|
<DialogTitle className="flex items-center gap-2"> |
||||||
|
<MapPin className="size-5" /> |
||||||
|
{t('Schedule in-person meeting')} |
||||||
|
</DialogTitle> |
||||||
|
<DialogDescription> |
||||||
|
{t('Required: start (or start date), invitees. Optional: title, end, location, summary, topics, image.')} |
||||||
|
</DialogDescription> |
||||||
|
</DialogHeader> |
||||||
|
<form onSubmit={handleSubmit} className="space-y-4"> |
||||||
|
<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="own-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="own-inperson-type-date" /> |
||||||
|
<span className="text-sm">{t('Date-based (all-day)')}</span> |
||||||
|
</label> |
||||||
|
</RadioGroup> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-title"> |
||||||
|
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-inperson-title" |
||||||
|
value={title} |
||||||
|
onChange={(e) => setTitle(e.target.value)} |
||||||
|
placeholder={t('In-person meeting')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{eventType === 'date' ? ( |
||||||
|
<> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-start-date">{t('Start date')} *</Label> |
||||||
|
<Input |
||||||
|
id="own-inperson-start-date" |
||||||
|
type="date" |
||||||
|
value={startDateStr} |
||||||
|
onChange={(e) => setStartDateStr(e.target.value)} |
||||||
|
className="mt-1" |
||||||
|
required={eventType === 'date'} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-end-date"> |
||||||
|
{t('End date')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-inperson-end-date" |
||||||
|
type="date" |
||||||
|
value={endDateStr} |
||||||
|
onChange={(e) => setEndDateStr(e.target.value)} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<DateTimePicker |
||||||
|
id="own-inperson-start" |
||||||
|
value={startDatetime} |
||||||
|
onChange={setStartDatetime} |
||||||
|
label={t('Start')} |
||||||
|
labelSuffix="*" |
||||||
|
required={eventType === 'time'} |
||||||
|
/> |
||||||
|
<DateTimePicker |
||||||
|
id="own-inperson-end" |
||||||
|
value={endDatetime} |
||||||
|
onChange={setEndDatetime} |
||||||
|
label={t('End')} |
||||||
|
labelSuffix={ |
||||||
|
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
} |
||||||
|
/> |
||||||
|
</> |
||||||
|
)} |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-location"> |
||||||
|
{t('Location')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-inperson-location" |
||||||
|
value={location} |
||||||
|
onChange={(e) => setLocation(e.target.value)} |
||||||
|
placeholder={t('Address, venue, or place')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-summary"> |
||||||
|
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Textarea |
||||||
|
id="own-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="own-inperson-topics"> |
||||||
|
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-inperson-topics" |
||||||
|
value={topics} |
||||||
|
onChange={(e) => setTopics(e.target.value)} |
||||||
|
placeholder={t('e.g. meetup, conference')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-image"> |
||||||
|
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-inperson-image" |
||||||
|
type="url" |
||||||
|
value={image} |
||||||
|
onChange={(e) => setImage(e.target.value)} |
||||||
|
placeholder={t('Optional image for the event')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-inperson-invitees">{t('Invitees')} *</Label> |
||||||
|
<InviteePicker |
||||||
|
labelId="own-inperson-invitees" |
||||||
|
value={inviteePubkeys} |
||||||
|
onChange={setInviteePubkeys} |
||||||
|
placeholder={t('Search by name or npub…')} |
||||||
|
className="mt-1" |
||||||
|
max={MAX_CALENDAR_INVITEES} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{formValid && previewDraft && ( |
||||||
|
<div> |
||||||
|
<Label className="mb-1 block">{t('Preview')}</Label> |
||||||
|
<CalendarEventPreview draft={previewDraft} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<DialogFooter> |
||||||
|
<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 invites')} |
||||||
|
</Button> |
||||||
|
</DialogFooter> |
||||||
|
</form> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,381 @@ |
|||||||
|
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"> |
||||||
|
<DialogHeader> |
||||||
|
<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="space-y-4"> |
||||||
|
<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> |
||||||
|
<Label className="mb-1 block">{t('Preview')}</Label> |
||||||
|
<CalendarEventPreview draft={previewDraft} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<DialogFooter> |
||||||
|
<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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,318 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogDescription, |
||||||
|
DialogFooter, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { InviteePicker } from '@/components/InviteePicker' |
||||||
|
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 { MAX_CALENDAR_INVITEES } from '@/constants' |
||||||
|
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 ScheduleVideoCallDialog({ |
||||||
|
open, |
||||||
|
onOpenChange |
||||||
|
}: { |
||||||
|
open: boolean |
||||||
|
onOpenChange: (open: boolean) => void |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { publish } = useNostr() |
||||||
|
|
||||||
|
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 [inviteePubkeys, setInviteePubkeys] = useState<string[]>([]) |
||||||
|
const [submitting, setSubmitting] = useState(false) |
||||||
|
|
||||||
|
const formValid = useMemo(() => { |
||||||
|
if (!startDatetime.trim()) return false |
||||||
|
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 false |
||||||
|
if (inviteePubkeys.length === 0 || inviteePubkeys.length > MAX_CALENDAR_INVITEES) return false |
||||||
|
return true |
||||||
|
}, [startDatetime, endDatetime, inviteePubkeys]) |
||||||
|
|
||||||
|
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: 'Guest' }) |
||||||
|
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: inviteePubkeys |
||||||
|
}) |
||||||
|
}, [ |
||||||
|
title, |
||||||
|
startDatetime, |
||||||
|
endDatetime, |
||||||
|
locationUrl, |
||||||
|
summary, |
||||||
|
topics, |
||||||
|
image, |
||||||
|
inviteePubkeys, |
||||||
|
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 |
||||||
|
} |
||||||
|
if (inviteePubkeys.length === 0) { |
||||||
|
toast.error(t('Add at least one invitee')) |
||||||
|
return |
||||||
|
} |
||||||
|
if (inviteePubkeys.length > MAX_CALENDAR_INVITEES) { |
||||||
|
toast.error(t('Maximum {{max}} invitees allowed', { max: MAX_CALENDAR_INVITEES })) |
||||||
|
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: 'Guest' |
||||||
|
}) |
||||||
|
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: inviteePubkeys |
||||||
|
}) |
||||||
|
|
||||||
|
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, |
||||||
|
inviteePubkeys, |
||||||
|
{ addClientTag: true } |
||||||
|
) |
||||||
|
await publish(pmDraft) |
||||||
|
|
||||||
|
toast.success( |
||||||
|
t('Scheduled call created and {{count}} invite(s) sent', { |
||||||
|
count: inviteePubkeys.length |
||||||
|
}) |
||||||
|
) |
||||||
|
onOpenChange(false) |
||||||
|
setTitle('') |
||||||
|
setStartDatetime('') |
||||||
|
setEndDatetime('') |
||||||
|
setLocationUrl('') |
||||||
|
setSummary('') |
||||||
|
setTopics('') |
||||||
|
setImage('') |
||||||
|
setInviteePubkeys([]) |
||||||
|
} 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"> |
||||||
|
<DialogHeader> |
||||||
|
<DialogTitle className="flex items-center gap-2"> |
||||||
|
<Calendar className="size-5" /> |
||||||
|
{t('Schedule a video call')} |
||||||
|
</DialogTitle> |
||||||
|
<DialogDescription> |
||||||
|
{t('Required: start time, invitees. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')} |
||||||
|
</DialogDescription> |
||||||
|
</DialogHeader> |
||||||
|
<form onSubmit={handleSubmit} className="space-y-4"> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-call-title"> |
||||||
|
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-call-title" |
||||||
|
value={title} |
||||||
|
onChange={(e) => setTitle(e.target.value)} |
||||||
|
placeholder={t('Video call')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<DateTimePicker |
||||||
|
id="own-call-start" |
||||||
|
value={startDatetime} |
||||||
|
onChange={setStartDatetime} |
||||||
|
label={t('Start')} |
||||||
|
labelSuffix="*" |
||||||
|
required |
||||||
|
/> |
||||||
|
<DateTimePicker |
||||||
|
id="own-call-end" |
||||||
|
value={endDatetime} |
||||||
|
onChange={setEndDatetime} |
||||||
|
label={t('End')} |
||||||
|
labelSuffix={ |
||||||
|
<span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
} |
||||||
|
/> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-call-location"> |
||||||
|
{t('Join link')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-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="own-call-summary"> |
||||||
|
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Textarea |
||||||
|
id="own-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="own-call-topics"> |
||||||
|
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-call-topics" |
||||||
|
value={topics} |
||||||
|
onChange={(e) => setTopics(e.target.value)} |
||||||
|
placeholder={t('e.g. meetup, conference')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-call-image"> |
||||||
|
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span> |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
id="own-call-image" |
||||||
|
type="url" |
||||||
|
value={image} |
||||||
|
onChange={(e) => setImage(e.target.value)} |
||||||
|
placeholder={t('Optional image for the event')} |
||||||
|
className="mt-1" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<Label htmlFor="own-call-invitees">{t('Invitees')} *</Label> |
||||||
|
<InviteePicker |
||||||
|
labelId="own-call-invitees" |
||||||
|
value={inviteePubkeys} |
||||||
|
onChange={setInviteePubkeys} |
||||||
|
placeholder={t('Search by name or npub…')} |
||||||
|
className="mt-1" |
||||||
|
max={MAX_CALENDAR_INVITEES} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{formValid && previewDraft && ( |
||||||
|
<div> |
||||||
|
<Label className="mb-1 block">{t('Preview')}</Label> |
||||||
|
<CalendarEventPreview draft={previewDraft} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<DialogFooter> |
||||||
|
<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 invites')} |
||||||
|
</Button> |
||||||
|
</DialogFooter> |
||||||
|
</form> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,292 @@ |
|||||||
|
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"> |
||||||
|
<DialogHeader> |
||||||
|
<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="space-y-4"> |
||||||
|
<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> |
||||||
|
<Label className="mb-1 block">{t('Preview')}</Label> |
||||||
|
<CalendarEventPreview draft={previewDraft} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<DialogFooter> |
||||||
|
<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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
export { ScheduleVideoCallDialog } from './ScheduleVideoCallDialog' |
||||||
|
export { ScheduleVideoCallSingleDialog } from './ScheduleVideoCallSingleDialog' |
||||||
|
export { ScheduleInPersonMeetingDialog } from './ScheduleInPersonMeetingDialog' |
||||||
|
export { ScheduleInPersonMeetingSingleDialog } from './ScheduleInPersonMeetingSingleDialog' |
||||||
@ -0,0 +1,91 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { TimePicker } from '@/components/ui/TimePicker' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
|
||||||
|
/** Value is "YYYY-MM-DDTHH:mm" (same as datetime-local). */ |
||||||
|
export interface DateTimePickerProps { |
||||||
|
value: string |
||||||
|
onChange: (value: string) => void |
||||||
|
id?: string |
||||||
|
label?: React.ReactNode |
||||||
|
/** Start * or (optional) etc. */ |
||||||
|
labelSuffix?: React.ReactNode |
||||||
|
required?: boolean |
||||||
|
className?: string |
||||||
|
disabled?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
function datePart(dt: string): string { |
||||||
|
if (!dt || dt.length < 10) return '' |
||||||
|
return dt.slice(0, 10) |
||||||
|
} |
||||||
|
|
||||||
|
function timePart(dt: string): string { |
||||||
|
if (!dt || dt.length < 16) return '09:00' |
||||||
|
return dt.slice(11, 16) |
||||||
|
} |
||||||
|
|
||||||
|
export function DateTimePicker({ |
||||||
|
value, |
||||||
|
onChange, |
||||||
|
id, |
||||||
|
label, |
||||||
|
labelSuffix, |
||||||
|
required, |
||||||
|
className, |
||||||
|
disabled |
||||||
|
}: DateTimePickerProps) { |
||||||
|
const date = datePart(value) |
||||||
|
const time = timePart(value) |
||||||
|
|
||||||
|
const handleDateChange = React.useCallback( |
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => { |
||||||
|
const d = e.target.value |
||||||
|
onChange(d ? `${d}T${time}` : '') |
||||||
|
}, |
||||||
|
[time, onChange] |
||||||
|
) |
||||||
|
|
||||||
|
const handleTimeChange = React.useCallback( |
||||||
|
(t: string) => { |
||||||
|
if (!date) { |
||||||
|
const today = new Date().toISOString().slice(0, 10) |
||||||
|
onChange(`${today}T${t}`) |
||||||
|
} else { |
||||||
|
onChange(`${date}T${t}`) |
||||||
|
} |
||||||
|
}, |
||||||
|
[date, onChange] |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('space-y-2', className)}> |
||||||
|
{label != null && ( |
||||||
|
<Label htmlFor={id}> |
||||||
|
{label} {labelSuffix} |
||||||
|
</Label> |
||||||
|
)} |
||||||
|
<div className="flex flex-wrap items-end gap-3"> |
||||||
|
<div className="flex-1 min-w-[140px]"> |
||||||
|
<Input |
||||||
|
id={id} |
||||||
|
type="date" |
||||||
|
value={date} |
||||||
|
onChange={handleDateChange} |
||||||
|
required={required} |
||||||
|
disabled={disabled} |
||||||
|
className="h-9" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<TimePicker |
||||||
|
value={time} |
||||||
|
onChange={handleTimeChange} |
||||||
|
disabled={disabled} |
||||||
|
id={id ? `${id}-time` : undefined} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,174 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import { |
||||||
|
Select, |
||||||
|
SelectContent, |
||||||
|
SelectItem, |
||||||
|
SelectTrigger, |
||||||
|
SelectValue |
||||||
|
} from '@/components/ui/select' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
/** Value is always 24-hour "HH:mm" */ |
||||||
|
export interface TimePickerProps { |
||||||
|
value: string |
||||||
|
onChange: (value: string) => void |
||||||
|
/** When true, show 12-hour with AM/PM; when false, show 24-hour. Default from locale (en-US -> 12h). */ |
||||||
|
hour12?: boolean |
||||||
|
onHour12Change?: (hour12: boolean) => void |
||||||
|
className?: string |
||||||
|
id?: string |
||||||
|
disabled?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
function parseHHmm(value: string): { hour: number; minute: number } { |
||||||
|
const match = /^(\d{1,2}):(\d{2})$/.exec(value) |
||||||
|
if (!match) return { hour: 0, minute: 0 } |
||||||
|
const hour = Math.min(23, Math.max(0, parseInt(match[1]!, 10))) |
||||||
|
const minute = Math.min(59, Math.max(0, parseInt(match[2]!, 10))) |
||||||
|
return { hour, minute } |
||||||
|
} |
||||||
|
|
||||||
|
function toHHmm(hour: number, minute: number): string { |
||||||
|
const h = Math.min(23, Math.max(0, hour)) |
||||||
|
const m = Math.min(59, Math.max(0, minute)) |
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` |
||||||
|
} |
||||||
|
|
||||||
|
/** 24h hour (0-23) to 12h display: { displayHour 1-12, pm: boolean } */ |
||||||
|
function to12h(hour24: number): { displayHour: number; pm: boolean } { |
||||||
|
if (hour24 === 0) return { displayHour: 12, pm: false } |
||||||
|
if (hour24 < 12) return { displayHour: hour24, pm: false } |
||||||
|
if (hour24 === 12) return { displayHour: 12, pm: true } |
||||||
|
return { displayHour: hour24 - 12, pm: true } |
||||||
|
} |
||||||
|
|
||||||
|
/** 12h + AM/PM to 24h hour (0-23) */ |
||||||
|
function to24h(displayHour: number, pm: boolean): number { |
||||||
|
if (pm) return displayHour === 12 ? 12 : displayHour + 12 |
||||||
|
return displayHour === 12 ? 0 : displayHour |
||||||
|
} |
||||||
|
|
||||||
|
const MINUTES = Array.from({ length: 60 }, (_, i) => i) |
||||||
|
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i) |
||||||
|
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1) |
||||||
|
|
||||||
|
/** Default: use 12h for en-US, 24h otherwise */ |
||||||
|
function defaultHour12(): boolean { |
||||||
|
try { |
||||||
|
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en-US' |
||||||
|
return lang.startsWith('en-US') |
||||||
|
} catch { |
||||||
|
return false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function TimePicker({ |
||||||
|
value, |
||||||
|
onChange, |
||||||
|
hour12: controlledHour12, |
||||||
|
onHour12Change, |
||||||
|
className, |
||||||
|
id, |
||||||
|
disabled |
||||||
|
}: TimePickerProps) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const [internalHour12, setInternalHour12] = React.useState(defaultHour12) |
||||||
|
const hour12 = controlledHour12 ?? internalHour12 |
||||||
|
const setHour12 = React.useCallback( |
||||||
|
(v: boolean) => { |
||||||
|
if (onHour12Change) onHour12Change(v) |
||||||
|
else setInternalHour12(v) |
||||||
|
}, |
||||||
|
[onHour12Change] |
||||||
|
) |
||||||
|
|
||||||
|
const { hour: hour24, minute } = parseHHmm(value) |
||||||
|
const { displayHour: hour12Val, pm } = to12h(hour24) |
||||||
|
|
||||||
|
const handleMinuteChange = React.useCallback( |
||||||
|
(m: number) => { |
||||||
|
onChange(toHHmm(hour24, m)) |
||||||
|
}, |
||||||
|
[hour24, onChange] |
||||||
|
) |
||||||
|
|
||||||
|
const handleHourChange = React.useCallback( |
||||||
|
(newHour: number) => { |
||||||
|
if (hour12) { |
||||||
|
const new24 = to24h(newHour, pm) |
||||||
|
onChange(toHHmm(new24, minute)) |
||||||
|
} else { |
||||||
|
onChange(toHHmm(newHour, minute)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[hour12, minute, pm, onChange] |
||||||
|
) |
||||||
|
|
||||||
|
const handleAmPmChange = React.useCallback( |
||||||
|
(newPm: boolean) => { |
||||||
|
const new24 = to24h(hour12Val, newPm) |
||||||
|
onChange(toHHmm(new24, minute)) |
||||||
|
}, |
||||||
|
[hour12Val, minute, onChange] |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('flex flex-wrap items-center gap-2', className)}> |
||||||
|
<div className="flex items-center gap-1"> |
||||||
|
<Select |
||||||
|
value={hour12 ? String(hour12Val) : String(hour24)} |
||||||
|
onValueChange={(v) => handleHourChange(parseInt(v, 10))} |
||||||
|
disabled={disabled} |
||||||
|
> |
||||||
|
<SelectTrigger id={id} className="w-[72px]"> |
||||||
|
<SelectValue /> |
||||||
|
</SelectTrigger> |
||||||
|
<SelectContent> |
||||||
|
{(hour12 ? HOURS_12 : HOURS_24).map((h) => ( |
||||||
|
<SelectItem key={h} value={String(h)}> |
||||||
|
{hour12 ? h : String(h).padStart(2, '0')} |
||||||
|
</SelectItem> |
||||||
|
))} |
||||||
|
</SelectContent> |
||||||
|
</Select> |
||||||
|
<span className="text-muted-foreground">:</span> |
||||||
|
<Select |
||||||
|
value={String(minute)} |
||||||
|
onValueChange={(v) => handleMinuteChange(parseInt(v, 10))} |
||||||
|
disabled={disabled} |
||||||
|
> |
||||||
|
<SelectTrigger className="w-[72px]"> |
||||||
|
<SelectValue /> |
||||||
|
</SelectTrigger> |
||||||
|
<SelectContent> |
||||||
|
{MINUTES.map((m) => ( |
||||||
|
<SelectItem key={m} value={String(m)}> |
||||||
|
{String(m).padStart(2, '0')} |
||||||
|
</SelectItem> |
||||||
|
))} |
||||||
|
</SelectContent> |
||||||
|
</Select> |
||||||
|
</div> |
||||||
|
{hour12 && ( |
||||||
|
<Select value={pm ? 'pm' : 'am'} onValueChange={(v) => handleAmPmChange(v === 'pm')} disabled={disabled}> |
||||||
|
<SelectTrigger className="w-[72px]"> |
||||||
|
<SelectValue /> |
||||||
|
</SelectTrigger> |
||||||
|
<SelectContent> |
||||||
|
<SelectItem value="am">{t('AM')}</SelectItem> |
||||||
|
<SelectItem value="pm">{t('PM')}</SelectItem> |
||||||
|
</SelectContent> |
||||||
|
</Select> |
||||||
|
)} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => setHour12(!hour12)} |
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline" |
||||||
|
disabled={disabled} |
||||||
|
> |
||||||
|
{hour12 ? t('24-hour') : t('12-hour (AM/PM)')} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { getReplaceableCoordinateFromEvent } from '@/lib/event' |
||||||
|
import { isCalendarEventKind } from '@/lib/calendar-event' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useEffect, useState } from 'react' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
|
||||||
|
function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined { |
||||||
|
const status = rsvp.tags.find(tagNameEquals('status'))?.[1] |
||||||
|
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status |
||||||
|
return undefined |
||||||
|
} |
||||||
|
|
||||||
|
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { |
||||||
|
const { relayList } = useNostr() |
||||||
|
const [rsvps, setRsvps] = useState<Event[]>([]) |
||||||
|
const [isFetching, setIsFetching] = useState(false) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) { |
||||||
|
setRsvps([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
let cancelled = false |
||||||
|
setIsFetching(true) |
||||||
|
|
||||||
|
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) |
||||||
|
const userRead = relayList?.read ?? [] |
||||||
|
const relayUrls = Array.from( |
||||||
|
new Set([ |
||||||
|
...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url), |
||||||
|
...userRead.map((url) => normalizeUrl(url) || url) |
||||||
|
]) |
||||||
|
).filter(Boolean) as string[] |
||||||
|
|
||||||
|
client |
||||||
|
.fetchEvents(relayUrls, { |
||||||
|
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], |
||||||
|
'#a': [coordinate], |
||||||
|
limit: 200 |
||||||
|
}) |
||||||
|
.then((events) => { |
||||||
|
if (cancelled) return |
||||||
|
setRsvps(events) |
||||||
|
}) |
||||||
|
.finally(() => { |
||||||
|
if (!cancelled) setIsFetching(false) |
||||||
|
}) |
||||||
|
|
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [calendarEvent?.id, calendarEvent?.kind, relayList?.read]) |
||||||
|
|
||||||
|
return { |
||||||
|
rsvps, |
||||||
|
isFetching, |
||||||
|
getRsvpStatus |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export interface CalendarEventMeta { |
||||||
|
title: string |
||||||
|
summary: string |
||||||
|
image: string |
||||||
|
/** Time-based: Unix seconds. Date-based: undefined. */ |
||||||
|
start: number | undefined |
||||||
|
/** Time-based: Unix seconds. Date-based: undefined. */ |
||||||
|
end: number | undefined |
||||||
|
/** Date-based: YYYY-MM-DD. Time-based: undefined. */ |
||||||
|
startDate: string |
||||||
|
/** Date-based: YYYY-MM-DD (exclusive end). Time-based: undefined. */ |
||||||
|
endDate: string |
||||||
|
isDateBased: boolean |
||||||
|
joinUrl: string |
||||||
|
topics: string[] |
||||||
|
} |
||||||
|
|
||||||
|
export function getCalendarEventMeta(event: Event): CalendarEventMeta { |
||||||
|
const title = event.tags.find(tagNameEquals('title'))?.[1] ?? '' |
||||||
|
const summary = event.tags.find(tagNameEquals('summary'))?.[1] ?? '' |
||||||
|
const image = event.tags.find(tagNameEquals('image'))?.[1] ?? '' |
||||||
|
const startStr = event.tags.find(tagNameEquals('start'))?.[1] |
||||||
|
const endStr = event.tags.find(tagNameEquals('end'))?.[1] |
||||||
|
const location = event.tags.find(tagNameEquals('location'))?.[1] |
||||||
|
const rTag = event.tags.find(tagNameEquals('r'))?.[1] |
||||||
|
const joinUrl = rTag || location || '' |
||||||
|
const topics = event.tags.filter(tagNameEquals('t')).map((t) => t[1]?.trim()).filter(Boolean) |
||||||
|
const isDateBased = event.kind === ExtendedKind.CALENDAR_EVENT_DATE |
||||||
|
if (isDateBased) { |
||||||
|
return { |
||||||
|
title, |
||||||
|
summary, |
||||||
|
image, |
||||||
|
start: undefined, |
||||||
|
end: undefined, |
||||||
|
startDate: startStr ?? '', |
||||||
|
endDate: endStr ?? '', |
||||||
|
isDateBased: true, |
||||||
|
joinUrl, |
||||||
|
topics |
||||||
|
} |
||||||
|
} |
||||||
|
const start = startStr ? parseInt(startStr, 10) : undefined |
||||||
|
const end = endStr ? parseInt(endStr, 10) : undefined |
||||||
|
return { |
||||||
|
title, |
||||||
|
summary, |
||||||
|
image, |
||||||
|
start, |
||||||
|
end, |
||||||
|
startDate: '', |
||||||
|
endDate: '', |
||||||
|
isDateBased: false, |
||||||
|
joinUrl, |
||||||
|
topics |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function formatCalendarTime(ts: number): string { |
||||||
|
const d = new Date(ts * 1000) |
||||||
|
return d.toLocaleString(undefined, { |
||||||
|
dateStyle: 'medium', |
||||||
|
timeStyle: 'short' |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
/** Format a YYYY-MM-DD date string for display. */ |
||||||
|
export function formatCalendarDate(dateStr: string): string { |
||||||
|
if (!dateStr) return '' |
||||||
|
const d = new Date(dateStr + 'T00:00:00') |
||||||
|
return d.toLocaleDateString(undefined, { dateStyle: 'long' }) |
||||||
|
} |
||||||
|
|
||||||
|
export function isCalendarEventKind(kind: number): boolean { |
||||||
|
return kind === ExtendedKind.CALENDAR_EVENT_DATE || kind === ExtendedKind.CALENDAR_EVENT_TIME |
||||||
|
} |
||||||
Loading…
Reference in new issue