diff --git a/src/components/CalendarEventContent/index.tsx b/src/components/CalendarEventContent/index.tsx new file mode 100644 index 00000000..fccaf256 --- /dev/null +++ b/src/components/CalendarEventContent/index.tsx @@ -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 ( +
e.stopPropagation()} + > +
+ {image ? ( + + ) : ( + + )} +
+ + {title || t('Scheduled video call')} + + {topics.length > 0 && ( +
+ {topics.map((topic) => ( + + #{topic} + + ))} +
+ )} +
+
+ {isDateBased ? ( + (startDate || endDate) && ( +
+ {startDate ? formatCalendarDate(startDate) : ''} + {endDate && endDate !== startDate && ( + <> – {formatCalendarDate(endDate)} + )} +
+ ) + ) : ( + start != null && + !isNaN(start) && ( +
+ {formatCalendarTime(start)} + {end != null && !isNaN(end) && end > start && ( + <> – {formatCalendarTime(end)} + )} +
+ ) + )} + {description && ( +

+ {description} +

+ )} +
+ {joinUrl && ( + + )} + {showRsvp && myPubkey && ( + + + + + + handleRsvp('accepted')}> + + {t('Accepted')} + + handleRsvp('tentative')}> + + {t('Tentative')} + + handleRsvp('declined')}> + + {t('Declined')} + + + + )} +
+
+ ) +} diff --git a/src/components/Embedded/EmbeddedCalendarEvent.tsx b/src/components/Embedded/EmbeddedCalendarEvent.tsx new file mode 100644 index 00000000..30870370 --- /dev/null +++ b/src/components/Embedded/EmbeddedCalendarEvent.tsx @@ -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 ( +
e.stopPropagation()} + > +
+ {image ? ( + + ) : ( + + )} +
+ + {title || t('Scheduled video call')} + + {topics.length > 0 && ( +
+ {topics.map((topic) => ( + + #{topic} + + ))} +
+ )} +
+
+ {isDateBased ? ( + (startDate || endDate) && ( +
+ {startDate ? formatCalendarDate(startDate) : ''} + {endDate && endDate !== startDate && ( + <> – {formatCalendarDate(endDate)} + )} +
+ ) + ) : ( + start != null && + !isNaN(start) && ( +
+ {formatCalendarTime(start)} + {end != null && !isNaN(end) && end > start && ( + <> – {formatCalendarTime(end)} + )} +
+ ) + )} + {description && ( +

+ {description} +

+ )} + {joinUrl && ( + + )} +
+ ) +} diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 96046760..ef5bba1a 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -10,6 +10,7 @@ import { Event, nip19 } from 'nostr-tools' import ClientSelect from '../ClientSelect' import MainNoteCard from '../NoteCard/MainNoteCard' import { Button } from '../ui/button' +import { EmbeddedCalendarEvent } from './EmbeddedCalendarEvent' import { Search } from 'lucide-react' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' @@ -74,6 +75,15 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? ) } + // NIP-52 calendar event (scheduled video call) – render as calendar card + if (finalEvent.kind === ExtendedKind.CALENDAR_EVENT_TIME || finalEvent.kind === ExtendedKind.CALENDAR_EVENT_DATE) { + return ( +
e.stopPropagation()}> + +
+ ) + } + // Otherwise, render as regular embedded note return (
e.stopPropagation()}> diff --git a/src/components/Embedded/index.tsx b/src/components/Embedded/index.tsx index f3f8a43d..40595382 100644 --- a/src/components/Embedded/index.tsx +++ b/src/components/Embedded/index.tsx @@ -1,3 +1,4 @@ +export * from './EmbeddedCalendarEvent' export * from './EmbeddedHashtag' export * from './EmbeddedLNInvoice' export * from './EmbeddedMention' diff --git a/src/components/InviteePicker/index.tsx b/src/components/InviteePicker/index.tsx new file mode 100644 index 00000000..6bf979e0 --- /dev/null +++ b/src/components/InviteePicker/index.tsx @@ -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 ( +
+ {value.length > 0 && ( +
+ {value.map((pubkey) => ( + + + + + + ))} +
+ )} +
+ setSearch(e.target.value)} + placeholder={placeholder ?? t('Search by name or npub…')} + className="mt-1" + autoComplete="off" + /> + {search.trim() && !atLimit && ( +
+ {isFetching && filteredProfiles.length === 0 ? ( +
{t('Searching…')}
+ ) : filteredProfiles.length === 0 ? ( +
{t('No users found')}
+ ) : ( +
    + {filteredProfiles.map((profile) => ( +
  • + +
  • + ))} +
+ )} +
+ )} + {atLimit && ( +

+ {t('Maximum {{max}} invitees', { max: max ?? 0 })} +

+ )} +
+
+ ) +} diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index ff923ed1..8f9f9431 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -26,6 +26,7 @@ const KIND_FILTER_OPTIONS = [ { kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' }, { kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' }, { kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' }, + { kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' }, { kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' } ] diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 7044c7c3..fdf35552 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -42,6 +42,7 @@ import RelayReview from './RelayReview' import Zap from './Zap' import CitationCard from '@/components/CitationCard' import FollowPackPreview from '../ContentPreview/FollowPackPreview' +import CalendarEventContent from '../CalendarEventContent' export default function Note({ event, @@ -106,6 +107,8 @@ export default function Note({ event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT || event.kind === ExtendedKind.DISCUSSION || + event.kind === ExtendedKind.CALENDAR_EVENT_TIME || + event.kind === ExtendedKind.CALENDAR_EVENT_DATE || event.kind === ExtendedKind.COMMENT let content: React.ReactNode @@ -214,6 +217,8 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.RELAY_REVIEW) { content = + } else if (event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE) { + content = } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { content = } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index 2ccec235..469c0b4f 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -11,6 +11,8 @@ const POST_KIND_LIST = [ ExtendedKind.COMMENT, ExtendedKind.DISCUSSION, ExtendedKind.POLL, + ExtendedKind.CALENDAR_EVENT_DATE, + ExtendedKind.CALENDAR_EVENT_TIME, ExtendedKind.ZAP_RECEIPT, ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT @@ -51,6 +53,8 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[] if (kindNum === ExtendedKind.COMMENT) return 'comments' if (kindNum === ExtendedKind.DISCUSSION) return 'discussions' if (kindNum === ExtendedKind.POLL) return 'polls' + if (kindNum === ExtendedKind.CALENDAR_EVENT_TIME || kindNum === ExtendedKind.CALENDAR_EVENT_DATE) + return 'calendar events' if (kindNum === ExtendedKind.ZAP_RECEIPT) return 'zaps' if (kindNum === ExtendedKind.VOICE) return 'voice posts' if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments' diff --git a/src/components/Profile/ProfileTimeline.tsx b/src/components/Profile/ProfileTimeline.tsx index 4fc2c47f..1b9c8836 100644 --- a/src/components/Profile/ProfileTimeline.tsx +++ b/src/components/Profile/ProfileTimeline.tsx @@ -1,4 +1,5 @@ import NoteCard from '@/components/NoteCard' +import { CALENDAR_EVENT_KINDS } from '@/constants' import { Skeleton } from '@/components/ui/skeleton' import { Event } from 'nostr-tools' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react' @@ -85,7 +86,10 @@ const ProfileTimeline = forwardRef< if (Number.isNaN(kindNumber)) { return timelineEvents } - return timelineEvents.filter((event) => event.kind === kindNumber) + return timelineEvents.filter((event) => + event.kind === kindNumber || + (CALENDAR_EVENT_KINDS.includes(kindNumber) && CALENDAR_EVENT_KINDS.includes(event.kind)) + ) }, [timelineEvents, kindFilter]) const filteredEvents = useMemo(() => { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 1af827b9..51c22857 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -32,7 +32,13 @@ import { toNoteList } from '@/lib/link' import { parseAdvancedSearch } from '@/lib/search-parser' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { FileText, Link, Film, Copy } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { FileText, Link, Film, Copy, Ellipsis, Calendar, MapPin, Pencil } from 'lucide-react' import { useEffect, useMemo, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -52,6 +58,10 @@ import { toFollowPacks } from '@/lib/link' import ZapDialog from '@/components/ZapDialog' import PaytoLink from '@/components/PaytoLink' import PostEditor from '@/components/PostEditor' +import { + ScheduleVideoCallDialog, + ScheduleInPersonMeetingDialog +} from '@/components/ScheduleVideoCallDialog' import type { TProfile } from '@/types' type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' @@ -165,6 +175,8 @@ export default function Profile({ id }: { id?: string }) { const [openZapDialog, setOpenZapDialog] = useState(false) const [openPublicMessageTo, setOpenPublicMessageTo] = useState(null) const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) + const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false) + const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false) const mergedPaymentMethods = useMemo(() => { const list = mergePaymentMethods(paymentInfo, profile ?? null) @@ -458,22 +470,31 @@ export default function Profile({ id }: { id?: string }) { } /> {isSelf ? ( -
- - -
+ + + + + + setOpenScheduleOwnCall(true)}> + + {t('Schedule a video call')} + + setOpenScheduleInPersonMeeting(true)}> + + {t('Schedule in-person meeting')} + + push(toFollowPacks())}> + + {t('Browse follow packs')} + + push(toProfileEditor())}> + + {t('Edit')} + + + ) : ( <> {mergedPaymentMethods.some((m) => m.type === 'lightning') && ( @@ -640,6 +661,11 @@ export default function Profile({ id }: { id?: string }) { const discussionCount = postEvents.filter((event) => event.kind === ExtendedKind.DISCUSSION).length const pollCount = postEvents.filter((event) => event.kind === ExtendedKind.POLL).length const superzapCount = postEvents.filter((event) => event.kind === ExtendedKind.ZAP_RECEIPT).length + const calendarEventCount = postEvents.filter( + (event) => + event.kind === ExtendedKind.CALENDAR_EVENT_TIME || + event.kind === ExtendedKind.CALENDAR_EVENT_DATE + ).length return ( @@ -811,6 +838,14 @@ export default function Profile({ id }: { id?: string }) { defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`} /> )} + + ) } diff --git a/src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx b/src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx new file mode 100644 index 00000000..afdf1c1e --- /dev/null +++ b/src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx @@ -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 ( +
+ + + {t('Rendered')} + {t('JSON')} + + +
+ +
+
+ +
+            {jsonString}
+          
+
+
+
+ ) +} diff --git a/src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx b/src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx new file mode 100644 index 00000000..b6a74172 --- /dev/null +++ b/src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx @@ -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([]) + 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 ( + + + + + + {t('Schedule in-person meeting')} + + + {t('Required: start (or start date), invitees. Optional: title, end, location, summary, topics, image.')} + + +
+
+ + setEventType(v as 'time' | 'date')} + className="mt-2 flex gap-4" + > + + + +
+
+ + setTitle(e.target.value)} + placeholder={t('In-person meeting')} + className="mt-1" + /> +
+ {eventType === 'date' ? ( + <> +
+ + setStartDateStr(e.target.value)} + className="mt-1" + required={eventType === 'date'} + /> +
+
+ + setEndDateStr(e.target.value)} + className="mt-1" + /> +
+ + ) : ( + <> + + ({t('optional')}) + } + /> + + )} +
+ + setLocation(e.target.value)} + placeholder={t('Address, venue, or place')} + className="mt-1" + /> +
+
+ +