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()}>
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 (
+
+ )
+}
diff --git a/src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx b/src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx
new file mode 100644
index 00000000..95abe738
--- /dev/null
+++ b/src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx b/src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx
new file mode 100644
index 00000000..6b9c033f
--- /dev/null
+++ b/src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx
@@ -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([])
+ 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 (
+
+ )
+}
diff --git a/src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx b/src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx
new file mode 100644
index 00000000..4f0d231f
--- /dev/null
+++ b/src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/ScheduleVideoCallDialog/index.tsx b/src/components/ScheduleVideoCallDialog/index.tsx
new file mode 100644
index 00000000..b217d9b6
--- /dev/null
+++ b/src/components/ScheduleVideoCallDialog/index.tsx
@@ -0,0 +1,4 @@
+export { ScheduleVideoCallDialog } from './ScheduleVideoCallDialog'
+export { ScheduleVideoCallSingleDialog } from './ScheduleVideoCallSingleDialog'
+export { ScheduleInPersonMeetingDialog } from './ScheduleInPersonMeetingDialog'
+export { ScheduleInPersonMeetingSingleDialog } from './ScheduleInPersonMeetingSingleDialog'
diff --git a/src/components/ui/DateTimePicker.tsx b/src/components/ui/DateTimePicker.tsx
new file mode 100644
index 00000000..4994e3fd
--- /dev/null
+++ b/src/components/ui/DateTimePicker.tsx
@@ -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) => {
+ 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 (
+
+ {label != null && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/ui/TimePicker.tsx b/src/components/ui/TimePicker.tsx
new file mode 100644
index 00000000..b967d0bf
--- /dev/null
+++ b/src/components/ui/TimePicker.tsx
@@ -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 (
+
+
+
+ :
+
+
+ {hour12 && (
+
+ )}
+
+
+ )
+}
diff --git a/src/constants.ts b/src/constants.ts
index 48bed782..1573c204 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -136,7 +136,6 @@ export const GIF_RELAY_URLS = [
]
export const SEARCHABLE_RELAY_URLS = [
- 'wss://nostr.sovbit.host',
'wss://freelay.sovbit.host',
'wss://search.nos.today',
'wss://nostr.wine',
@@ -200,9 +199,24 @@ export const ExtendedKind = {
/** NIP-66 Relay discovery (relay characteristics from NIP-11 or probing) */
RELAY_DISCOVERY: 30166,
/** NIP-66 Relay monitor announcement (intent to publish 30166 at a frequency) */
- RELAY_MONITOR_ANNOUNCEMENT: 10166
+ RELAY_MONITOR_ANNOUNCEMENT: 10166,
+ /** NIP-52 Date-based calendar event (all-day / multi-day) */
+ CALENDAR_EVENT_DATE: 31922,
+ /** NIP-52 Time-based calendar event */
+ CALENDAR_EVENT_TIME: 31923,
+ /** NIP-52 Calendar event RSVP */
+ CALENDAR_EVENT_RSVP: 31925
}
+/** NIP-52 calendar event kinds (addressable by d-tag); use in isReplaceableEvent. */
+export const CALENDAR_EVENT_KINDS = [
+ ExtendedKind.CALENDAR_EVENT_DATE,
+ ExtendedKind.CALENDAR_EVENT_TIME
+]
+
+/** Maximum invitees for calendar event group invites (one kind 24 with all as p-tags). */
+export const MAX_CALENDAR_INVITEES = 10
+
export const SUPPORTED_KINDS = [
kinds.ShortTextNote,
kinds.Repost,
@@ -219,6 +233,8 @@ export const SUPPORTED_KINDS = [
ExtendedKind.RELAY_REVIEW,
ExtendedKind.DISCUSSION,
ExtendedKind.ZAP_RECEIPT,
+ ExtendedKind.CALENDAR_EVENT_DATE,
+ ExtendedKind.CALENDAR_EVENT_TIME,
ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx
index 652e395d..333ca959 100644
--- a/src/hooks/index.tsx
+++ b/src/hooks/index.tsx
@@ -1,3 +1,4 @@
+export * from './useFetchCalendarRsvps'
export * from './useFetchEvent'
export * from './useFetchFollowings'
export * from './useFetchNip05'
diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx
new file mode 100644
index 00000000..a74cbc8c
--- /dev/null
+++ b/src/hooks/useFetchCalendarRsvps.tsx
@@ -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([])
+ 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
+ }
+}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index c3331d21..df1b6dd9 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -71,6 +71,60 @@ export default {
'Start call about this': 'Anruf zu diesem Beitrag starten',
'Send call invite': 'Anruf-Einladung senden',
'Join the video call': 'Am Videoanruf teilnehmen',
+ 'Schedule video call': 'Videoanruf planen',
+ "You're invited to a scheduled video call.": 'Du bist zu einem geplanten Videoanruf eingeladen.',
+ 'Create a calendar event and send an invite. The recipient will see the event with a join link.':
+ 'Kalendertermin erstellen und Einladung senden. Der Empfänger sieht den Termin mit einem Teilnahme-Link.',
+ 'Schedule a video call': 'Videoanruf planen',
+ 'Create a calendar event and send kind 24 invites to each listed invitee.':
+ 'Kalendertermin erstellen und an jede eingetragene Person eine Kind-24-Einladung senden.',
+ Invitees: 'Eingeladene',
+ 'Paste nostr:npub1... or nostr:nprofile1... (one or more)':
+ 'nostr:npub1... oder nostr:nprofile1... einfügen (einer oder mehrere)',
+ 'Schedule and send invites': 'Planen und Einladungen senden',
+ 'Add at least one invitee (paste nostr:npub or nostr:nprofile links)':
+ 'Mindestens eine Person hinzufügen (nostr:npub- oder nostr:nprofile-Links einfügen)',
+ 'Scheduled call created and {{count}} invite(s) sent':
+ 'Geplanter Anruf erstellt und {{count}} Einladung(en) gesendet',
+ 'Join video call': 'Videoanruf beitreten',
+ 'Scheduled video call': 'Geplanter Videoanruf',
+ 'Video call': 'Videoanruf',
+ 'Schedule and send invite': 'Planen und Einladung senden',
+ 'Scheduling…': 'Wird geplant…',
+ 'Please set a start time': 'Bitte Startzeit angeben',
+ 'End time must be after start time': 'Endzeit muss nach der Startzeit liegen',
+ 'Failed to schedule call': 'Anruf konnte nicht geplant werden',
+ 'Scheduled call created and invite sent': 'Geplanter Anruf erstellt und Einladung gesendet',
+ RSVP: 'Rückmeldung',
+ 'RSVP: {{status}}': 'Rückmeldung: {{status}}',
+ Accepted: 'Zusage',
+ Tentative: 'Vielleicht',
+ Declined: 'Absage',
+ 'You need to log in to RSVP': 'Zum Antworten bitte anmelden',
+ 'RSVP updated': 'Rückmeldung gesendet',
+ 'Failed to update RSVP': 'Rückmeldung konnte nicht gesendet werden',
+ 'Calendar Events': 'Kalendertermine',
+ 'Calendar Event': 'Kalendertermin',
+ 'Schedule in-person meeting': 'Präsenztermin planen',
+ 'Create a calendar event and send an invite. No video link — for real-life meetups, conferences, etc.':
+ 'Kalendertermin erstellen und Einladung senden. Ohne Video-Link – für reale Treffen, Konferenzen usw.',
+ "You're invited to an in-person meeting.": 'Du bist zu einem Präsenztermin eingeladen.',
+ 'Meeting created and invite sent': 'Termin erstellt und Einladung gesendet',
+ 'Failed to create meeting': 'Termin konnte nicht erstellt werden',
+ 'Create and send invite': 'Erstellen und Einladung senden',
+ 'Creating…': 'Wird erstellt…',
+ 'In-person meeting': 'Präsenztermin',
+ Location: 'Ort',
+ 'Address, venue, or place': 'Adresse, Veranstaltungsort oder Ort',
+ Description: 'Beschreibung',
+ 'Optional notes': 'Optionale Notizen',
+ 'Create a calendar event for a real-life meetup and send kind 24 invites to each invitee.':
+ 'Kalendertermin für ein reales Treffen erstellen und an jede Person eine Kind-24-Einladung senden.',
+ 'Meeting created and {{count}} invite(s) sent': 'Termin erstellt und {{count}} Einladung(en) gesendet',
+ 'Create and send invites': 'Erstellen und Einladungen senden',
+ Title: 'Titel',
+ Start: 'Beginn',
+ End: 'Ende',
Delete: 'Löschen',
'Relay already exists': 'Relay existiert bereits',
'invalid relay URL': 'Ungültige Relay-URL',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 0be3684a..4e62f6aa 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -125,6 +125,60 @@ export default {
'Start call about this': 'Start call about this',
'Send call invite': 'Send call invite',
'Join the video call': 'Join the video call',
+ 'Schedule video call': 'Schedule video call',
+ "You're invited to a scheduled video call.": "You're invited to a scheduled video call.",
+ 'Create a calendar event and send an invite. The recipient will see the event with a join link.':
+ 'Create a calendar event and send an invite. The recipient will see the event with a join link.',
+ 'Schedule a video call': 'Schedule a video call',
+ 'Create a calendar event and send kind 24 invites to each listed invitee.':
+ 'Create a calendar event and send kind 24 invites to each listed invitee.',
+ Invitees: 'Invitees',
+ 'Paste nostr:npub1... or nostr:nprofile1... (one or more)':
+ 'Paste nostr:npub1... or nostr:nprofile1... (one or more)',
+ 'Schedule and send invites': 'Schedule and send invites',
+ 'Add at least one invitee (paste nostr:npub or nostr:nprofile links)':
+ 'Add at least one invitee (paste nostr:npub or nostr:nprofile links)',
+ 'Scheduled call created and {{count}} invite(s) sent':
+ 'Scheduled call created and {{count}} invite(s) sent',
+ 'Join video call': 'Join video call',
+ 'Scheduled video call': 'Scheduled video call',
+ 'Video call': 'Video call',
+ 'Schedule and send invite': 'Schedule and send invite',
+ 'Scheduling…': 'Scheduling…',
+ 'Please set a start time': 'Please set a start time',
+ 'End time must be after start time': 'End time must be after start time',
+ 'Failed to schedule call': 'Failed to schedule call',
+ 'Scheduled call created and invite sent': 'Scheduled call created and invite sent',
+ RSVP: 'RSVP',
+ 'RSVP: {{status}}': 'RSVP: {{status}}',
+ Accepted: 'Accepted',
+ Tentative: 'Tentative',
+ Declined: 'Declined',
+ 'You need to log in to RSVP': 'You need to log in to RSVP',
+ 'RSVP updated': 'RSVP updated',
+ 'Failed to update RSVP': 'Failed to update RSVP',
+ 'Calendar Events': 'Calendar Events',
+ 'Calendar Event': 'Calendar Event',
+ 'Schedule in-person meeting': 'Schedule in-person meeting',
+ 'Create a calendar event and send an invite. No video link — for real-life meetups, conferences, etc.':
+ 'Create a calendar event and send an invite. No video link — for real-life meetups, conferences, etc.',
+ "You're invited to an in-person meeting.": "You're invited to an in-person meeting.",
+ 'Meeting created and invite sent': 'Meeting created and invite sent',
+ 'Failed to create meeting': 'Failed to create meeting',
+ 'Create and send invite': 'Create and send invite',
+ 'Creating…': 'Creating…',
+ 'In-person meeting': 'In-person meeting',
+ Location: 'Location',
+ 'Address, venue, or place': 'Address, venue, or place',
+ Description: 'Description',
+ 'Optional notes': 'Optional notes',
+ 'Create a calendar event for a real-life meetup and send kind 24 invites to each invitee.':
+ 'Create a calendar event for a real-life meetup and send kind 24 invites to each invitee.',
+ 'Meeting created and {{count}} invite(s) sent': 'Meeting created and {{count}} invite(s) sent',
+ 'Create and send invites': 'Create and send invites',
+ Title: 'Title',
+ Start: 'Start',
+ End: 'End',
Delete: 'Delete',
'Relay already exists': 'Relay already exists',
'invalid relay URL': 'invalid relay URL',
diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts
new file mode 100644
index 00000000..92ffd8d0
--- /dev/null
+++ b/src/lib/calendar-event.ts
@@ -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
+}
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index dcfc2818..3a5d6fe6 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -421,6 +421,165 @@ export async function createPublicMessageDraftEvent(
return setDraftEventCache(baseDraft)
}
+const SECONDS_PER_DAY = 86400
+
+/**
+ * NIP-52 time-based calendar event (kind 31923) for scheduled video calls.
+ * Tags: d, title, summary, image, start, end, D, location/r, p, t (topics).
+ * Content = description (optional).
+ */
+export function createCalendarEventDraftEvent(params: {
+ d: string
+ title: string
+ start: number
+ end?: number
+ locationUrl: string
+ summary?: string
+ image?: string
+ topics?: string[]
+ content?: string
+ participants: string[]
+}): TDraftEvent {
+ const dayStart = Math.floor(params.start / SECONDS_PER_DAY)
+ const dayEnd =
+ params.end != null ? Math.floor(params.end / SECONDS_PER_DAY) : dayStart
+ const dTags: string[][] = []
+ for (let day = dayStart; day <= dayEnd; day++) {
+ dTags.push(['D', String(day)])
+ }
+ const tags: string[][] = [
+ ['d', params.d],
+ ['title', params.title],
+ ...(params.summary?.trim() ? [['summary', params.summary.trim()]] : []),
+ ...(params.image?.trim() ? [['image', params.image.trim()]] : []),
+ ['start', String(params.start)],
+ ...(params.end != null ? [['end', String(params.end)]] : []),
+ ...dTags,
+ ['r', params.locationUrl],
+ ...(params.topics ?? []).filter(Boolean).map((topic) => ['t', topic.trim()]),
+ ...params.participants.map((pubkey) => ['p', pubkey])
+ ]
+ return {
+ kind: ExtendedKind.CALENDAR_EVENT_TIME,
+ content: params.content?.trim() ?? '',
+ tags,
+ created_at: dayjs().unix()
+ }
+}
+
+/**
+ * NIP-52 date-based calendar event (kind 31922) for in-person all-day / multi-day.
+ * Tags: d, title, summary, image, start (YYYY-MM-DD), end (YYYY-MM-DD), location, r, p, t.
+ * Content = description (optional).
+ */
+export function createInPersonDateBasedCalendarEventDraftEvent(params: {
+ d: string
+ title: string
+ start: string
+ end?: string
+ location?: string
+ link?: string
+ summary?: string
+ image?: string
+ topics?: string[]
+ content?: string
+ participants: string[]
+}): TDraftEvent {
+ const tags: string[][] = [
+ ['d', params.d],
+ ['title', params.title],
+ ...(params.summary?.trim() ? [['summary', params.summary.trim()]] : []),
+ ...(params.image?.trim() ? [['image', params.image.trim()]] : []),
+ ['start', params.start],
+ ...(params.end?.trim() ? [['end', params.end]] : []),
+ ...(params.location?.trim() ? [['location', params.location.trim()]] : []),
+ ...(params.link?.trim() ? [['r', params.link.trim()]] : []),
+ ...(params.topics ?? []).filter(Boolean).map((topic) => ['t', topic.trim()]),
+ ...params.participants.map((pubkey) => ['p', pubkey])
+ ]
+ return {
+ kind: ExtendedKind.CALENDAR_EVENT_DATE,
+ content: params.content?.trim() ?? '',
+ tags,
+ created_at: dayjs().unix()
+ }
+}
+
+/**
+ * NIP-52 time-based calendar event (kind 31923) for in-person meetings.
+ * Tags: d, title, summary, image, start, end, D, optional location, optional r, p, t (topics).
+ * Content = description (optional).
+ */
+export function createInPersonCalendarEventDraftEvent(params: {
+ d: string
+ title: string
+ start: number
+ end?: number
+ location?: string
+ link?: string
+ summary?: string
+ image?: string
+ topics?: string[]
+ content?: string
+ participants: string[]
+}): TDraftEvent {
+ const dayStart = Math.floor(params.start / SECONDS_PER_DAY)
+ const dayEnd =
+ params.end != null ? Math.floor(params.end / SECONDS_PER_DAY) : dayStart
+ const dTags: string[][] = []
+ for (let day = dayStart; day <= dayEnd; day++) {
+ dTags.push(['D', String(day)])
+ }
+ const tags: string[][] = [
+ ['d', params.d],
+ ['title', params.title],
+ ...(params.summary?.trim() ? [['summary', params.summary.trim()]] : []),
+ ...(params.image?.trim() ? [['image', params.image.trim()]] : []),
+ ['start', String(params.start)],
+ ...(params.end != null ? [['end', String(params.end)]] : []),
+ ...dTags,
+ ...(params.location?.trim() ? [['location', params.location.trim()]] : []),
+ ...(params.link?.trim() ? [['r', params.link.trim()]] : []),
+ ...(params.topics ?? []).filter(Boolean).map((topic) => ['t', topic.trim()]),
+ ...params.participants.map((pubkey) => ['p', pubkey])
+ ]
+ return {
+ kind: ExtendedKind.CALENDAR_EVENT_TIME,
+ content: params.content?.trim() ?? '',
+ tags,
+ created_at: dayjs().unix()
+ }
+}
+
+/**
+ * NIP-52 calendar event RSVP (kind 31925).
+ * Tags: a (required), e (optional), d (required), status (required), p (optional), fb (optional).
+ */
+export function createCalendarRsvpDraftEvent(
+ calendarEvent: Event,
+ status: 'accepted' | 'tentative' | 'declined',
+ options: { content?: string; fb?: 'free' | 'busy' } = {}
+): TDraftEvent {
+ const coordinate = getReplaceableCoordinateFromEvent(calendarEvent)
+ const hint = client.getEventHint(calendarEvent.id)
+ const tags: string[][] = [
+ ['a', coordinate, hint ?? ''],
+ ['e', calendarEvent.id, hint ?? ''],
+ ['d', randomString(12)],
+ ['status', status],
+ ['p', calendarEvent.pubkey]
+ ]
+ if (options.fb && status !== 'declined') {
+ tags.push(['fb', options.fb])
+ }
+ return {
+ kind: ExtendedKind.CALENDAR_EVENT_RSVP,
+ content: options.content ?? '',
+ tags,
+ created_at: dayjs().unix()
+ }
+}
+
export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
return {
kind: kinds.RelayList,
diff --git a/src/lib/event.ts b/src/lib/event.ts
index f1ca7384..4e75b361 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -1,4 +1,4 @@
-import { EMBEDDED_MENTION_REGEX, ExtendedKind } from '@/constants'
+import { CALENDAR_EVENT_KINDS, EMBEDDED_MENTION_REGEX, ExtendedKind } from '@/constants'
import client from '@/services/client.service'
import { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache'
@@ -43,7 +43,11 @@ export function isReplyNoteEvent(event: Event) {
}
export function isReplaceableEvent(kind: number) {
- return kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind)
+ return (
+ kinds.isReplaceableKind(kind) ||
+ kinds.isAddressableKind(kind) ||
+ CALENDAR_EVENT_KINDS.includes(kind)
+ )
}
export function isPictureEvent(event: Event) {
diff --git a/src/lib/hivetalk.ts b/src/lib/hivetalk.ts
index 551ff5f9..c2312d3a 100644
--- a/src/lib/hivetalk.ts
+++ b/src/lib/hivetalk.ts
@@ -36,3 +36,8 @@ export function roomIdForPubkeys(pubkeyA: string, pubkeyB: string): string {
const shortB = b.slice(0, 8)
return `jumble-${shortA}-${shortB}`
}
+
+/** Room id for a scheduled call (NIP-52 calendar event); one room per event. */
+export function roomIdForScheduledCall(dTag: string): string {
+ return `jumble-cal-${dTag}`
+}
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 57ed272c..d5189c0b 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -55,6 +55,9 @@ function getEventTypeName(kind: number): string {
return 'Wiki Article'
case ExtendedKind.DISCUSSION:
return 'Discussion'
+ case ExtendedKind.CALENDAR_EVENT_TIME:
+ case ExtendedKind.CALENDAR_EVENT_DATE:
+ return 'Calendar Event'
default:
return `Event (kind ${kind})`
}
@@ -131,6 +134,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
return 'Note: Poll'
case 31987: // ExtendedKind.RELAY_REVIEW
return 'Note: Relay Review'
+ case 31922: // ExtendedKind.CALENDAR_EVENT_DATE
+ case 31923: // ExtendedKind.CALENDAR_EVENT_TIME
+ return 'Note: Calendar Event'
case 9735: // ExtendedKind.ZAP_RECEIPT
return 'Note: Zap Receipt'
case 6: // kinds.Repost
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 13597009..7576e5fb 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -233,6 +233,39 @@ class ClientService extends EventTarget {
return reportRelays
}
+ // Public messages (kind 24) and calendar RSVPs (kind 31925): only author's outboxes + each recipient's inboxes
+ if (
+ event.kind === ExtendedKind.PUBLIC_MESSAGE ||
+ event.kind === ExtendedKind.CALENDAR_EVENT_RSVP
+ ) {
+ const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[] }))
+ let authorWrite = (authorRelayList?.write ?? []).map((url) => normalizeUrl(url)).filter(Boolean) as string[]
+ if (authorWrite.length === 0) {
+ authorWrite = [...FAST_WRITE_RELAY_URLS]
+ }
+ const recipientPubkeys = Array.from(
+ new Set(
+ event.tags.filter((t) => t[0] === 'p' && t[1] && isValidPubkey(t[1])).map((t) => t[1] as string)
+ )
+ ).filter((p) => p !== event.pubkey)
+ let recipientRead: string[] = []
+ if (recipientPubkeys.length > 0) {
+ const recipientRelayLists = await this.fetchRelayLists(recipientPubkeys)
+ recipientRead = recipientRelayLists.flatMap((rl) => rl?.read ?? [])
+ recipientRead = recipientRead
+ .map((url) => normalizeUrl(url))
+ .filter((url): url is string => !!url && !isLocalNetworkUrl(url))
+ }
+ const relays = Array.from(new Set([...authorWrite, ...recipientRead]))
+ logger.debug('[DetermineTargetRelays] Public message / calendar RSVP: author outbox + recipient inboxes only', {
+ kind: event.kind,
+ relayCount: relays.length,
+ authorWriteCount: authorWrite.length,
+ recipientReadCount: recipientRead.length
+ })
+ return relays.length > 0 ? relays : [...FAST_WRITE_RELAY_URLS]
+ }
+
let relays: string[]
if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls