diff --git a/apps/desktop2/src/components/interactive-button.tsx b/apps/desktop2/src/components/interactive-button.tsx index 1bff72cc8..e5834ba1e 100644 --- a/apps/desktop2/src/components/interactive-button.tsx +++ b/apps/desktop2/src/components/interactive-button.tsx @@ -5,6 +5,7 @@ interface InteractiveButtonProps { children: ReactNode; onClick?: () => void; onCmdClick?: () => void; + onMouseDown?: (e: MouseEvent) => void; contextMenu?: ReactNode; className?: string; disabled?: boolean; @@ -15,6 +16,7 @@ export function InteractiveButton({ children, onClick, onCmdClick, + onMouseDown, contextMenu, className, disabled, @@ -40,7 +42,12 @@ export function InteractiveButton({ if (!contextMenu) { return ( - + {children} ); @@ -49,7 +56,12 @@ export function InteractiveButton({ return ( - + {children} diff --git a/apps/desktop2/src/components/main/body/index.tsx b/apps/desktop2/src/components/main/body/index.tsx index e04271ce1..fa980d17a 100644 --- a/apps/desktop2/src/components/main/body/index.tsx +++ b/apps/desktop2/src/components/main/body/index.tsx @@ -1,3 +1,6 @@ +import { Button } from "@hypr/ui/components/ui/button"; +import { cn } from "@hypr/ui/lib/utils"; + import { useRouteContext } from "@tanstack/react-router"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { ArrowLeftIcon, ArrowRightIcon, PanelLeftOpenIcon, PlusIcon } from "lucide-react"; @@ -5,7 +8,6 @@ import { Reorder } from "motion/react"; import { useCallback, useEffect, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { cn } from "@hypr/ui/lib/utils"; import { useShell } from "../../../contexts/shell"; import { type Tab, uniqueIdfromTab, useTabs } from "../../../store/zustand/tabs"; import { id } from "../../../utils"; @@ -23,6 +25,7 @@ export function Body() { useTabCloseHotkey(); useTabSelectHotkeys(); + useNewTabHotkeys(); if (!currentTab) { return null; @@ -62,62 +65,35 @@ function Header({ tabs }: { tabs: Tab[] }) { return (
{!leftsidebar.expanded && ( -
+
+ )}
- - + + +
- + - + +
); } @@ -303,8 +278,8 @@ export function StandardTabWrapper( { children, afterBorder }: { children: React.ReactNode; afterBorder?: React.ReactNode }, ) { return ( -
-
+
+
{children}
@@ -328,7 +303,7 @@ const useTabCloseHotkey = () => { await appWindow.close(); } }, - { enableOnFormTags: true }, + { enableOnFormTags: true, enableOnContentEditable: true }, [tabs, currentTab, close], ); }; @@ -353,11 +328,41 @@ const useTabSelectHotkeys = () => { event.preventDefault(); select(target); }, - { enableOnFormTags: true }, + { enableOnFormTags: true, enableOnContentEditable: true }, [tabs, select], ); }; +const useNewTabHotkeys = () => { + const { persistedStore, internalStore } = useRouteContext({ from: "__root__" }); + const { currentTab, close, openNew } = useTabs(); + + useHotkeys( + ["mod+n", "mod+t"], + (e) => { + e.preventDefault(); + + const sessionId = id(); + const user_id = internalStore?.getValue("user_id"); + + persistedStore?.setRow("sessions", sessionId, { user_id, created_at: new Date().toISOString() }); + + if (e.key === "n" && currentTab) { + close(currentTab); + } + + openNew({ + type: "sessions", + id: sessionId, + active: true, + state: { editor: "raw" }, + }); + }, + { enableOnFormTags: true, enableOnContentEditable: true }, + [persistedStore, internalStore, currentTab, close, openNew], + ); +}; + function useScrollActiveTabIntoView(tabs: Tab[]) { const tabRefsMap = useRef>(new Map()); diff --git a/apps/desktop2/src/components/main/body/sessions/index.tsx b/apps/desktop2/src/components/main/body/sessions/index.tsx index d901ad198..38152dbde 100644 --- a/apps/desktop2/src/components/main/body/sessions/index.tsx +++ b/apps/desktop2/src/components/main/body/sessions/index.tsx @@ -38,12 +38,14 @@ export function TabContentNote({ tab }: { tab: Extract }> -
- + +
+ +
+ +
+
- - - ); diff --git a/apps/desktop2/src/components/main/body/sessions/inner-header.tsx b/apps/desktop2/src/components/main/body/sessions/inner-header.tsx new file mode 100644 index 000000000..e30f61508 --- /dev/null +++ b/apps/desktop2/src/components/main/body/sessions/inner-header.tsx @@ -0,0 +1,102 @@ +import { useEffect } from "react"; + +import { cn } from "@hypr/ui/lib/utils"; +import { type Tab, useTabs } from "../../../../store/zustand/tabs"; + +interface TabHeaderProps { + tab: Tab; + onVisibilityChange?: (isVisible: boolean) => void; + isCurrentlyRecording: boolean; + shouldShowTab: boolean; + shouldShowEnhancedTab: boolean; +} + +export const InnerHeader = ({ + tab, + onVisibilityChange, + isCurrentlyRecording, + shouldShowTab, + shouldShowEnhancedTab, +}: TabHeaderProps) => { + const { updateSessionTabState } = useTabs(); + + const currentTab = tab.type === "sessions" ? (tab.state.editor ?? "raw") : "raw"; + + const handleTabChange = (view: "raw" | "enhanced" | "transcript") => { + updateSessionTabState(tab, { editor: view }); + }; + + // set default tab to 'raw' for blank notes (no meeting session) + useEffect(() => { + if (!shouldShowTab && tab.type === "sessions") { + updateSessionTabState(tab, { editor: "raw" }); + } + }, [shouldShowTab, tab, updateSessionTabState]); + + // notify parent when visibility changes + useEffect(() => { + if (onVisibilityChange) { + onVisibilityChange(shouldShowTab ?? false); + } + }, [shouldShowTab, onVisibilityChange]); + + // don't render tabs at all for blank notes (no meeting session) + if (!shouldShowTab) { + return null; + } + + return ( +
+
+
+
+ {shouldShowEnhancedTab && ( + + )} + + + + +
+
+
+
+ ); +}; diff --git a/apps/desktop2/src/components/main/body/sessions/note-input/index.tsx b/apps/desktop2/src/components/main/body/sessions/note-input/index.tsx index ef6c367b1..dadc9712d 100644 --- a/apps/desktop2/src/components/main/body/sessions/note-input/index.tsx +++ b/apps/desktop2/src/components/main/body/sessions/note-input/index.tsx @@ -53,23 +53,21 @@ function Header( } return ( -
-
- {editorTabs.map((view) => ( - - ))} -
+
+ {editorTabs.map((view) => ( + + ))}
); } diff --git a/apps/desktop2/src/components/main/body/sessions/note-input/raw.tsx b/apps/desktop2/src/components/main/body/sessions/note-input/raw.tsx index 59ce2dfcf..c94bb517e 100644 --- a/apps/desktop2/src/components/main/body/sessions/note-input/raw.tsx +++ b/apps/desktop2/src/components/main/body/sessions/note-input/raw.tsx @@ -65,14 +65,14 @@ const PlaceHolderInner = () => { return (
- + Take notes or press / for commands.
- You can also upload/drop an + You can also upload/drop an + + + + + )} /> - / - + + + + + + ); } @@ -63,28 +85,65 @@ function RenderIfRootNotExist( { title, handleChangeTitle, + sessionId, }: { title: string; handleChangeTitle: (title: string) => void; + sessionId: string; }, ) { + const folderIds = persisted.UI.useRowIds("folders", persisted.STORE_ID); + + const handleSelectFolder = persisted.UI.useSetPartialRowCallback( + "sessions", + sessionId, + (folderId: string) => ({ folder_id: folderId }), + [], + persisted.STORE_ID, + ); + return ( <> - - / - + + + + Select folder + + + {folderIds?.map((id) => handleSelectFolder(id)} />)} + {(!folderIds || folderIds.length === 0) && ( + No folders available + )} + + + + + + + + + ); } +function FolderMenuItem({ folderId, onSelect }: { folderId: string; onSelect: () => void }) { + const name = persisted.UI.useCell("folders", folderId, "name", persisted.STORE_ID); + + return ( + + + {name ?? "Untitled"} + + ); +} + function TitleInput({ title, handleChangeTitle }: { title: string; handleChangeTitle: (title: string) => void }) { return ( handleChangeTitle(e.target.value)} /> diff --git a/apps/desktop2/src/components/main/body/sessions/outer-header/index.tsx b/apps/desktop2/src/components/main/body/sessions/outer-header/index.tsx index c7351f1ff..6b66a8cf4 100644 --- a/apps/desktop2/src/components/main/body/sessions/outer-header/index.tsx +++ b/apps/desktop2/src/components/main/body/sessions/outer-header/index.tsx @@ -1,9 +1,8 @@ import * as internal from "../../../../../store/tinybase/internal"; -import { SessionEvent } from "./event"; import { FolderChain } from "./folder"; +import { SessionMetadata } from "./metadata"; import { OthersButton } from "./other"; -import { SessionParticipants } from "./participant"; import { ShareButton } from "./share"; export function OuterHeader({ sessionId }: { sessionId: string }) { @@ -14,8 +13,7 @@ export function OuterHeader({ sessionId }: { sessionId: string }) {
- - diff --git a/apps/desktop2/src/components/main/body/sessions/outer-header/metadata.tsx b/apps/desktop2/src/components/main/body/sessions/outer-header/metadata.tsx new file mode 100644 index 000000000..9a2ece97a --- /dev/null +++ b/apps/desktop2/src/components/main/body/sessions/outer-header/metadata.tsx @@ -0,0 +1,223 @@ +import { + MeetingMetadata, + MeetingMetadataChip, + MeetingParticipant, +} from "@hypr/ui/components/block/meeting-metadata-chip"; + +import { useCallback, useMemo, useState } from "react"; + +import { useQuery } from "../../../../../hooks/useQuery"; +import * as internal from "../../../../../store/tinybase/internal"; +import * as persisted from "../../../../../store/tinybase/persisted"; +import { useTabs } from "../../../../../store/zustand/tabs"; + +export function SessionMetadata({ + sessionId, + currentUserId, +}: { + sessionId: string; + currentUserId: string | undefined; +}) { + const [participantSearchQuery, setParticipantSearchQuery] = useState(""); + const { openNew } = useTabs(); + const { user_id } = internal.UI.useValues(internal.STORE_ID); + + const store = persisted.UI.useStore(persisted.STORE_ID); + const indexes = persisted.UI.useIndexes(persisted.STORE_ID); + + const sessionRow = persisted.UI.useRow("sessions", sessionId, persisted.STORE_ID); + + const eventRow = persisted.UI.useRow( + "events", + sessionRow.event_id || "dummy-event-id", + persisted.STORE_ID, + ); + + const participantMappingIds = persisted.UI.useSliceRowIds( + persisted.INDEXES.sessionParticipantsBySession, + sessionId, + persisted.STORE_ID, + ); + + const meetingMetadata: MeetingMetadata | null = useMemo(() => { + if (!sessionRow.event_id || !eventRow || !eventRow.started_at || !eventRow.ended_at) { + return null; + } + + const participants: MeetingParticipant[] = []; + if (store && participantMappingIds) { + participantMappingIds.forEach((mappingId) => { + const humanId = store.getCell("mapping_session_participant", mappingId, "human_id") as + | string + | undefined; + if (humanId) { + const humanRow = store.getRow("humans", humanId); + if (humanRow) { + const orgId = humanRow.org_id as string | undefined; + const org = orgId ? store.getRow("organizations", orgId) : null; + + participants.push({ + id: humanId, + full_name: humanRow.name as string | null, + email: humanRow.email as string | null, + job_title: humanRow.job_title as string | null, + linkedin_username: humanRow.linkedin_username as string | null, + organization: org && orgId + ? { id: orgId, name: org.name as string } + : null, + }); + } + } + }); + } + + return { + id: sessionRow.event_id, + title: eventRow.title ?? "Untitled Event", + started_at: eventRow.started_at, + ended_at: eventRow.ended_at, + location: null, // TODO: Add location field to event schema + meeting_link: null, // TODO: Add meeting_link field to event schema + description: null, // TODO: Add description field to event schema + participants, + }; + }, [sessionRow.event_id, eventRow, store, participantMappingIds]); + + const participantSearch = useQuery({ + enabled: !!store && !!indexes && !!participantSearchQuery.trim(), + deps: [store, indexes, participantSearchQuery, sessionId] as const, + queryFn: async (store, indexes, query, sessionId) => { + const results: MeetingParticipant[] = []; + const existingParticipantIds = new Set(); + + const participantMappings = indexes!.getSliceRowIds( + persisted.INDEXES.sessionParticipantsBySession, + sessionId, + ); + participantMappings?.forEach((mappingId: string) => { + const humanId = store!.getCell( + "mapping_session_participant", + mappingId, + "human_id", + ) as string | undefined; + if (humanId) { + existingParticipantIds.add(humanId); + } + }); + + const normalizedQuery = query.toLowerCase(); + + store!.forEachRow("humans", (rowId, forEachCell) => { + if (existingParticipantIds.has(rowId)) { + return; + } + + let name: string | undefined; + let email: string | undefined; + let job_title: string | undefined; + let linkedin_username: string | undefined; + let org_id: string | undefined; + + forEachCell((cellId, cell) => { + if (cellId === "name") { + name = cell as string; + } else if (cellId === "email") { + email = cell as string; + } else if (cellId === "job_title") { + job_title = cell as string; + } else if (cellId === "linkedin_username") { + linkedin_username = cell as string; + } else if (cellId === "org_id") { + org_id = cell as string; + } + }); + + if ( + name && !name.toLowerCase().includes(normalizedQuery) + && (!email || !email.toLowerCase().includes(normalizedQuery)) + ) { + return; + } + + const org = org_id ? store!.getRow("organizations", org_id) : null; + + results.push({ + id: rowId, + full_name: name || null, + email: email || null, + job_title: job_title || null, + linkedin_username: linkedin_username || null, + organization: org + ? { + id: org_id!, + name: org.name as string, + } + : null, + }); + }); + + return results.slice(0, 10); + }, + }); + + const handleJoinMeeting = useCallback((meetingLink: string) => { + window.open(meetingLink, "_blank"); + }, []); + + const handleParticipantClick = useCallback((participant: MeetingParticipant) => { + openNew({ + type: "contacts", + active: true, + state: { + selectedPerson: participant.id, + selectedOrganization: null, + }, + }); + }, [openNew]); + + const handleParticipantAdd = useCallback((participantId: string) => { + if (!store) { + return; + } + + const mappingId = crypto.randomUUID(); + + store.setRow("mapping_session_participant", mappingId, { + user_id, + session_id: sessionId, + human_id: participantId, + created_at: new Date().toISOString(), + }); + + setParticipantSearchQuery(""); + }, [store, sessionId, user_id]); + + const handleParticipantRemove = useCallback((participantId: string) => { + if (!store || !participantMappingIds) { + return; + } + + const mappingId = participantMappingIds.find((id) => { + const humanId = store.getCell("mapping_session_participant", id, "human_id"); + return humanId === participantId; + }); + + if (mappingId) { + store.delRow("mapping_session_participant", mappingId); + } + }, [store, participantMappingIds]); + + return ( + + ); +} diff --git a/apps/desktop2/src/components/main/body/sessions/outer-header/other.tsx b/apps/desktop2/src/components/main/body/sessions/outer-header/other.tsx index 797d5edfa..8e2116095 100644 --- a/apps/desktop2/src/components/main/body/sessions/outer-header/other.tsx +++ b/apps/desktop2/src/components/main/body/sessions/outer-header/other.tsx @@ -1,9 +1,147 @@ -import { MoreHorizontalIcon } from "lucide-react"; +import { + ClockIcon, + FileTextIcon, + FolderIcon, + Link2Icon, + LockIcon, + MicIcon, + MicOffIcon, + MoreHorizontalIcon, + TrashIcon, +} from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@hypr/ui/components/ui/dropdown-menu"; +import { Switch } from "@hypr/ui/components/ui/switch"; export function OthersButton(_: { sessionId: string }) { + const [isLocked, setIsLocked] = useState(false); + const [isListening, setIsListening] = useState(false); + + // TODO: Get actual folders + const folders = [ + { id: "1", name: "Work Notes" }, + { id: "2", name: "Personal" }, + { id: "3", name: "Projects" }, + ]; + + const handleCopyLink = () => { + // TODO: Implement copy link functionality + console.log("Copy link"); + }; + + const handleMoveToFolder = () => { + // TODO: Implement move to folder functionality + console.log("Move to folder"); + }; + + const handleToggleLock = () => { + setIsLocked(!isLocked); + // TODO: Implement lock note functionality + console.log("Toggle lock"); + }; + + const handleExportPDF = () => { + // TODO: Implement export to PDF functionality + console.log("Export to PDF"); + }; + + const handleToggleListening = () => { + setIsListening(!isListening); + // TODO: Implement start/stop listening functionality + console.log("Toggle listening"); + }; + + const handleDeleteNote = () => { + // TODO: Implement delete note functionality + console.log("Delete note"); + }; + + const handleDeleteRecording = () => { + // TODO: Implement delete recording functionality + console.log("Delete recording"); + }; + + const handleHistory = () => { + // TODO: Implement history functionality + console.log("View history"); + }; + return ( - + + + + + + + + Copy link + + + + + + Move to + + + {folders.map((folder) => ( + + + {folder.name} + + ))} + + + + + + Lock note + + + + + + Export to PDF + + + + + + {isListening ? : } + {isListening ? "Stop listening" : "Start listening"} + + + + + + + Delete note + + + + + Delete only recording + + + + + + + History + + + ); } diff --git a/apps/desktop2/src/components/main/body/sessions/outer-header/share.tsx b/apps/desktop2/src/components/main/body/sessions/outer-header/share.tsx index 4b3aca114..00be6c60a 100644 --- a/apps/desktop2/src/components/main/body/sessions/outer-header/share.tsx +++ b/apps/desktop2/src/components/main/body/sessions/outer-header/share.tsx @@ -1,7 +1,9 @@ +import { Button } from "@hypr/ui/components/ui/button"; + export function ShareButton(_: { sessionId: string }) { return ( - + ); } diff --git a/apps/desktop2/src/components/main/body/shared.tsx b/apps/desktop2/src/components/main/body/shared.tsx index 8dc1bfe19..4820ba6ca 100644 --- a/apps/desktop2/src/components/main/body/shared.tsx +++ b/apps/desktop2/src/components/main/body/shared.tsx @@ -1,6 +1,9 @@ +import { Button } from "@hypr/ui/components/ui/button"; +import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu"; + import { clsx } from "clsx"; +import { X } from "lucide-react"; -import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu"; import { type Tab } from "../../../store/zustand/tabs"; import { InteractiveButton } from "../../interactive-button"; @@ -31,6 +34,14 @@ export function TabItemBase( handleCloseAll, }: TabItemBaseProps, ) { + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button === 1) { + e.preventDefault(); + e.stopPropagation(); + handleCloseThis(); + } + }; + const contextMenu = ( <> close tab @@ -44,31 +55,34 @@ export function TabItemBase( asChild contextMenu={contextMenu} onClick={handleSelectThis} + onMouseDown={handleMouseDown} className={clsx([ "flex items-center gap-2 cursor-pointer group", - "min-w-[100px] max-w-[200px] h-full px-2", + "w-48 h-full pl-2 pr-1", "bg-color1 rounded-lg border", active ? "text-black border-black" : "text-color3 border-transparent", ])} > -
+
{icon} {title}
- + + ); } diff --git a/apps/desktop2/src/components/main/sidebar/index.tsx b/apps/desktop2/src/components/main/sidebar/index.tsx index 69c30856f..102467e40 100644 --- a/apps/desktop2/src/components/main/sidebar/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/index.tsx @@ -1,3 +1,5 @@ +import { Button } from "@hypr/ui/components/ui/button"; + import { clsx } from "clsx"; import { PanelLeftCloseIcon } from "lucide-react"; @@ -24,10 +26,11 @@ export function LeftSidebar() { "pl-[72px] bg-color1", ])} > - +
diff --git a/apps/desktop2/src/components/main/sidebar/timeline.tsx b/apps/desktop2/src/components/main/sidebar/timeline.tsx new file mode 100644 index 000000000..8ffe0e853 --- /dev/null +++ b/apps/desktop2/src/components/main/sidebar/timeline.tsx @@ -0,0 +1,402 @@ +import { Button } from "@hypr/ui/components/ui/button"; +import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu"; + +import { clsx } from "clsx"; +import { ChevronDownIcon, ChevronUpIcon, ExternalLink, Trash2 } from "lucide-react"; +import { forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import * as persisted from "../../../store/tinybase/persisted"; +import { Tab, useTabs } from "../../../store/zustand/tabs"; +import { id } from "../../../utils"; +import { buildTimelineBuckets } from "../../../utils/timeline"; +import type { TimelineBucket, TimelineItem, TimelinePrecision } from "../../../utils/timeline"; +import { InteractiveButton } from "../../interactive-button"; + +export function TimelineView() { + const buckets = useTimelineData(); + const currentTime = useCurrentTime(); + const { + containerRef, + isTodayVisible, + isScrolledPastToday, + scrollToToday, + hasToday, + setCurrentTimeIndicatorRef, + } = useTimelineScroll(buckets); + + return ( +
+
+ {buckets.map((bucket) => { + const isToday = bucket.label === "Today"; + + return ( +
+
+ +
+ {isToday + ? ( + + ) + : ( + bucket.items.map((item) => ( + + )) + )} +
+ ); + })} +
+ + {hasToday && !isTodayVisible && ( + + )} +
+ ); +} + +function useTimelineScroll(buckets: TimelineBucket[]) { + const containerRef = useRef(null); + const [isTodayVisible, setIsTodayVisible] = useState(true); + const [isScrolledPastToday, setIsScrolledPastToday] = useState(false); + const [indicatorNode, setIndicatorNode] = useState(null); + + const hasToday = useMemo(() => buckets.some(bucket => bucket.label === "Today"), [buckets]); + + const setCurrentTimeIndicatorRef = useCallback((node: HTMLDivElement | null) => { + setIndicatorNode(prevNode => (prevNode === node ? prevNode : node)); + }, []); + + const scrollToToday = useCallback(() => { + const container = containerRef.current; + if (!container || !indicatorNode) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const indicatorRect = indicatorNode.getBoundingClientRect(); + const indicatorCenter = indicatorRect.top - containerRect.top + container.scrollTop + (indicatorRect.height / 2); + const targetScrollTop = Math.max(indicatorCenter - (container.clientHeight / 2), 0); + container.scrollTo({ top: targetScrollTop, behavior: "smooth" }); + }, [indicatorNode]); + + useEffect(() => { + if (!hasToday) { + return; + } + + requestAnimationFrame(() => { + scrollToToday(); + }); + }, [hasToday, scrollToToday]); + + useEffect(() => { + const container = containerRef.current; + + if (!container || !indicatorNode) { + setIsTodayVisible(true); + setIsScrolledPastToday(false); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + const containerRect = container.getBoundingClientRect(); + const indicatorRect = indicatorNode.getBoundingClientRect(); + + setIsTodayVisible(entry.isIntersecting); + setIsScrolledPastToday(indicatorRect.top < containerRect.top); + }, + { root: container, threshold: 0.1 }, + ); + + observer.observe(indicatorNode); + + return () => observer.disconnect(); + }, [indicatorNode]); + + return { + containerRef, + isTodayVisible, + isScrolledPastToday, + scrollToToday, + hasToday, + setCurrentTimeIndicatorRef, + }; +} + +function DateHeader({ label }: { label: string }) { + return
{label}
; +} + +function TimelineItemComponent({ item, precision }: { item: TimelineItem; precision: TimelinePrecision }) { + const { currentTab, openCurrent, openNew } = useTabs(); + const store = persisted.UI.useStore(persisted.STORE_ID); + + const title = item.data.title || "Untitled"; + const timestamp = item.type === "event" ? item.data.started_at : item.data.created_at; + const eventId = item.type === "event" ? item.id : (item.data.event_id || undefined); + + const handleClick = () => { + if (item.type === "event") { + handleEventClick(false); + } else { + const tab: Tab = { id: item.id, type: "sessions", active: false, state: { editor: "raw" } }; + openCurrent(tab); + } + }; + + const handleCmdClick = () => { + if (item.type === "event") { + handleEventClick(true); + } else { + const tab: Tab = { id: item.id, type: "sessions", active: false, state: { editor: "raw" } }; + openNew(tab); + } + }; + + const handleEventClick = (openInNewTab: boolean) => { + if (!eventId || !store) { + return; + } + + const sessions = store.getTable("sessions"); + let existingSessionId: string | null = null; + + Object.entries(sessions).forEach(([sessionId, session]) => { + if (session.event_id === eventId) { + existingSessionId = sessionId; + } + }); + + if (existingSessionId) { + const tab: Tab = { id: existingSessionId, type: "sessions", active: false, state: { editor: "raw" } }; + if (openInNewTab) { + openNew(tab); + } else { + openCurrent(tab); + } + } else { + const sessionId = id(); + store.setRow("sessions", sessionId, { + event_id: eventId, + title: title, + created_at: new Date().toISOString(), + }); + const tab: Tab = { id: sessionId, type: "sessions", active: false, state: { editor: "raw" } }; + if (openInNewTab) { + openNew(tab); + } else { + openCurrent(tab); + } + } + }; + + // TODO: not ideal + const active = currentTab?.type === "sessions" && ( + (item.type === "session" && currentTab.id === item.id) + || (item.type === "event" && item.id === eventId && (() => { + if (!store) { + return false; + } + const session = store.getRow("sessions", currentTab.id); + return session?.event_id === eventId; + })()) + ); + + const contextMenu = ( + <> + handleCmdClick()}> + + New Tab + + console.log("Delete:", item.type, item.id)}> + + Delete + + + ); + const displayTime = useMemo(() => { + if (!timestamp) { + return ""; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ""; + } + + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "numeric" }); + + if (precision === "time") { + return time; + } + + const sameYear = date.getFullYear() === new Date().getFullYear(); + const dateStr = sameYear + ? date.toLocaleDateString([], { month: "short", day: "numeric" }) + : date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); + return `${dateStr}, ${time}`; + }, [timestamp, precision]); + + return ( + +
+
{title}
+ {displayTime &&
{displayTime}
} +
+
+ ); +} + +function TodayBucket({ + items, + precision, + currentTime, + registerIndicator, +}: { + items: TimelineItem[]; + precision: TimelinePrecision; + currentTime: Date; + registerIndicator: (node: HTMLDivElement | null) => void; +}) { + const entries = useMemo( + () => items.map((timelineItem) => ({ item: timelineItem, timestamp: getItemTimestamp(timelineItem) })), + [items], + ); + + const currentTimeMs = currentTime.getTime(); + + const indicatorIndex = useMemo(() => { + for (let index = 0; index < entries.length; index += 1) { + const timestamp = entries[index].timestamp; + + if (!timestamp) { + return index; + } + + if (timestamp.getTime() < currentTimeMs) { + return index; + } + } + + return entries.length; + }, [entries, currentTimeMs]); + + if (entries.length === 0) { + return ( + <> + +
+ No items today +
+ + ); + } + + return ( + <> + {entries.map((entry, index) => ( + + {index === indicatorIndex && } + + + ))} + {indicatorIndex === entries.length && } + + ); +} + +function useTimelineData(): TimelineBucket[] { + const eventsWithoutSessionTable = persisted.UI.useResultTable( + persisted.QUERIES.eventsWithoutSession, + persisted.STORE_ID, + ); + const sessionsWithMaybeEventTable = persisted.UI.useResultTable( + persisted.QUERIES.sessionsWithMaybeEvent, + persisted.STORE_ID, + ); + + return useMemo( + () => + buildTimelineBuckets({ + eventsWithoutSessionTable, + sessionsWithMaybeEventTable, + }), + [eventsWithoutSessionTable, sessionsWithMaybeEventTable], + ); +} + +function useCurrentTime() { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const update = () => setNow(new Date()); + update(); + const interval = window.setInterval(update, 60_000); + + return () => { + window.clearInterval(interval); + }; + }, []); + + return now; +} + +function getItemTimestamp(item: TimelineItem): Date | null { + const value = item.type === "event" ? item.data.started_at : item.data.created_at; + + if (!value) { + return null; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date; +} + +const CurrentTimeIndicator = forwardRef((_, ref) => ( +
+
+
+)); + +CurrentTimeIndicator.displayName = "CurrentTimeIndicator"; diff --git a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx index e6f4e3d57..0f4c70b9d 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx @@ -99,10 +99,8 @@ export function TimelineView() { : "shadow-[0_2px_8px_rgba(248,113,113,0.3)] hover:shadow-[0_2px_12px_rgba(239,68,68,0.4)]", ])} > -
- - Go to Today -
+ + Go to Today )}
diff --git a/packages/tiptap/src/editor/index.tsx b/packages/tiptap/src/editor/index.tsx index e367e04d0..91f2ca308 100644 --- a/packages/tiptap/src/editor/index.tsx +++ b/packages/tiptap/src/editor/index.tsx @@ -63,6 +63,12 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( }, scrollThreshold: 32, scrollMargin: 32, + handleKeyDown: (_, event) => { + if ((event.metaKey || event.ctrlKey) && (event.key === "w" || event.key === "n" || event.key === "t")) { + return false; + } + return false; + }, }, }); @@ -97,6 +103,10 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key === "w" || e.key === "n" || e.key === "t")) { + return; + } + if (e.key === "Backspace" && editor?.state.selection.empty) { const isAtStart = editor.state.selection.$head.pos === 0; if (isAtStart) { diff --git a/packages/ui/src/components/block/event-chip.tsx b/packages/ui/src/components/block/event-chip.tsx index 4bcb5e471..30828fca0 100644 --- a/packages/ui/src/components/block/event-chip.tsx +++ b/packages/ui/src/components/block/event-chip.tsx @@ -87,7 +87,6 @@ export function EventChip({ return ; }; - // Wrapper functions to handle closing popover after actions const handleEventSelect = (eventId: string) => { onEventSelect?.(eventId); setIsOpen(false); @@ -108,7 +107,7 @@ export function EventChip({
- {getIcon()} + {getIcon()} {!isVeryNarrow && ( -

+

{formatRelativeDate(event?.start_date || date)}

)} @@ -212,7 +211,7 @@ function EventDetails({ )}
{event.name}
-
{getDateString()}
+
{getDateString()}
{event.meetingLink && onJoinMeeting && ( @@ -237,7 +236,7 @@ function EventDetails({
{event.note && ( -
+
{event.note}
)} @@ -259,19 +258,19 @@ function EventSearch({ return (
- + onSearchChange?.(e.target.value)} - className="w-full bg-transparent text-sm focus:outline-none placeholder:text-neutral-400" + className="w-full bg-transparent text-sm focus:outline-none placeholder:text-color3" />
{searchResults.length === 0 ? ( -
+
{searchQuery ? "No matching events found." : "No past events available."}
) @@ -286,7 +285,7 @@ function EventSearch({

{event.name}

-

+

{formatDate(new Date(event.start_date), "MMM d, yyyy")}

diff --git a/packages/ui/src/components/block/meeting-metadata-chip.tsx b/packages/ui/src/components/block/meeting-metadata-chip.tsx new file mode 100644 index 000000000..5aa72d94a --- /dev/null +++ b/packages/ui/src/components/block/meeting-metadata-chip.tsx @@ -0,0 +1,435 @@ +import { + CalendarIcon, + CircleMinus, + CornerDownLeft, + ExternalLinkIcon, + MailIcon, + MapPinIcon, + SearchIcon, + VideoIcon, +} from "lucide-react"; +import { useState } from "react"; + +import { cn } from "../../lib/utils"; +import { LinkedInIcon } from "../icons/linkedin"; +import { Avatar, AvatarFallback } from "../ui/avatar"; +import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; + +const formatDate = (date: Date, format: string): string => { + const pad = (n: number) => n.toString().padStart(2, "0"); + + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + const replacements: Record = { + "yyyy": date.getFullYear().toString(), + "MMM": months[date.getMonth()], + "MM": pad(date.getMonth() + 1), + "d": date.getDate().toString(), + "dd": pad(date.getDate()), + "EEE": days[date.getDay()], + "h": (date.getHours() % 12 || 12).toString(), + "mm": pad(date.getMinutes()), + "a": date.getHours() >= 12 ? "PM" : "AM", + "p": `${date.getHours() % 12 || 12}:${pad(date.getMinutes())} ${date.getHours() >= 12 ? "PM" : "AM"}`, + }; + + return format.replace(/yyyy|MMM|MM|dd|EEE|h|mm|a|p|d/g, (token) => replacements[token]); +}; + +const isSameDay = (date1: Date, date2: Date): boolean => { + return date1.getFullYear() === date2.getFullYear() + && date1.getMonth() === date2.getMonth() + && date1.getDate() === date2.getDate(); +}; + +export interface MeetingMetadata { + id: string; + title: string; + started_at: string; + ended_at: string; + location?: string | null; + meeting_link?: string | null; + description?: string | null; + participants: MeetingParticipant[]; +} + +export interface MeetingParticipant { + id: string; + full_name?: string | null; + email?: string | null; + job_title?: string | null; + linkedin_username?: string | null; + organization?: { + id: string; + name: string; + } | null; +} + +export interface MeetingMetadataChipProps { + metadata?: MeetingMetadata | null; + isVeryNarrow?: boolean; + isNarrow?: boolean; + onJoinMeeting?: (meetingLink: string) => void; + onParticipantClick?: (participant: MeetingParticipant) => void; + onParticipantAdd?: (participantId: string) => void; + onParticipantRemove?: (participantId: string) => void; + participantSearchQuery?: string; + onParticipantSearchChange?: (query: string) => void; + participantSearchResults?: MeetingParticipant[]; + currentUserId?: string; + formatRelativeDate?: (date: string) => string; +} + +export function MeetingMetadataChip({ + metadata, + onJoinMeeting, + onParticipantClick, + onParticipantAdd, + onParticipantRemove, + participantSearchQuery = "", + onParticipantSearchChange, + participantSearchResults = [], + currentUserId, +}: MeetingMetadataChipProps) { + const [isOpen, setIsOpen] = useState(false); + + const getMeetingDomain = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return url; + } + }; + + if (!metadata) { + return ( + + ); + } + + return ( + + + + + + +
+
{metadata.title}
+ +
+ + {metadata.location && ( + <> +
+ + + {metadata.location} + +
+
+ + )} + + {metadata.meeting_link && ( + <> +
+
+ + + {getMeetingDomain(metadata.meeting_link)} + +
+ +
+
+ + )} + +

+ {formatDateRange(metadata.started_at, metadata.ended_at)} +

+ +
+ + + + {metadata.description && ( + <> +
+
+ {metadata.description} +
+ + )} +
+ + + ); +} + +function formatDateRange(startDate: string, endDate: string): string { + const start = new Date(startDate); + const end = new Date(endDate); + + const formatTime = (date: Date) => formatDate(date, "p"); + const formatFullDate = (date: Date) => formatDate(date, "MMM d, yyyy"); + + if (isSameDay(start, end)) { + return `${formatFullDate(start)} ${formatTime(start)} to ${formatTime(end)}`; + } else { + return `${formatFullDate(start)} ${formatTime(start)} to ${formatFullDate(end)} ${formatTime(end)}`; + } +} + +interface ParticipantsSectionProps { + participants: MeetingParticipant[]; + searchQuery: string; + searchResults: MeetingParticipant[]; + onSearchChange?: (query: string) => void; + onParticipantAdd?: (participantId: string) => void; + onParticipantClick?: (participant: MeetingParticipant) => void; + onParticipantRemove?: (participantId: string) => void; + currentUserId?: string; +} + +function ParticipantsSection({ + participants, + searchQuery, + searchResults, + onSearchChange, + onParticipantAdd, + onParticipantClick, + onParticipantRemove, + currentUserId, +}: ParticipantsSectionProps) { + const [isFocused, setIsFocused] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!searchQuery.trim() || searchResults.length === 0) { + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (prev < searchResults.length - 1 ? prev + 1 : prev)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < searchResults.length) { + handleSelectParticipant(searchResults[selectedIndex].id); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setIsFocused(false); + onSearchChange?.(""); + } + }; + + const handleSelectParticipant = (participantId: string) => { + onParticipantAdd?.(participantId); + onSearchChange?.(""); + setSelectedIndex(-1); + setIsFocused(true); // Keep focus on input + }; + + return ( +
+
Participants
+ + {/* Existing Participants Chips */} + {participants.length > 0 && ( +
+ {participants.map((participant) => ( + onParticipantClick?.(participant)} + onRemove={() => onParticipantRemove?.(participant.id)} + /> + ))} +
+ )} + + {/* Search Input */} +
+
+ + { + onSearchChange?.(e.target.value); + setSelectedIndex(-1); + }} + onKeyDown={handleKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => { + // Delay to allow click on results + setTimeout(() => setIsFocused(false), 200); + }} + placeholder="Add participant" + className="w-full bg-transparent text-sm focus:outline-none placeholder:text-color3" + /> + {searchQuery.trim() && ( + + )} +
+ + {/* Search Results Dropdown */} + {isFocused && searchQuery.trim() && ( +
+ {searchResults.length > 0 + ? ( + searchResults.map((participant, index) => ( + + )) + ) + : ( +
+ No matching participants found +
+ )} +
+ )} +
+
+ ); +} + +interface ParticipantChipProps { + participant: MeetingParticipant; + currentUserId?: string; + onClick?: () => void; + onRemove?: () => void; +} + +function ParticipantChip({ participant, currentUserId, onClick, onRemove }: ParticipantChipProps) { + const getInitials = (name: string) => { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + }; + + const displayName = participant.full_name + || (participant.id === currentUserId ? "You" : "Unknown"); + + return ( +
+
+ + + {participant.full_name ? getInitials(participant.full_name) : "?"} + + + + {displayName} + +
+ +
+ {participant.email && ( + e.stopPropagation()} + className="text-color3 transition-colors hover:text-color4 opacity-0 group-hover:opacity-100" + > + + + )} + {participant.linkedin_username && ( + e.stopPropagation()} + target="_blank" + rel="noopener noreferrer" + className="text-color3 transition-colors hover:text-color4 opacity-0 group-hover:opacity-100" + > + + + )} + +
+
+ ); +} diff --git a/packages/ui/src/components/block/participants-chip.tsx b/packages/ui/src/components/block/participants-chip.tsx index ad7ab855e..b7c7679d3 100644 --- a/packages/ui/src/components/block/participants-chip.tsx +++ b/packages/ui/src/components/block/participants-chip.tsx @@ -1,5 +1,6 @@ -import { CircleMinus, CornerDownLeft, LinkedinIcon, MailIcon, SearchIcon, Users2Icon } from "lucide-react"; +import { CircleMinus, CornerDownLeft, MailIcon, SearchIcon, Users2Icon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { LinkedInIcon } from "../icons/linkedin"; import { Avatar, AvatarFallback } from "../ui/avatar"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; @@ -81,9 +82,9 @@ export function ParticipantsChip({ isVeryNarrow ? "px-1.5" : "px-2", )} > - - {getButtonText()} - {count > 1 && !isVeryNarrow && !isNarrow && + {count - 1}} + + {getButtonText()} + {count > 1 && !isVeryNarrow && !isNarrow && + {count - 1}}
@@ -140,7 +141,7 @@ function ParticipantList({
{participants.map((group, index) => (
-
+
{group.organization?.name || "No organization"}
@@ -198,7 +199,7 @@ function ParticipantItem({ )} > - + {participant.full_name ? getInitials(participant.full_name) : "?"} @@ -216,10 +217,10 @@ function ParticipantItem({ )}
- + {participant.full_name || (participant.id === currentUserId ? "You" : "Unknown")} - {participant.job_title && {participant.job_title}} + {participant.job_title && {participant.job_title}}
@@ -228,7 +229,7 @@ function ParticipantItem({ e.stopPropagation()} - className="text-neutral-400 transition-colors hover:text-neutral-600 p-1 rounded-full hover:bg-neutral-200" + className="text-color3 transition-colors hover:text-color4 p-1 rounded-full hover:bg-neutral-200" > @@ -237,9 +238,9 @@ function ParticipantItem({ e.stopPropagation()} - className="text-neutral-400 transition-colors hover:text-neutral-600 p-1 rounded-full hover:bg-neutral-200" + className="text-color3 transition-colors hover:text-color4 p-1 rounded-full hover:bg-neutral-200" > - + )}
@@ -271,18 +272,18 @@ function AddParticipantInput({ >
- + onChange?.(e.target.value)} placeholder="Find person" - className="w-full bg-transparent text-sm focus:outline-none placeholder:text-neutral-400" + className="w-full bg-transparent text-sm focus:outline-none placeholder:text-color3" /> {value.trim() && ( @@ -300,7 +301,7 @@ function AddParticipantInput({ > {participant.full_name} {participant.organization?.name && ( - + {participant.organization.name} )} @@ -311,8 +312,8 @@ function AddParticipantInput({ type="submit" className="flex items-center px-3 py-2 text-sm text-left hover:bg-neutral-100 transition-colors w-full" > - - Create "{value.trim()}" + + Create "{value.trim()}" )} diff --git a/packages/ui/src/components/icons/linkedin.tsx b/packages/ui/src/components/icons/linkedin.tsx new file mode 100644 index 000000000..559247615 --- /dev/null +++ b/packages/ui/src/components/icons/linkedin.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from "react"; + +export interface LinkedInIconProps extends React.SVGProps { + size?: number; +} + +export const LinkedInIcon = forwardRef( + ({ size = 24, ...props }, ref) => { + return ( + + + + ); + }, +); + +LinkedInIcon.displayName = "LinkedInIcon"; diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx index 77c260ad9..5ecd3616b 100644 --- a/packages/ui/src/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { cn } from "../../lib/utils"; +import { Spinner } from "./spinner"; type ButtonVariant = "default" | "destructive" | "outline" | "ghost"; type ButtonSize = "sm" | "md" | "lg" | "icon"; @@ -13,7 +14,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { const styles = { base: - "inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-1 rounded-lg font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50", variants: { default: @@ -60,28 +61,7 @@ export const Button = React.forwardRef( )} {...props} > - {isLoading && ( - - - - - )} + {isLoading && } {children} ); diff --git a/packages/utils/package.json b/packages/utils/package.json index efe2e3fcc..6ffbcc878 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -3,9 +3,6 @@ "exports": { ".": "./src/index.ts" }, - "scripts": { - "test": "vitest run" - }, "dependencies": { "@ai-sdk/openai-compatible": "^1.0.22", "@date-fns/tz": "^1.4.1", diff --git a/plugins/notification/src/handler.rs b/plugins/notification/src/handler.rs index 097f9f28a..5291fb894 100644 --- a/plugins/notification/src/handler.rs +++ b/plugins/notification/src/handler.rs @@ -100,6 +100,14 @@ impl NotificationHandler { "com.prakashjoshipax.VoiceInk", "com.goodsnooze.macwhisper", "com.descript.beachcube", + "com.openai.chat", + "com.anthropic.claudefordesktop", + "com.raycast.macos", + "com.apple.VoiceMemos", + "com.exafunction.windsurf", + "dev.zed.Zed", + "com.microsoft.VSCode", + "com.todesktop.230313mzl4w4u92", ] .contains(&app.id.as_str()) }) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 157b0ebcc..496731f4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -789,15 +789,6 @@ importers: specifier: ^2.8.0 version: 2.8.0 - plugins/obsidian: - dependencies: - '@hypr/obsidian': - specifier: workspace:^ - version: link:../../packages/obsidian - '@tauri-apps/api': - specifier: ^2.8.0 - version: 2.8.0 - plugins/sfx: dependencies: '@tauri-apps/api': @@ -816,12 +807,6 @@ importers: specifier: ^2.8.0 version: 2.8.0 - plugins/task: - dependencies: - '@tauri-apps/api': - specifier: ^2.8.0 - version: 2.8.0 - plugins/template: dependencies: '@tauri-apps/api':