Browse Source

add call scheduling and calendar events

imwald
Silberengel 1 month ago
parent
commit
ea09f1b146
  1. 164
      src/components/CalendarEventContent/index.tsx
  2. 104
      src/components/Embedded/EmbeddedCalendarEvent.tsx
  3. 10
      src/components/Embedded/EmbeddedNote.tsx
  4. 1
      src/components/Embedded/index.tsx
  5. 135
      src/components/InviteePicker/index.tsx
  6. 1
      src/components/KindFilter/index.tsx
  7. 5
      src/components/Note/index.tsx
  8. 4
      src/components/Profile/ProfileFeed.tsx
  9. 6
      src/components/Profile/ProfileTimeline.tsx
  10. 69
      src/components/Profile/index.tsx
  11. 58
      src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx
  12. 407
      src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx
  13. 381
      src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx
  14. 318
      src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx
  15. 292
      src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx
  16. 4
      src/components/ScheduleVideoCallDialog/index.tsx
  17. 91
      src/components/ui/DateTimePicker.tsx
  18. 174
      src/components/ui/TimePicker.tsx
  19. 20
      src/constants.ts
  20. 1
      src/hooks/index.tsx
  21. 65
      src/hooks/useFetchCalendarRsvps.tsx
  22. 54
      src/i18n/locales/de.ts
  23. 54
      src/i18n/locales/en.ts
  24. 80
      src/lib/calendar-event.ts
  25. 159
      src/lib/draft-event.ts
  26. 8
      src/lib/event.ts
  27. 5
      src/lib/hivetalk.ts
  28. 6
      src/pages/secondary/NotePage/index.tsx
  29. 33
      src/services/client.service.ts

164
src/components/CalendarEventContent/index.tsx

@ -0,0 +1,164 @@ @@ -0,0 +1,164 @@
import { createCalendarRsvpDraftEvent } from '@/lib/draft-event'
import {
getCalendarEventMeta,
formatCalendarTime,
formatCalendarDate,
isCalendarEventKind
} from '@/lib/calendar-event'
import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { Calendar, Video, CheckCircle, HelpCircle, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { toast } from 'sonner'
type RsvpStatus = 'accepted' | 'tentative' | 'declined'
export default function CalendarEventContent({
event,
className,
showRsvp = true
}: {
event: Event
className?: string
showRsvp?: boolean
}) {
const { t } = useTranslation()
const { pubkey: myPubkey, publish } = useNostr()
const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event)
if (!isCalendarEventKind(event.kind)) return null
const { title, summary, image, start, end, startDate, endDate, isDateBased, joinUrl, topics } =
getCalendarEventMeta(event)
const description = summary || event.content?.trim() || ''
const myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined
const myStatus = myRsvp ? getStatus(myRsvp) : undefined
const handleRsvp = async (status: RsvpStatus) => {
if (!myPubkey) {
toast.error(t('You need to log in to RSVP'))
return
}
try {
const draft = createCalendarRsvpDraftEvent(event, status)
await publish(draft)
toast.success(t('RSVP updated'))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to update RSVP'))
}
}
return (
<div
className={cn('rounded-lg border bg-muted/40 p-3 text-sm min-w-0', className)}
data-calendar-event-content
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-2 mb-2">
{image ? (
<img
src={image}
alt=""
className="size-12 shrink-0 rounded object-cover"
/>
) : (
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<span className="font-medium text-foreground truncate block">
{title || t('Scheduled video call')}
</span>
{topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
>
#{topic}
</span>
))}
</div>
)}
</div>
</div>
{isDateBased ? (
(startDate || endDate) && (
<div className="text-muted-foreground text-xs mb-2">
{startDate ? formatCalendarDate(startDate) : ''}
{endDate && endDate !== startDate && (
<> {formatCalendarDate(endDate)}</>
)}
</div>
)
) : (
start != null &&
!isNaN(start) && (
<div className="text-muted-foreground text-xs mb-2">
{formatCalendarTime(start)}
{end != null && !isNaN(end) && end > start && (
<> {formatCalendarTime(end)}</>
)}
</div>
)
)}
{description && (
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 mt-2">
{joinUrl && (
<Button variant="secondary" size="sm" className="gap-2" asChild>
<a href={joinUrl} target="_blank" rel="noopener noreferrer">
<Video className="size-4" />
{t('Join video call')}
</a>
</Button>
)}
{showRsvp && myPubkey && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={isFetching}
>
{myStatus === 'accepted' && <CheckCircle className="size-4 text-green-600" />}
{myStatus === 'tentative' && <HelpCircle className="size-4 text-amber-600" />}
{myStatus === 'declined' && <XCircle className="size-4 text-muted-foreground" />}
{myStatus
? t('RSVP: {{status}}', { status: myStatus })
: t('RSVP')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => handleRsvp('accepted')}>
<CheckCircle className="size-4 mr-2 text-green-600" />
{t('Accepted')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRsvp('tentative')}>
<HelpCircle className="size-4 mr-2 text-amber-600" />
{t('Tentative')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRsvp('declined')}>
<XCircle className="size-4 mr-2" />
{t('Declined')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}

104
src/components/Embedded/EmbeddedCalendarEvent.tsx

@ -0,0 +1,104 @@ @@ -0,0 +1,104 @@
import { ExtendedKind } from '@/constants'
import {
getCalendarEventMeta,
formatCalendarTime,
formatCalendarDate,
isCalendarEventKind
} from '@/lib/calendar-event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
import { Calendar, Video } from 'lucide-react'
export function EmbeddedCalendarEvent({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
if (!isCalendarEventKind(event.kind)) return null
const { title, summary, image, start, end, startDate, endDate, isDateBased, joinUrl, topics } =
getCalendarEventMeta(event)
const description = summary || event.content?.trim() || ''
return (
<div
className={cn(
'rounded-lg border bg-muted/40 p-3 text-sm min-w-0',
className
)}
data-embedded-calendar-event
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-2 mb-2">
{image ? (
<img
src={image}
alt=""
className="size-12 shrink-0 rounded object-cover"
/>
) : (
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<span className="font-medium text-foreground truncate block">
{title || t('Scheduled video call')}
</span>
{topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
>
#{topic}
</span>
))}
</div>
)}
</div>
</div>
{isDateBased ? (
(startDate || endDate) && (
<div className="text-muted-foreground text-xs mb-2">
{startDate ? formatCalendarDate(startDate) : ''}
{endDate && endDate !== startDate && (
<> {formatCalendarDate(endDate)}</>
)}
</div>
)
) : (
start != null &&
!isNaN(start) && (
<div className="text-muted-foreground text-xs mb-2">
{formatCalendarTime(start)}
{end != null && !isNaN(end) && end > start && (
<> {formatCalendarTime(end)}</>
)}
</div>
)
)}
{description && (
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words">
{description}
</p>
)}
{joinUrl && (
<Button
variant="secondary"
size="sm"
className="w-full gap-2 mt-1"
asChild
>
<a href={joinUrl} target="_blank" rel="noopener noreferrer">
<Video className="size-4" />
{t('Join video call')}
</a>
</Button>
)}
</div>
)
}

10
src/components/Embedded/EmbeddedNote.tsx

@ -10,6 +10,7 @@ import { Event, nip19 } from 'nostr-tools' @@ -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? @@ -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 (
<div data-embedded-note onClick={(e) => e.stopPropagation()}>
<EmbeddedCalendarEvent event={finalEvent} className={className} />
</div>
)
}
// Otherwise, render as regular embedded note
return (
<div data-embedded-note onClick={(e) => e.stopPropagation()}>

1
src/components/Embedded/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
export * from './EmbeddedCalendarEvent'
export * from './EmbeddedHashtag'
export * from './EmbeddedLNInvoice'
export * from './EmbeddedMention'

135
src/components/InviteePicker/index.tsx

@ -0,0 +1,135 @@ @@ -0,0 +1,135 @@
import { Input } from '@/components/ui/input'
import { useSearchProfiles } from '@/hooks'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
import Nip05 from '../Nip05'
const SEARCH_DEBOUNCE_MS = 300
const SEARCH_LIMIT = 10
export function InviteePicker({
value,
onChange,
placeholder,
className,
labelId,
max
}: {
value: string[]
onChange: (pubkeys: string[]) => void
placeholder?: string
className?: string
labelId?: string
/** Max number of invitees (e.g. MAX_CALENDAR_INVITEES). When reached, adding is disabled. */
max?: number
}) {
const { t } = useTranslation()
const { pubkey: myPubkey } = useNostr()
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(id)
}, [search])
const { profiles, isFetching } = useSearchProfiles(debouncedSearch, SEARCH_LIMIT)
const selectedSet = new Set(value)
const atLimit = max != null && value.length >= max
const filteredProfiles = profiles.filter((p) => !selectedSet.has(p.pubkey) && p.pubkey !== myPubkey)
const addInvitee = useCallback(
(pubkey: string) => {
if (pubkey === myPubkey || selectedSet.has(pubkey)) return
if (max != null && value.length >= max) return
onChange([...value, pubkey])
setSearch('')
},
[value, onChange, myPubkey, selectedSet, max]
)
const removeInvitee = useCallback(
(pubkey: string) => {
onChange(value.filter((p) => p !== pubkey))
},
[value, onChange]
)
return (
<div className={cn('space-y-2', className)}>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{value.map((pubkey) => (
<span
key={pubkey}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm"
>
<SimpleUserAvatar userId={pubkey} className="size-5 shrink-0" />
<SimpleUsername userId={pubkey} className="max-w-[120px] truncate" />
<button
type="button"
onClick={() => removeInvitee(pubkey)}
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
aria-label={t('Remove')}
>
<X className="size-3.5" />
</button>
</span>
))}
</div>
)}
<div className="relative">
<Input
id={labelId}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder ?? t('Search by name or npub…')}
className="mt-1"
autoComplete="off"
/>
{search.trim() && !atLimit && (
<div
className={cn(
'absolute left-0 right-0 top-full z-10 mt-1 max-h-60 overflow-auto rounded-md border bg-popover shadow-md'
)}
>
{isFetching && filteredProfiles.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">{t('Searching…')}</div>
) : filteredProfiles.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">{t('No users found')}</div>
) : (
<ul className="py-1">
{filteredProfiles.map((profile) => (
<li key={profile.pubkey}>
<button
type="button"
className="flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground"
onClick={() => addInvitee(profile.pubkey)}
>
<SimpleUserAvatar userId={profile.pubkey} className="size-8 shrink-0" />
<div className="min-w-0 flex-1">
<SimpleUsername userId={profile.pubkey} className="font-medium truncate" />
<Nip05 pubkey={profile.pubkey} />
</div>
</button>
</li>
))}
</ul>
)}
</div>
)}
{atLimit && (
<p className="text-xs text-muted-foreground">
{t('Maximum {{max}} invitees', { max: max ?? 0 })}
</p>
)}
</div>
</div>
)
}

1
src/components/KindFilter/index.tsx

@ -26,6 +26,7 @@ const KIND_FILTER_OPTIONS = [ @@ -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' }
]

5
src/components/Note/index.tsx

@ -42,6 +42,7 @@ import RelayReview from './RelayReview' @@ -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({ @@ -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({ @@ -214,6 +217,8 @@ export default function Note({
content = <VideoNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE) {
content = <CalendarEventContent event={event} className="mt-2" showRsvp />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = <MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {

4
src/components/Profile/ProfileFeed.tsx

@ -11,6 +11,8 @@ const POST_KIND_LIST = [ @@ -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[] @@ -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'

6
src/components/Profile/ProfileTimeline.tsx

@ -1,4 +1,5 @@ @@ -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< @@ -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(() => {

69
src/components/Profile/index.tsx

@ -32,7 +32,13 @@ import { toNoteList } from '@/lib/link' @@ -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' @@ -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 }) { @@ -165,6 +175,8 @@ export default function Profile({ id }: { id?: string }) {
const [openZapDialog, setOpenZapDialog] = useState(false)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(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 }) { @@ -458,22 +470,31 @@ export default function Profile({ id }: { id?: string }) {
}
/>
{isSelf ? (
<div className="flex gap-2">
<Button
className="rounded-full whitespace-nowrap"
variant="secondary"
onClick={() => push(toFollowPacks())}
>
{t('Browse follow packs')}
</Button>
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={() => push(toProfileEditor())}
>
{t('Edit')}
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOpenScheduleOwnCall(true)}>
<Calendar />
{t('Schedule a video call')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setOpenScheduleInPersonMeeting(true)}>
<MapPin />
{t('Schedule in-person meeting')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toFollowPacks())}>
<Link />
{t('Browse follow packs')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toProfileEditor())}>
<Pencil />
{t('Edit')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{mergedPaymentMethods.some((m) => m.type === 'lightning') && (
@ -640,6 +661,11 @@ export default function Profile({ id }: { id?: string }) { @@ -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 (
<Select value={postKindFilter} onValueChange={setPostKindFilter}>
@ -654,6 +680,7 @@ export default function Profile({ id }: { id?: string }) { @@ -654,6 +680,7 @@ export default function Profile({ id }: { id?: string }) {
<SelectItem value={String(ExtendedKind.COMMENT)}>Comments ({commentCount})</SelectItem>
<SelectItem value={String(ExtendedKind.DISCUSSION)}>Discussions ({discussionCount})</SelectItem>
<SelectItem value={String(ExtendedKind.POLL)}>Polls ({pollCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CALENDAR_EVENT_TIME)}>Calendar Events ({calendarEventCount})</SelectItem>
<SelectItem value={String(ExtendedKind.ZAP_RECEIPT)}>Superzaps ({superzapCount})</SelectItem>
</SelectContent>
</Select>
@ -811,6 +838,14 @@ export default function Profile({ id }: { id?: string }) { @@ -811,6 +838,14 @@ export default function Profile({ id }: { id?: string }) {
defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`}
/>
)}
<ScheduleVideoCallDialog
open={openScheduleOwnCall}
onOpenChange={setOpenScheduleOwnCall}
/>
<ScheduleInPersonMeetingDialog
open={openScheduleInPersonMeeting}
onOpenChange={setOpenScheduleInPersonMeeting}
/>
</>
)
}

58
src/components/ScheduleVideoCallDialog/CalendarEventPreview.tsx

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
import { EmbeddedCalendarEvent } from '@/components/Embedded/EmbeddedCalendarEvent'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TDraftEvent } from '@/types'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
/**
* Converts a draft (no id/pubkey/sig) into an event-like object for preview rendering.
*/
function draftToPreviewEvent(draft: TDraftEvent): Event {
return {
id: '',
pubkey: '',
sig: '',
kind: draft.kind,
created_at: draft.created_at,
tags: draft.tags,
content: draft.content
}
}
export function CalendarEventPreview({
draft,
className
}: {
draft: TDraftEvent
className?: string
}) {
const { t } = useTranslation()
const previewEvent = draftToPreviewEvent(draft)
const jsonString = JSON.stringify(
{ kind: draft.kind, content: draft.content, tags: draft.tags, created_at: draft.created_at },
null,
2
)
return (
<div className={cn('space-y-2', className)}>
<Tabs defaultValue="rendered" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="rendered">{t('Rendered')}</TabsTrigger>
<TabsTrigger value="json">{t('JSON')}</TabsTrigger>
</TabsList>
<TabsContent value="rendered" className="mt-2">
<div className="rounded-md border bg-muted/20 p-2">
<EmbeddedCalendarEvent event={previewEvent} />
</div>
</TabsContent>
<TabsContent value="json" className="mt-2">
<pre className="max-h-[240px] overflow-auto rounded-md border bg-muted/20 p-3 text-xs">
{jsonString}
</pre>
</TabsContent>
</Tabs>
</div>
)
}

407
src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingDialog.tsx

@ -0,0 +1,407 @@ @@ -0,0 +1,407 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { InviteePicker } from '@/components/InviteePicker'
import { DateTimePicker } from '@/components/ui/DateTimePicker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Textarea } from '@/components/ui/textarea'
import {
createInPersonCalendarEventDraftEvent,
createInPersonDateBasedCalendarEventDraftEvent,
createPublicMessageDraftEvent
} from '@/lib/draft-event'
import { MAX_CALENDAR_INVITEES } from '@/constants'
import { getNoteBech32Id } from '@/lib/event'
import { randomString } from '@/lib/random'
import { useNostr } from '@/providers/NostrProvider'
import { MapPin } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CalendarEventPreview } from './CalendarEventPreview'
function parseTopicTags(value: string): string[] {
return [
...new Set(
value
.trim()
.split(/[\s,]+/)
.map((s) => s.replace(/^#+/, '').trim())
.filter(Boolean)
)
]
}
export function ScheduleInPersonMeetingDialog({
open,
onOpenChange
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const { publish } = useNostr()
const [eventType, setEventType] = useState<'time' | 'date'>('time')
const [title, setTitle] = useState('')
const [startDatetime, setStartDatetime] = useState('')
const [endDatetime, setEndDatetime] = useState('')
const [startDateStr, setStartDateStr] = useState('')
const [endDateStr, setEndDateStr] = useState('')
const [location, setLocation] = useState('')
const [summary, setSummary] = useState('')
const [topics, setTopics] = useState('')
const [image, setImage] = useState('')
const [inviteePubkeys, setInviteePubkeys] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const formValid = useMemo(() => {
if (inviteePubkeys.length === 0 || inviteePubkeys.length > MAX_CALENDAR_INVITEES) return false
if (eventType === 'date') {
if (!startDateStr.trim()) return false
if (endDateStr.trim() && endDateStr <= startDateStr) return false
return true
}
if (!startDatetime.trim()) return false
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return false
return true
}, [eventType, startDatetime, endDatetime, startDateStr, endDateStr, inviteePubkeys])
const previewDraft = useMemo(() => {
if (!formValid) return null
const d = 'preview'
if (eventType === 'date') {
if (!startDateStr.trim()) return null
if (endDateStr.trim() && endDateStr <= startDateStr) return null
return createInPersonDateBasedCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: startDateStr,
end: endDateStr.trim() || undefined,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: inviteePubkeys
})
}
if (!startDatetime.trim()) return null
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return null
return createInPersonCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: startUnix,
end: endUnix,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: inviteePubkeys
})
}, [
eventType,
title,
startDatetime,
endDatetime,
startDateStr,
endDateStr,
location,
summary,
topics,
image,
inviteePubkeys,
t,
formValid
])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formValid) return
if (inviteePubkeys.length === 0) {
toast.error(t('Add at least one invitee'))
return
}
if (inviteePubkeys.length > MAX_CALENDAR_INVITEES) {
toast.error(t('Maximum {{max}} invitees allowed', { max: MAX_CALENDAR_INVITEES }))
return
}
if (eventType === 'date') {
if (!startDateStr.trim()) {
toast.error(t('Please set a start date'))
return
}
if (endDateStr.trim() && endDateStr <= startDateStr) {
toast.error(t('End date must be after start date'))
return
}
} else {
if (!startDatetime.trim()) {
toast.error(t('Please set a start time'))
return
}
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) {
toast.error(t('End time must be after start time'))
return
}
}
setSubmitting(true)
try {
const d = `jumble-inperson-${randomString(12)}`
const calendarDraft =
eventType === 'date'
? createInPersonDateBasedCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: startDateStr,
end: endDateStr.trim() || undefined,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: inviteePubkeys
})
: createInPersonCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: Math.floor(new Date(startDatetime).getTime() / 1000),
end: endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: inviteePubkeys
})
const calendarEvent = await publish(calendarDraft)
const naddr = getNoteBech32Id(calendarEvent)
const messageContent = `${t("You're invited to an in-person meeting.")} nostr:${naddr}`
const pmDraft = await createPublicMessageDraftEvent(
messageContent,
inviteePubkeys,
{ addClientTag: true }
)
await publish(pmDraft)
toast.success(
t('Meeting created and {{count}} invite(s) sent', {
count: inviteePubkeys.length
})
)
onOpenChange(false)
setEventType('time')
setTitle('')
setStartDatetime('')
setEndDatetime('')
setStartDateStr('')
setEndDateStr('')
setLocation('')
setSummary('')
setTopics('')
setImage('')
setInviteePubkeys([])
} catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to create meeting'))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MapPin className="size-5" />
{t('Schedule in-person meeting')}
</DialogTitle>
<DialogDescription>
{t('Required: start (or start date), invitees. Optional: title, end, location, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>{t('Event type')}</Label>
<RadioGroup
value={eventType}
onValueChange={(v) => setEventType(v as 'time' | 'date')}
className="mt-2 flex gap-4"
>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="time" id="own-inperson-type-time" />
<span className="text-sm">{t('Time-based')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="date" id="own-inperson-type-date" />
<span className="text-sm">{t('Date-based (all-day)')}</span>
</label>
</RadioGroup>
</div>
<div>
<Label htmlFor="own-inperson-title">
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-inperson-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('In-person meeting')}
className="mt-1"
/>
</div>
{eventType === 'date' ? (
<>
<div>
<Label htmlFor="own-inperson-start-date">{t('Start date')} *</Label>
<Input
id="own-inperson-start-date"
type="date"
value={startDateStr}
onChange={(e) => setStartDateStr(e.target.value)}
className="mt-1"
required={eventType === 'date'}
/>
</div>
<div>
<Label htmlFor="own-inperson-end-date">
{t('End date')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-inperson-end-date"
type="date"
value={endDateStr}
onChange={(e) => setEndDateStr(e.target.value)}
className="mt-1"
/>
</div>
</>
) : (
<>
<DateTimePicker
id="own-inperson-start"
value={startDatetime}
onChange={setStartDatetime}
label={t('Start')}
labelSuffix="*"
required={eventType === 'time'}
/>
<DateTimePicker
id="own-inperson-end"
value={endDatetime}
onChange={setEndDatetime}
label={t('End')}
labelSuffix={
<span className="text-muted-foreground font-normal">({t('optional')})</span>
}
/>
</>
)}
<div>
<Label htmlFor="own-inperson-location">
{t('Location')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-inperson-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder={t('Address, venue, or place')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="own-inperson-summary">
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Textarea
id="own-inperson-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder={t('Brief description of the event')}
className="mt-1 min-h-[60px]"
/>
</div>
<div>
<Label htmlFor="own-inperson-topics">
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-inperson-topics"
value={topics}
onChange={(e) => setTopics(e.target.value)}
placeholder={t('e.g. meetup, conference')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="own-inperson-image">
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-inperson-image"
type="url"
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder={t('Optional image for the event')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="own-inperson-invitees">{t('Invitees')} *</Label>
<InviteePicker
labelId="own-inperson-invitees"
value={inviteePubkeys}
onChange={setInviteePubkeys}
placeholder={t('Search by name or npub…')}
className="mt-1"
max={MAX_CALENDAR_INVITEES}
/>
</div>
{formValid && previewDraft && (
<div>
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={submitting || !formValid}>
{submitting ? t('Creating…') : t('Create and send invites')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

381
src/components/ScheduleVideoCallDialog/ScheduleInPersonMeetingSingleDialog.tsx

@ -0,0 +1,381 @@ @@ -0,0 +1,381 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DateTimePicker } from '@/components/ui/DateTimePicker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Textarea } from '@/components/ui/textarea'
import {
createInPersonCalendarEventDraftEvent,
createInPersonDateBasedCalendarEventDraftEvent,
createPublicMessageDraftEvent
} from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event'
import { randomString } from '@/lib/random'
import { useNostr } from '@/providers/NostrProvider'
import { MapPin } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CalendarEventPreview } from './CalendarEventPreview'
function parseTopicTags(value: string): string[] {
return [
...new Set(
value
.trim()
.split(/[\s,]+/)
.map((s) => s.replace(/^#+/, '').trim())
.filter(Boolean)
)
]
}
export function ScheduleInPersonMeetingSingleDialog({
inviteePubkey,
open,
onOpenChange
}: {
inviteePubkey: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const { publish } = useNostr()
const [eventType, setEventType] = useState<'time' | 'date'>('time')
const [title, setTitle] = useState('')
const [startDatetime, setStartDatetime] = useState('')
const [endDatetime, setEndDatetime] = useState('')
const [startDateStr, setStartDateStr] = useState('')
const [endDateStr, setEndDateStr] = useState('')
const [location, setLocation] = useState('')
const [summary, setSummary] = useState('')
const [topics, setTopics] = useState('')
const [image, setImage] = useState('')
const [submitting, setSubmitting] = useState(false)
const formValid = useMemo(() => {
if (eventType === 'date') {
if (!startDateStr.trim()) return false
if (endDateStr.trim() && endDateStr <= startDateStr) return false
return true
}
if (!startDatetime.trim()) return false
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return false
return true
}, [eventType, startDatetime, endDatetime, startDateStr, endDateStr])
const previewDraft = useMemo(() => {
if (!formValid) return null
const d = 'preview'
if (eventType === 'date') {
if (!startDateStr.trim()) return null
if (endDateStr.trim() && endDateStr <= startDateStr) return null
return createInPersonDateBasedCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: startDateStr,
end: endDateStr.trim() || undefined,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: [inviteePubkey]
})
}
if (!startDatetime.trim()) return null
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return null
return createInPersonCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: startUnix,
end: endUnix,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: [inviteePubkey]
})
}, [
eventType,
title,
startDatetime,
endDatetime,
startDateStr,
endDateStr,
location,
summary,
topics,
image,
inviteePubkey,
t,
formValid
])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formValid) return
if (eventType === 'date') {
if (!startDateStr.trim()) {
toast.error(t('Please set a start date'))
return
}
if (endDateStr.trim() && endDateStr <= startDateStr) {
toast.error(t('End date must be after start date'))
return
}
} else {
if (!startDatetime.trim()) {
toast.error(t('Please set a start time'))
return
}
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) {
toast.error(t('End time must be after start time'))
return
}
}
setSubmitting(true)
try {
const d = `jumble-inperson-${randomString(12)}`
const calendarDraft =
eventType === 'date'
? createInPersonDateBasedCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: startDateStr,
end: endDateStr.trim() || undefined,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: [inviteePubkey]
})
: createInPersonCalendarEventDraftEvent({
d,
title: title.trim() || t('In-person meeting'),
start: Math.floor(new Date(startDatetime).getTime() / 1000),
end: endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined,
location: location.trim() || undefined,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: [inviteePubkey]
})
const calendarEvent = await publish(calendarDraft)
const naddr = getNoteBech32Id(calendarEvent)
const messageContent = `${t("You're invited to an in-person meeting.")} nostr:${naddr}`
const pmDraft = await createPublicMessageDraftEvent(
messageContent,
[inviteePubkey],
{ addClientTag: true }
)
await publish(pmDraft)
toast.success(t('Meeting created and invite sent'))
onOpenChange(false)
setEventType('time')
setTitle('')
setStartDatetime('')
setEndDatetime('')
setStartDateStr('')
setEndDateStr('')
setLocation('')
setSummary('')
setTopics('')
setImage('')
} catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to create meeting'))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MapPin className="size-5" />
{t('Schedule in-person meeting')}
</DialogTitle>
<DialogDescription>
{t('Required: start time or start date. Optional: title, end, location, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>{t('Event type')}</Label>
<RadioGroup
value={eventType}
onValueChange={(v) => setEventType(v as 'time' | 'date')}
className="mt-2 flex gap-4"
>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="time" id="inperson-type-time" />
<span className="text-sm">{t('Time-based')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value="date" id="inperson-type-date" />
<span className="text-sm">{t('Date-based (all-day)')}</span>
</label>
</RadioGroup>
</div>
<div>
<Label htmlFor="inperson-title">
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="inperson-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('In-person meeting')}
className="mt-1"
/>
</div>
{eventType === 'date' ? (
<>
<div>
<Label htmlFor="inperson-start-date">{t('Start date')} *</Label>
<Input
id="inperson-start-date"
type="date"
value={startDateStr}
onChange={(e) => setStartDateStr(e.target.value)}
className="mt-1"
required={eventType === 'date'}
/>
</div>
<div>
<Label htmlFor="inperson-end-date">
{t('End date')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="inperson-end-date"
type="date"
value={endDateStr}
onChange={(e) => setEndDateStr(e.target.value)}
className="mt-1"
/>
</div>
</>
) : (
<>
<DateTimePicker
id="inperson-start"
value={startDatetime}
onChange={setStartDatetime}
label={t('Start')}
labelSuffix="*"
required={eventType === 'time'}
/>
<DateTimePicker
id="inperson-end"
value={endDatetime}
onChange={setEndDatetime}
label={t('End')}
labelSuffix={
<span className="text-muted-foreground font-normal">({t('optional')})</span>
}
/>
</>
)}
<div>
<Label htmlFor="inperson-location">
{t('Location')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="inperson-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder={t('Address, venue, or place')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="inperson-summary">
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Textarea
id="inperson-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder={t('Brief description of the event')}
className="mt-1 min-h-[60px]"
/>
</div>
<div>
<Label htmlFor="inperson-topics">
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="inperson-topics"
value={topics}
onChange={(e) => setTopics(e.target.value)}
placeholder={t('e.g. meetup, conference')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="inperson-image">
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="inperson-image"
type="url"
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder={t('Optional image for the event')}
className="mt-1"
/>
</div>
{formValid && previewDraft && (
<div>
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={submitting || !formValid}>
{submitting ? t('Creating…') : t('Create and send invite')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

318
src/components/ScheduleVideoCallDialog/ScheduleVideoCallDialog.tsx

@ -0,0 +1,318 @@ @@ -0,0 +1,318 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { InviteePicker } from '@/components/InviteePicker'
import { DateTimePicker } from '@/components/ui/DateTimePicker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
createCalendarEventDraftEvent,
createPublicMessageDraftEvent
} from '@/lib/draft-event'
import { MAX_CALENDAR_INVITEES } from '@/constants'
import { getNoteBech32Id } from '@/lib/event'
import { buildHiveTalkJoinUrl, roomIdForScheduledCall } from '@/lib/hivetalk'
import { randomString } from '@/lib/random'
import { useNostr } from '@/providers/NostrProvider'
import { Calendar } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CalendarEventPreview } from './CalendarEventPreview'
function parseTopicTags(value: string): string[] {
return [
...new Set(
value
.trim()
.split(/[\s,]+/)
.map((s) => s.replace(/^#+/, '').trim())
.filter(Boolean)
)
]
}
export function ScheduleVideoCallDialog({
open,
onOpenChange
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const { publish } = useNostr()
const [title, setTitle] = useState('')
const [startDatetime, setStartDatetime] = useState('')
const [endDatetime, setEndDatetime] = useState('')
const [locationUrl, setLocationUrl] = useState('')
const [summary, setSummary] = useState('')
const [topics, setTopics] = useState('')
const [image, setImage] = useState('')
const [inviteePubkeys, setInviteePubkeys] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const formValid = useMemo(() => {
if (!startDatetime.trim()) return false
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return false
if (inviteePubkeys.length === 0 || inviteePubkeys.length > MAX_CALENDAR_INVITEES) return false
return true
}, [startDatetime, endDatetime, inviteePubkeys])
const previewDraft = useMemo(() => {
if (!formValid) return null
if (!startDatetime.trim()) return null
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return null
const d = 'preview'
const roomId = roomIdForScheduledCall(d)
const defaultJoinUrl = buildHiveTalkJoinUrl({ room: roomId, name: 'Guest' })
const joinUrl = locationUrl.trim() || defaultJoinUrl
return createCalendarEventDraftEvent({
d,
title: title.trim() || t('Video call'),
start: startUnix,
end: endUnix,
locationUrl: joinUrl,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: inviteePubkeys
})
}, [
title,
startDatetime,
endDatetime,
locationUrl,
summary,
topics,
image,
inviteePubkeys,
t,
formValid
])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formValid) return
if (!startDatetime.trim()) {
toast.error(t('Please set a start time'))
return
}
if (inviteePubkeys.length === 0) {
toast.error(t('Add at least one invitee'))
return
}
if (inviteePubkeys.length > MAX_CALENDAR_INVITEES) {
toast.error(t('Maximum {{max}} invitees allowed', { max: MAX_CALENDAR_INVITEES }))
return
}
setSubmitting(true)
try {
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) {
toast.error(t('End time must be after start time'))
setSubmitting(false)
return
}
const d = `jumble-cal-${randomString(12)}`
const roomId = roomIdForScheduledCall(d)
const defaultJoinUrl = buildHiveTalkJoinUrl({
room: roomId,
name: 'Guest'
})
const joinUrl = locationUrl.trim() || defaultJoinUrl
const calendarDraft = createCalendarEventDraftEvent({
d,
title: title.trim() || t('Video call'),
start: startUnix,
end: endUnix,
locationUrl: joinUrl,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: inviteePubkeys
})
const calendarEvent = await publish(calendarDraft)
const naddr = getNoteBech32Id(calendarEvent)
const messageContent = `${t("You're invited to a scheduled video call.")} nostr:${naddr}`
const pmDraft = await createPublicMessageDraftEvent(
messageContent,
inviteePubkeys,
{ addClientTag: true }
)
await publish(pmDraft)
toast.success(
t('Scheduled call created and {{count}} invite(s) sent', {
count: inviteePubkeys.length
})
)
onOpenChange(false)
setTitle('')
setStartDatetime('')
setEndDatetime('')
setLocationUrl('')
setSummary('')
setTopics('')
setImage('')
setInviteePubkeys([])
} catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to schedule call'))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="size-5" />
{t('Schedule a video call')}
</DialogTitle>
<DialogDescription>
{t('Required: start time, invitees. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="own-call-title">
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-call-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('Video call')}
className="mt-1"
/>
</div>
<DateTimePicker
id="own-call-start"
value={startDatetime}
onChange={setStartDatetime}
label={t('Start')}
labelSuffix="*"
required
/>
<DateTimePicker
id="own-call-end"
value={endDatetime}
onChange={setEndDatetime}
label={t('End')}
labelSuffix={
<span className="text-muted-foreground font-normal">({t('optional')})</span>
}
/>
<div>
<Label htmlFor="own-call-location">
{t('Join link')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-call-location"
type="url"
value={locationUrl}
onChange={(e) => setLocationUrl(e.target.value)}
placeholder={t('Leave empty for HiveTalk, or paste Zoom / Teams / other link')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="own-call-summary">
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Textarea
id="own-call-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder={t('Brief description of the event')}
className="mt-1 min-h-[60px]"
/>
</div>
<div>
<Label htmlFor="own-call-topics">
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-call-topics"
value={topics}
onChange={(e) => setTopics(e.target.value)}
placeholder={t('e.g. meetup, conference')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="own-call-image">
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="own-call-image"
type="url"
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder={t('Optional image for the event')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="own-call-invitees">{t('Invitees')} *</Label>
<InviteePicker
labelId="own-call-invitees"
value={inviteePubkeys}
onChange={setInviteePubkeys}
placeholder={t('Search by name or npub…')}
className="mt-1"
max={MAX_CALENDAR_INVITEES}
/>
</div>
{formValid && previewDraft && (
<div>
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={submitting || !formValid}>
{submitting ? t('Scheduling…') : t('Schedule and send invites')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

292
src/components/ScheduleVideoCallDialog/ScheduleVideoCallSingleDialog.tsx

@ -0,0 +1,292 @@ @@ -0,0 +1,292 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DateTimePicker } from '@/components/ui/DateTimePicker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
createCalendarEventDraftEvent,
createPublicMessageDraftEvent
} from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event'
import { buildHiveTalkJoinUrl, roomIdForScheduledCall } from '@/lib/hivetalk'
import { randomString } from '@/lib/random'
import { useNostr } from '@/providers/NostrProvider'
import { Calendar } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CalendarEventPreview } from './CalendarEventPreview'
function parseTopicTags(value: string): string[] {
return [
...new Set(
value
.trim()
.split(/[\s,]+/)
.map((s) => s.replace(/^#+/, '').trim())
.filter(Boolean)
)
]
}
export function ScheduleVideoCallSingleDialog({
inviteePubkey,
open,
onOpenChange
}: {
inviteePubkey: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const { publish } = useNostr()
const joinAsName = 'Guest'
const [title, setTitle] = useState('')
const [startDatetime, setStartDatetime] = useState('')
const [endDatetime, setEndDatetime] = useState('')
const [locationUrl, setLocationUrl] = useState('')
const [summary, setSummary] = useState('')
const [topics, setTopics] = useState('')
const [image, setImage] = useState('')
const [submitting, setSubmitting] = useState(false)
const formValid = useMemo(() => {
if (!startDatetime.trim()) return false
const startUnix = Math.floor(new Date(startDatetime).getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return false
return true
}, [startDatetime, endDatetime])
const previewDraft = useMemo(() => {
if (!formValid) return null
if (!startDatetime.trim()) return null
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) return null
const d = 'preview'
const roomId = roomIdForScheduledCall(d)
const defaultJoinUrl = buildHiveTalkJoinUrl({ room: roomId, name: joinAsName })
const joinUrl = locationUrl.trim() || defaultJoinUrl
return createCalendarEventDraftEvent({
d,
title: title.trim() || t('Video call'),
start: startUnix,
end: endUnix,
locationUrl: joinUrl,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: [inviteePubkey]
})
}, [
title,
startDatetime,
endDatetime,
locationUrl,
summary,
topics,
image,
inviteePubkey,
joinAsName,
t,
formValid
])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formValid) return
if (!startDatetime.trim()) {
toast.error(t('Please set a start time'))
return
}
setSubmitting(true)
try {
const startDate = new Date(startDatetime)
const startUnix = Math.floor(startDate.getTime() / 1000)
const endUnix = endDatetime.trim()
? Math.floor(new Date(endDatetime).getTime() / 1000)
: undefined
if (endUnix != null && endUnix <= startUnix) {
toast.error(t('End time must be after start time'))
setSubmitting(false)
return
}
const d = `jumble-cal-${randomString(12)}`
const roomId = roomIdForScheduledCall(d)
const defaultJoinUrl = buildHiveTalkJoinUrl({
room: roomId,
name: joinAsName
})
const joinUrl = locationUrl.trim() || defaultJoinUrl
const calendarDraft = createCalendarEventDraftEvent({
d,
title: title.trim() || t('Video call'),
start: startUnix,
end: endUnix,
locationUrl: joinUrl,
summary: summary.trim() || undefined,
image: image.trim() || undefined,
topics: parseTopicTags(topics),
participants: [inviteePubkey]
})
const calendarEvent = await publish(calendarDraft)
const naddr = getNoteBech32Id(calendarEvent)
const messageContent = `${t("You're invited to a scheduled video call.")} nostr:${naddr}`
const pmDraft = await createPublicMessageDraftEvent(
messageContent,
[inviteePubkey],
{ addClientTag: true }
)
await publish(pmDraft)
toast.success(t('Scheduled call created and invite sent'))
onOpenChange(false)
setTitle('')
setStartDatetime('')
setEndDatetime('')
setLocationUrl('')
setSummary('')
setTopics('')
setImage('')
} catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to schedule call'))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="size-5" />
{t('Schedule video call')}
</DialogTitle>
<DialogDescription>
{t('Required: start time. Join link defaults to HiveTalk. Optional: title, end, summary, topics, image.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="schedule-call-title">
{t('Title')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="schedule-call-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('Video call')}
className="mt-1"
/>
</div>
<DateTimePicker
id="schedule-call-start"
value={startDatetime}
onChange={setStartDatetime}
label={t('Start')}
labelSuffix="*"
required
/>
<DateTimePicker
id="schedule-call-end"
value={endDatetime}
onChange={setEndDatetime}
label={t('End')}
labelSuffix={
<span className="text-muted-foreground font-normal">({t('optional')})</span>
}
/>
<div>
<Label htmlFor="schedule-call-location">
{t('Join link')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="schedule-call-location"
type="url"
value={locationUrl}
onChange={(e) => setLocationUrl(e.target.value)}
placeholder={t('Leave empty for HiveTalk, or paste Zoom / Teams / other link')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="schedule-call-summary">
{t('Summary')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Textarea
id="schedule-call-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder={t('Brief description of the event')}
className="mt-1 min-h-[60px]"
/>
</div>
<div>
<Label htmlFor="schedule-call-topics">
{t('Topics')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="schedule-call-topics"
value={topics}
onChange={(e) => setTopics(e.target.value)}
placeholder={t('e.g. meetup, conference')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="schedule-call-image">
{t('Image URL')} <span className="text-muted-foreground font-normal">({t('optional')})</span>
</Label>
<Input
id="schedule-call-image"
type="url"
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder={t('Optional image for the event')}
className="mt-1"
/>
</div>
{formValid && previewDraft && (
<div>
<Label className="mb-1 block">{t('Preview')}</Label>
<CalendarEventPreview draft={previewDraft} />
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={submitting || !formValid}>
{submitting ? t('Scheduling…') : t('Schedule and send invite')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

4
src/components/ScheduleVideoCallDialog/index.tsx

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
export { ScheduleVideoCallDialog } from './ScheduleVideoCallDialog'
export { ScheduleVideoCallSingleDialog } from './ScheduleVideoCallSingleDialog'
export { ScheduleInPersonMeetingDialog } from './ScheduleInPersonMeetingDialog'
export { ScheduleInPersonMeetingSingleDialog } from './ScheduleInPersonMeetingSingleDialog'

91
src/components/ui/DateTimePicker.tsx

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
import * as React from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { TimePicker } from '@/components/ui/TimePicker'
import { cn } from '@/lib/utils'
/** Value is "YYYY-MM-DDTHH:mm" (same as datetime-local). */
export interface DateTimePickerProps {
value: string
onChange: (value: string) => void
id?: string
label?: React.ReactNode
/** Start * or (optional) etc. */
labelSuffix?: React.ReactNode
required?: boolean
className?: string
disabled?: boolean
}
function datePart(dt: string): string {
if (!dt || dt.length < 10) return ''
return dt.slice(0, 10)
}
function timePart(dt: string): string {
if (!dt || dt.length < 16) return '09:00'
return dt.slice(11, 16)
}
export function DateTimePicker({
value,
onChange,
id,
label,
labelSuffix,
required,
className,
disabled
}: DateTimePickerProps) {
const date = datePart(value)
const time = timePart(value)
const handleDateChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const d = e.target.value
onChange(d ? `${d}T${time}` : '')
},
[time, onChange]
)
const handleTimeChange = React.useCallback(
(t: string) => {
if (!date) {
const today = new Date().toISOString().slice(0, 10)
onChange(`${today}T${t}`)
} else {
onChange(`${date}T${t}`)
}
},
[date, onChange]
)
return (
<div className={cn('space-y-2', className)}>
{label != null && (
<Label htmlFor={id}>
{label} {labelSuffix}
</Label>
)}
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[140px]">
<Input
id={id}
type="date"
value={date}
onChange={handleDateChange}
required={required}
disabled={disabled}
className="h-9"
/>
</div>
<TimePicker
value={time}
onChange={handleTimeChange}
disabled={disabled}
id={id ? `${id}-time` : undefined}
/>
</div>
</div>
)
}

174
src/components/ui/TimePicker.tsx

@ -0,0 +1,174 @@ @@ -0,0 +1,174 @@
import * as React from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
/** Value is always 24-hour "HH:mm" */
export interface TimePickerProps {
value: string
onChange: (value: string) => void
/** When true, show 12-hour with AM/PM; when false, show 24-hour. Default from locale (en-US -> 12h). */
hour12?: boolean
onHour12Change?: (hour12: boolean) => void
className?: string
id?: string
disabled?: boolean
}
function parseHHmm(value: string): { hour: number; minute: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
if (!match) return { hour: 0, minute: 0 }
const hour = Math.min(23, Math.max(0, parseInt(match[1]!, 10)))
const minute = Math.min(59, Math.max(0, parseInt(match[2]!, 10)))
return { hour, minute }
}
function toHHmm(hour: number, minute: number): string {
const h = Math.min(23, Math.max(0, hour))
const m = Math.min(59, Math.max(0, minute))
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
/** 24h hour (0-23) to 12h display: { displayHour 1-12, pm: boolean } */
function to12h(hour24: number): { displayHour: number; pm: boolean } {
if (hour24 === 0) return { displayHour: 12, pm: false }
if (hour24 < 12) return { displayHour: hour24, pm: false }
if (hour24 === 12) return { displayHour: 12, pm: true }
return { displayHour: hour24 - 12, pm: true }
}
/** 12h + AM/PM to 24h hour (0-23) */
function to24h(displayHour: number, pm: boolean): number {
if (pm) return displayHour === 12 ? 12 : displayHour + 12
return displayHour === 12 ? 0 : displayHour
}
const MINUTES = Array.from({ length: 60 }, (_, i) => i)
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i)
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1)
/** Default: use 12h for en-US, 24h otherwise */
function defaultHour12(): boolean {
try {
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en-US'
return lang.startsWith('en-US')
} catch {
return false
}
}
export function TimePicker({
value,
onChange,
hour12: controlledHour12,
onHour12Change,
className,
id,
disabled
}: TimePickerProps) {
const { t } = useTranslation()
const [internalHour12, setInternalHour12] = React.useState(defaultHour12)
const hour12 = controlledHour12 ?? internalHour12
const setHour12 = React.useCallback(
(v: boolean) => {
if (onHour12Change) onHour12Change(v)
else setInternalHour12(v)
},
[onHour12Change]
)
const { hour: hour24, minute } = parseHHmm(value)
const { displayHour: hour12Val, pm } = to12h(hour24)
const handleMinuteChange = React.useCallback(
(m: number) => {
onChange(toHHmm(hour24, m))
},
[hour24, onChange]
)
const handleHourChange = React.useCallback(
(newHour: number) => {
if (hour12) {
const new24 = to24h(newHour, pm)
onChange(toHHmm(new24, minute))
} else {
onChange(toHHmm(newHour, minute))
}
},
[hour12, minute, pm, onChange]
)
const handleAmPmChange = React.useCallback(
(newPm: boolean) => {
const new24 = to24h(hour12Val, newPm)
onChange(toHHmm(new24, minute))
},
[hour12Val, minute, onChange]
)
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
<div className="flex items-center gap-1">
<Select
value={hour12 ? String(hour12Val) : String(hour24)}
onValueChange={(v) => handleHourChange(parseInt(v, 10))}
disabled={disabled}
>
<SelectTrigger id={id} className="w-[72px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(hour12 ? HOURS_12 : HOURS_24).map((h) => (
<SelectItem key={h} value={String(h)}>
{hour12 ? h : String(h).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground">:</span>
<Select
value={String(minute)}
onValueChange={(v) => handleMinuteChange(parseInt(v, 10))}
disabled={disabled}
>
<SelectTrigger className="w-[72px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={String(m)}>
{String(m).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{hour12 && (
<Select value={pm ? 'pm' : 'am'} onValueChange={(v) => handleAmPmChange(v === 'pm')} disabled={disabled}>
<SelectTrigger className="w-[72px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="am">{t('AM')}</SelectItem>
<SelectItem value="pm">{t('PM')}</SelectItem>
</SelectContent>
</Select>
)}
<button
type="button"
onClick={() => setHour12(!hour12)}
className="text-xs text-muted-foreground hover:text-foreground underline"
disabled={disabled}
>
{hour12 ? t('24-hour') : t('12-hour (AM/PM)')}
</button>
</div>
)
}

20
src/constants.ts

@ -136,7 +136,6 @@ export const GIF_RELAY_URLS = [ @@ -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 = { @@ -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 = [ @@ -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,

1
src/hooks/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
export * from './useFetchCalendarRsvps'
export * from './useFetchEvent'
export * from './useFetchFollowings'
export * from './useFetchNip05'

65
src/hooks/useFetchCalendarRsvps.tsx

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent } from '@/lib/event'
import { isCalendarEventKind } from '@/lib/calendar-event'
import client from '@/services/client.service'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { normalizeUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined {
const status = rsvp.tags.find(tagNameEquals('status'))?.[1]
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status
return undefined
}
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const { relayList } = useNostr()
const [rsvps, setRsvps] = useState<Event[]>([])
const [isFetching, setIsFetching] = useState(false)
useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) {
setRsvps([])
return
}
let cancelled = false
setIsFetching(true)
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent)
const userRead = relayList?.read ?? []
const relayUrls = Array.from(
new Set([
...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url),
...userRead.map((url) => normalizeUrl(url) || url)
])
).filter(Boolean) as string[]
client
.fetchEvents(relayUrls, {
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
})
.then((events) => {
if (cancelled) return
setRsvps(events)
})
.finally(() => {
if (!cancelled) setIsFetching(false)
})
return () => {
cancelled = true
}
}, [calendarEvent?.id, calendarEvent?.kind, relayList?.read])
return {
rsvps,
isFetching,
getRsvpStatus
}
}

54
src/i18n/locales/de.ts

@ -71,6 +71,60 @@ export default { @@ -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',

54
src/i18n/locales/en.ts

@ -125,6 +125,60 @@ export default { @@ -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',

80
src/lib/calendar-event.ts

@ -0,0 +1,80 @@ @@ -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
}

159
src/lib/draft-event.ts

@ -421,6 +421,165 @@ export async function createPublicMessageDraftEvent( @@ -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,

8
src/lib/event.ts

@ -1,4 +1,4 @@ @@ -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) { @@ -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) {

5
src/lib/hivetalk.ts

@ -36,3 +36,8 @@ export function roomIdForPubkeys(pubkeyA: string, pubkeyB: string): string { @@ -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}`
}

6
src/pages/secondary/NotePage/index.tsx

@ -55,6 +55,9 @@ function getEventTypeName(kind: number): string { @@ -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; @@ -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

33
src/services/client.service.ts

@ -233,6 +233,39 @@ class ClientService extends EventTarget { @@ -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

Loading…
Cancel
Save