diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index 5f74688d..b4e369e7 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react' +import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { SettingsDialog } from '@/components/settings-dialog' import { formatRelativeTime } from '@/lib/relative-time' import { extractConferenceLink } from '@/lib/calendar-event' @@ -40,14 +41,32 @@ type RawCalendarEvent = { start?: { dateTime?: string; date?: string } end?: { dateTime?: string; date?: string } location?: string + description?: string htmlLink?: string status?: string - attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }> + creator?: CalendarPerson + organizer?: CalendarPerson + attendees?: CalendarAttendee[] conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } hangoutLink?: string conferenceLink?: string } +type CalendarPerson = { + email?: string + displayName?: string + self?: boolean +} + +type CalendarAttendee = CalendarPerson & { + responseStatus?: string + optional?: boolean +} + +type DescriptionPart = + | { type: 'text'; text: string } + | { type: 'link'; text: string; href: string } + type UpcomingEvent = { id: string summary: string @@ -55,8 +74,12 @@ type UpcomingEvent = { end: Date | null isAllDay: boolean location: string | null + description: string | null htmlLink: string | null conferenceLink: string | null + creator: CalendarPerson | null + organizer: CalendarPerson | null + attendees: CalendarAttendee[] source: string // workspace path to the calendar_sync JSON rawStart: { dateTime?: string; date?: string } | undefined rawEnd: { dateTime?: string; date?: string } | undefined @@ -125,8 +148,12 @@ function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEven end, isAllDay, location: raw.location?.trim() || null, + description: raw.description?.trim() || null, htmlLink: raw.htmlLink ?? null, conferenceLink, + creator: raw.creator ?? null, + organizer: raw.organizer ?? null, + attendees: raw.attendees ?? [], source: sourcePath, rawStart: raw.start, rawEnd: raw.end, @@ -185,6 +212,115 @@ function formatEventTimeRange(event: UpcomingEvent): string { return `${start} – ${end}` } +function formatEventDetailTime(event: UpcomingEvent): string { + if (!event.isAllDay) { + const date = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }) + return `${date}, ${formatEventTimeRange(event)}` + } + + const start = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }) + if (!event.end) return `${start}, all day` + + const exclusiveEnd = addDays(event.end, -1) + if (localDateKey(exclusiveEnd) === localDateKey(event.start)) return `${start}, all day` + + const end = exclusiveEnd.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }) + return `${start} – ${end}, all day` +} + +function personLabel(person: CalendarPerson | null | undefined): string | null { + if (!person) return null + return person.displayName?.trim() || person.email?.trim() || null +} + +function attendeeLabel(attendee: CalendarAttendee): string | null { + const label = personLabel(attendee) + if (!label) return null + if (attendee.self) return `${label} (you)` + return label +} + +function normalizeDescriptionParts(parts: DescriptionPart[]): DescriptionPart[] { + const normalized: DescriptionPart[] = [] + for (const part of parts) { + const text = part.text.replace(/\n{3,}/g, '\n\n') + if (!text) continue + const previous = normalized[normalized.length - 1] + if (previous?.type === 'text' && part.type === 'text') { + previous.text += text + } else if (part.type === 'link') { + normalized.push({ ...part, text }) + } else { + normalized.push({ type: 'text', text }) + } + } + return normalized +} + +function isSafeDescriptionHref(value: string): boolean { + try { + const url = new URL(value, window.location.href) + return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:' + } catch { + return false + } +} + +function linkifyText(value: string): DescriptionPart[] { + const parts: DescriptionPart[] = [] + const urlRe = /\bhttps?:\/\/[^\s<>"')\]]+|\bwww\.[^\s<>"')\]]+/gi + let lastIndex = 0 + for (const match of value.matchAll(urlRe)) { + const raw = match[0] + const index = match.index ?? 0 + if (index > lastIndex) parts.push({ type: 'text', text: value.slice(lastIndex, index) }) + const href = raw.startsWith('www.') ? `https://${raw}` : raw + parts.push({ type: 'link', text: raw, href }) + lastIndex = index + raw.length + } + if (lastIndex < value.length) parts.push({ type: 'text', text: value.slice(lastIndex) }) + return parts +} + +function parseDescriptionParts(value: string): DescriptionPart[] { + const withLineBreaks = value.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/\s*(p|div|li|tr|h[1-6])\s*>/gi, '\n') + if (typeof DOMParser === 'undefined') { + return normalizeDescriptionParts(linkifyText(withLineBreaks.replace(/<[^>]*>/g, '').trim())) + } + const doc = new DOMParser().parseFromString(withLineBreaks, 'text/html') + const parts: DescriptionPart[] = [] + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + parts.push(...linkifyText(node.textContent ?? '')) + return + } + if (!(node instanceof HTMLElement)) return + if (node.tagName === 'A') { + const href = node.getAttribute('href') ?? '' + const text = node.textContent?.trim() || href + if (href && isSafeDescriptionHref(href)) { + parts.push({ type: 'link', text, href }) + return + } + } + if (node.tagName === 'BR') { + parts.push({ type: 'text', text: '\n' }) + return + } + node.childNodes.forEach(visit) + if (/^(P|DIV|LI|TR|H[1-6])$/.test(node.tagName)) { + parts.push({ type: 'text', text: '\n' }) + } + } + + doc.body.childNodes.forEach(visit) + return normalizeDescriptionParts(parts).map((part, index, all) => { + if (index === 0 || index === all.length - 1) return { ...part, text: part.text.trim() } + return part + }).filter((part) => part.text.length > 0) +} + function UpcomingEvents() { const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) @@ -430,74 +566,202 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool } function UpcomingEventItem({ event }: { event: UpcomingEvent }) { - const handleOpen = useCallback(() => { - if (event.htmlLink) window.open(event.htmlLink, '_blank') - }, [event.htmlLink]) + const [open, setOpen] = useState(false) const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - handleOpen() - } - }} - title={titleAndLocation} - className={cn( - 'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer', - )} - style={{ color: 'var(--gm-text)', minHeight: 40 }} - > - - - + +
- {event.summary} - - - {formatEventTimeRange(event)} - {event.location ? · {event.location} : null} - - -
- {event.conferenceLink ? ( - triggerMeetingCapture(event, true)} - onNotesOnly={() => triggerMeetingCapture(event, false)} + - ) : ( + + + {event.summary} + + + {formatEventTimeRange(event)} + {event.location ? · {event.location} : null} + + +
+ {event.conferenceLink ? ( + triggerMeetingCapture(event, true)} + onNotesOnly={() => triggerMeetingCapture(event, false)} + /> + ) : ( + + )} +
+
+ + setOpen(false)} /> + + ) +} + +function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose: () => void }) { + const organizer = personLabel(event.organizer) ?? personLabel(event.creator) + const attendees = event.attendees.map(attendeeLabel).filter((label): label is string => Boolean(label)) + const descriptionParts = event.description ? parseDescriptionParts(event.description) : [] + const handleMeetingCapture = (openConference: boolean) => { + onClose() + triggerMeetingCapture(event, openConference) + } + + return ( + +
+ {event.htmlLink ? ( - )} + ) : null} +
+
+
+ +
+

+ {event.summary} +

+
+
+ + } value={formatEventDetailTime(event)} /> + {event.location ? } value={event.location} /> : null} + {organizer ? } value={`Organizer: ${organizer}`} /> : null} + {attendees.length > 0 ? ( + } + value={attendees.slice(0, 8).join(', ') + (attendees.length > 8 ? `, +${attendees.length - 8} more` : '')} + /> + ) : null} + + {event.conferenceLink ? ( +
+
+ ) : ( +
+ + +
+ )} + + {descriptionParts.length > 0 ? ( +
+ + +
+ ) : null} +
+
+ ) +} + +function EventDetailRow({ icon, value }: { icon: React.ReactNode; value: string }) { + return ( +
+ {icon} + {value}
) }