diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e5e390abdb..33b08e9fde 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,7 +81,6 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tauri-plugin-sentry-api": "^0.4.1", - "tinybase": "^6.1.1", "tippy.js": "^6.3.7", "zod": "^3.24.4", "zustand": "^5.0.4" diff --git a/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx index 4aa51a67ff..16e904fe0e 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx @@ -2,53 +2,75 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { RiCornerDownLeftLine, RiLinkedinBoxFill } from "@remixicon/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { clsx } from "clsx"; -import { Users2Icon } from "lucide-react"; -import { CircleMinus, MailIcon, SearchIcon } from "lucide-react"; -import { useMemo } from "react"; -import React, { useState } from "react"; +import { CircleMinus, MailIcon, SearchIcon, Users2Icon } from "lucide-react"; +import React, { useMemo, useState } from "react"; import { useHypr } from "@/contexts/hypr"; import { commands as dbCommands, type Human } from "@hypr/plugin-db"; import { commands as windowsCommands } from "@hypr/plugin-windows"; import { Avatar, AvatarFallback } from "@hypr/ui/components/ui/avatar"; import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@hypr/ui/components/ui/tooltip"; import { getInitials } from "@hypr/utils"; const NO_ORGANIZATION_ID = "__NO_ORGANIZATION__"; -export function ParticipantsChip({ sessionId }: { sessionId: string }) { - const { userId } = useHypr(); - +export function useParticipantsWithOrg(sessionId: string) { const { data: participants = [] } = useQuery({ queryKey: ["participants", sessionId], queryFn: async () => { const participants = await dbCommands.sessionListParticipants(sessionId); - - return participants.sort((a, b) => { - if (a.is_user && !b.is_user) { + const orgs = await Promise.all( + participants + .map((p) => p.organization_id) + .filter((id) => id !== null) + .map((id) => dbCommands.getOrganization(id)), + ).then((orgs) => orgs.filter((o) => o !== null)); + + const grouped = participants.reduce((acc, participant) => { + const orgId = participant.organization_id ?? NO_ORGANIZATION_ID; + acc[orgId] = [...(acc[orgId] || []), participant]; + return acc; + }, {} as Record); + + return Object.entries(grouped).map(([orgId, participants]) => ({ + organization: orgs.find((o) => o.id === orgId) ?? null, + participants, + })).sort((a, b) => { + if (!a.organization && b.organization) { return 1; } - if (!a.is_user && b.is_user) { + if (a.organization && !b.organization) { return -1; } - return 0; + return (a.organization?.name || "").localeCompare(b.organization?.name || ""); }); }, }); - const count = participants.length; + return participants; +} + +export function ParticipantsChip({ sessionId }: { sessionId: string }) { + const participants = useParticipantsWithOrg(sessionId); + const { userId } = useHypr(); + + const count = participants.reduce((acc, group) => acc + (group.participants?.length ?? 0), 0); const buttonText = useMemo(() => { - const previewHuman = participants.at(0); - if (!previewHuman) { + if (count === 0) { return "Add participants"; } + + const previewHuman = participants.find((group) => group.participants.length > 0)?.participants[0]!; if (previewHuman.id === userId && !previewHuman.full_name) { return "You"; } return previewHuman.full_name ?? "??"; }, [participants, userId]); + const handleClickHuman = (human: Human) => { + windowsCommands.windowShow({ type: "human", value: human.id }); + }; + return ( @@ -60,89 +82,46 @@ export function ParticipantsChip({ sessionId }: { sessionId: string }) { - + ); } -function ParticipantsList( - { sessionId, participants }: { sessionId: string; participants: Human[] }, +export function ParticipantsChipInner( + { sessionId, handleClickHuman }: { sessionId: string; handleClickHuman: (human: Human) => void }, ) { - const grouped = useMemo(() => { - if (!participants?.length) { - return []; - } - const ret: Record = {}; - - participants.forEach((p) => { - const group = p.organization_id ?? NO_ORGANIZATION_ID; - ret[group] = [...(ret[group] || []), p]; - }); - - Object.values(ret).forEach((members) => - members.sort((a, b) => (a.full_name ?? "").localeCompare(b.full_name ?? "")) - ); - - return Object.entries(ret).sort( - ([, a], [, b]) => b.length - a.length, - ); - }, [participants]); - - if (!grouped.length) { - return ; - } + const participants = useParticipantsWithOrg(sessionId); return ( -
-
Participants
- -
- {grouped.map(([orgId, members]) => ( - - ))} -
- - -
- ); -} - -function OrganizationWithParticipants( - { orgId, members, sessionId }: { orgId: string; members: Human[]; sessionId: string }, -) { - const organization = useQuery({ - queryKey: ["org", orgId], - queryFn: () => { - if (orgId === NO_ORGANIZATION_ID) { - return null; - } - - return dbCommands.getOrganization(orgId); - }, - }); - - return ( -
-
- {organization.data?.name ?? "No organization"} -
-
- {members.map((member, index) => ( - - ))} -
-
+ !participants.length + ? + : ( +
+
Participants
+
+ {participants.map(({ organization, participants }) => ( +
+
+ {organization?.name ?? "No organization"} +
+
+ {(participants ?? []).map((member, index) => ( + + ))} +
+
+ ))} +
+ +
+ ) ); } @@ -150,10 +129,12 @@ function ParticipentItem({ member, sessionId, isLast = false, + handleClickHuman, }: { member: Human; sessionId: string; isLast?: boolean; + handleClickHuman: (human: Human) => void; }) { const queryClient = useQueryClient(); const { userId } = useHypr(); @@ -166,10 +147,6 @@ function ParticipentItem({ }), }); - const handleClickHuman = (human: Human) => { - windowsCommands.windowShow({ type: "human", value: human.id }); - }; - const handleRemoveParticipant = (id: string) => { removeParticipantMutation.mutate({ id: id }); }; @@ -192,36 +169,29 @@ function ParticipentItem({ - - -
{ - e.stopPropagation(); - handleRemoveParticipant(member.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - handleRemoveParticipant(member.id); - } - }} - className={clsx([ - "flex items-center justify-center", - "text-red-400 hover:text-red-600", - "absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity", - "bg-white shadow-sm", - ])} - > - -
-
- - Remove {member.full_name} from list - -
+
{ + e.stopPropagation(); + handleRemoveParticipant(member.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + handleRemoveParticipant(member.id); + } + }} + className={clsx([ + "flex items-center justify-center", + "text-red-400 hover:text-red-600", + "absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity", + "bg-white shadow-sm", + ])} + > + +
{member.full_name diff --git a/apps/desktop/src/components/editor-area/note-header/listen-button.tsx b/apps/desktop/src/components/editor-area/note-header/listen-button.tsx index 39692df46a..cedc9416aa 100644 --- a/apps/desktop/src/components/editor-area/note-header/listen-button.tsx +++ b/apps/desktop/src/components/editor-area/note-header/listen-button.tsx @@ -10,7 +10,7 @@ import { Volume2Icon, VolumeOffIcon, } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import SoundIndicator from "@/components/sound-indicator"; import { useHypr } from "@/contexts"; @@ -231,7 +231,6 @@ function WhenInactiveAndMeetingEndedOnboarding({ disabled, onClick }: { disabled export function WhenActive() { const [open, setOpen] = useState(true); - const isInitialMount = useRef(true); const ongoingSessionStore = useOngoingSession((s) => ({ pause: s.pause, @@ -261,10 +260,6 @@ export function WhenActive() { } }, [showConsent, refetchSpeakerMuted]); - useEffect(() => { - isInitialMount.current = false; - }, []); - const toggleMicMuted = useMutation({ mutationFn: () => listenerCommands.setMicMuted(!isMicMuted), onSuccess: () => { diff --git a/apps/desktop/src/components/right-panel/views/transcript-view.tsx b/apps/desktop/src/components/right-panel/views/transcript-view.tsx index f79157ec1b..c2d2cde567 100644 --- a/apps/desktop/src/components/right-panel/views/transcript-view.tsx +++ b/apps/desktop/src/components/right-panel/views/transcript-view.tsx @@ -1,12 +1,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMatch } from "@tanstack/react-router"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import { AudioLinesIcon, CheckIcon, ClipboardIcon, Copy, EarIcon, PencilIcon, UploadIcon } from "lucide-react"; -import { Fragment, type RefObject, useEffect, useRef, useState } from "react"; +import { AudioLinesIcon, ClipboardIcon, Copy, UploadIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; -import { commands as dbCommands, type Word } from "@hypr/plugin-db"; +import { ParticipantsChipInner } from "@/components/editor-area/note-header/chips/participants-chip"; +import { commands as dbCommands, Human, Word } from "@hypr/plugin-db"; import { commands as miscCommands } from "@hypr/plugin-misc"; -import TranscriptEditor from "@hypr/tiptap/transcript"; +import TranscriptEditor, { type SpeakerViewInnerProps, type TranscriptEditorRef } from "@hypr/tiptap/transcript"; import { Button } from "@hypr/ui/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; import { Spinner } from "@hypr/ui/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hypr/ui/components/ui/tooltip"; import { useOngoingSession, useSessions } from "@hypr/utils/contexts"; @@ -15,7 +18,6 @@ import { useTranscriptWidget } from "../hooks/useTranscriptWidget"; export function TranscriptView() { const queryClient = useQueryClient(); - const transcriptContainerRef = useRef(null); const sessionId = useSessions((s) => s.currentSessionId); const ongoingSession = useOngoingSession((s) => ({ @@ -27,6 +29,14 @@ export function TranscriptView() { const { showEmptyMessage, hasTranscript } = useTranscriptWidget(sessionId); const { isLive, words } = useTranscript(sessionId); + const editorRef = useRef(null); + + useEffect(() => { + if (words && words.length > 0) { + editorRef.current?.setWords(words); + } + }, [words]); + const handleCopyAll = () => { if (words && words.length > 0) { const transcriptText = words.map((word) => word.text).join(" "); @@ -34,11 +44,6 @@ export function TranscriptView() { } }; - const isOnboarding = useQuery({ - queryKey: ["onboarding"], - queryFn: () => dbCommands.onboardingSessionId().then((v) => v === sessionId), - }); - const audioExist = useQuery( { refetchInterval: 2500, @@ -49,54 +54,21 @@ export function TranscriptView() { queryClient, ); - const [editing, setEditing] = useState(false); - const editorRef = useRef(null); - - const handleClickToggleEditing = () => { - setEditing(!editing); - - if (!editing) { - if (editorRef.current) { - // @ts-expect-error - const words = editorRef.current.getWords(); - - if (words && sessionId) { - dbCommands.getSession({ id: sessionId! }).then((session) => { - if (session) { - dbCommands.upsertSession({ - ...session, - words, - }); - } - }).then(() => { - queryClient.invalidateQueries({ - predicate: (query) => (query.queryKey[0] as string).includes("session"), - }); - }); - } - } - } - }; - const handleOpenSession = () => { if (sessionId) { miscCommands.audioOpen(sessionId); } }; - useEffect(() => { - const scrollToBottom = () => { - requestAnimationFrame(() => { - if (transcriptContainerRef.current && "scrollTop" in transcriptContainerRef.current) { - transcriptContainerRef.current.scrollTop = transcriptContainerRef.current.scrollHeight; + const handleUpdate = (words: Word[]) => { + if (!isLive) { + dbCommands.getSession({ id: sessionId! }).then((session) => { + if (session) { + dbCommands.upsertSession({ ...session, words }); } }); - }; - - if (words?.length) { - scrollToBottom(); } - }, [words, isLive, transcriptContainerRef]); + }; if (!sessionId) { return null; @@ -121,7 +93,7 @@ export function TranscriptView() {
)}
- {(audioExist.data && ongoingSession.isInactive && hasTranscript && sessionId && !editing) && ( + {(audioExist.data && ongoingSession.isInactive && hasTranscript && sessionId) && ( @@ -135,7 +107,7 @@ export function TranscriptView() { )} - {(hasTranscript && sessionId && !editing) && ( + {(hasTranscript && sessionId) && ( @@ -149,29 +121,24 @@ export function TranscriptView() { )} - {false && hasTranscript && ongoingSession.isInactive && !isOnboarding.data && ( - - )}
- {editing - ? ( - - ) - : showEmptyMessage + {showEmptyMessage ? - : } + : ( +
+ +
+ )}
); @@ -227,29 +194,56 @@ function RenderEmpty({ sessionId }: { sessionId: string }) { ); } -function RenderContent({ containerRef, words, isLive }: { - containerRef: RefObject; - isLive: boolean; - words: Word[]; -}) { +const SpeakerSelector = ({ + onSpeakerIdChange, + speakerId, + speakerIndex, +}: SpeakerViewInnerProps) => { + const [isOpen, setIsOpen] = useState(false); + const inactive = useOngoingSession(s => s.status === "inactive"); + + const noteMatch = useMatch({ from: "/app/note/$id", shouldThrow: false }); + const sessionId = noteMatch?.params.id; + + const { data: participants = [] } = useQuery({ + enabled: !!sessionId, + queryKey: ["participants", sessionId!, "selector"], + queryFn: () => dbCommands.sessionListParticipants(sessionId!), + }); + + const handleClickHuman = (human: Human) => { + onSpeakerIdChange(human.id); + setIsOpen(false); + }; + + const foundSpeaker = participants.length === 1 ? participants[0] : participants.find((s) => s.id === speakerId); + const displayName = foundSpeaker?.full_name ?? `Speaker ${speakerIndex ?? 0}`; + + if (!sessionId) { + return

; + } + + if (!inactive && !foundSpeaker) { + return

; + } + return ( -
-

- {words.map((word, i) => ( - - {i > 0 && " "} - {word.text} - - ))} -

- {isLive && ( -
- Listening... (there might be a delay) -
- )} +
+ + { + // prevent cursor from moving to the end of the editor + e.preventDefault(); + }} + > + + {displayName} + + + + + +
); -} +}; diff --git a/apps/desktop/src/contexts/index.ts b/apps/desktop/src/contexts/index.ts index cef5cfb04a..2fc8ad79ac 100644 --- a/apps/desktop/src/contexts/index.ts +++ b/apps/desktop/src/contexts/index.ts @@ -6,4 +6,3 @@ export * from "./new-note"; export * from "./right-panel"; export * from "./search"; export * from "./settings"; -export * from "./tinybase"; diff --git a/apps/desktop/src/contexts/tinybase.tsx b/apps/desktop/src/contexts/tinybase.tsx deleted file mode 100644 index ca9949e6c7..0000000000 --- a/apps/desktop/src/contexts/tinybase.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useEffect } from "react"; - -import { createMergeableStore, createRelationships } from "tinybase"; -import { createCustomPersister } from "tinybase/persisters"; -import { createBroadcastChannelSynchronizer } from "tinybase/synchronizers/synchronizer-broadcast-channel"; -import { Provider, useCreateMergeableStore, useCreateRelationships } from "tinybase/ui-react"; - -import { - type Calendar, - commands as dbCommands, - type Event, - type Human, - type Organization, - type Session, -} from "@hypr/plugin-db"; -import { useHypr } from "./hypr"; - -export function TinyBaseProvider({ - children, -}: { - children: React.ReactNode; -}) { - const { userId } = useHypr(); - const store = useCreateMergeableStore(createMergeableStore); - - const persister = createCustomPersister( - store, - async () => { - const [sessions, humans, organizations, calendars, events] = await Promise.all([ - dbCommands.listSessions(null), - dbCommands.listHumans(null), - dbCommands.listOrganizations(null), - dbCommands.listCalendars(userId), - dbCommands.listEvents(null), - ]); - - return [{ - sessions: sessions.reduce((acc, session) => { - acc[session.id] = { - id: session.id, - title: session.title ?? "", - created_at: session.created_at ?? "", - visited_at: session.visited_at ?? "", - user_id: session.user_id ?? "", - calendar_event_id: session.calendar_event_id ?? "", - raw_memo_html: session.raw_memo_html ?? "", - enhanced_memo_html: session.enhanced_memo_html ?? "", - words: session.words ?? [], - } satisfies Session; - return acc; - }, {} as Record>), - humans: humans.reduce((acc, human) => { - acc[human.id] = { - id: human.id, - organization_id: human.organization_id ?? "", - is_user: human.is_user, - full_name: human.full_name ?? "", - email: human.email ?? "", - job_title: human.job_title ?? "", - linkedin_username: human.linkedin_username ?? "", - } satisfies Human; - return acc; - }, {} as Record>), - - organizations: organizations.reduce((acc, organization) => { - acc[organization.id] = { - id: organization.id, - name: organization.name ?? "", - description: organization.description ?? "", - } satisfies Organization; - return acc; - }, {} as Record>), - - calendars: calendars.reduce((acc, calendar) => { - acc[calendar.id] = calendar satisfies Calendar; - return acc; - }, {} as Record>), - events: events.reduce((acc, event) => { - acc[event.id] = event satisfies Event; - return acc; - }, {} as Record>), - }, {}]; - }, - async (getContent) => { - const [t] = getContent(); - - const tables = t as unknown as { - sessions: Session[]; - humans: Human[]; - organizations: Organization[]; - calendars: Calendar[]; - events: Event[]; - session_participants: { session_id: string; human_id: string }[]; - }; - - await Promise.all([ - ...Object.values(tables.sessions).map(dbCommands.upsertSession), - ...Object.values(tables.humans).map(dbCommands.upsertHuman), - ...Object.values(tables.organizations).map(dbCommands.upsertOrganization), - ...Object.values(tables.calendars).map(dbCommands.upsertCalendar), - ...Object.values(tables.session_participants).map(p => - dbCommands.sessionAddParticipant(p.session_id, p.human_id) - ), - ]); - }, - (listener) => setInterval(listener, 1000), - (interval) => clearInterval(interval), - undefined, - 3, - ); - - // useEffect(() => { - // persister.startAutoPersisting(); - // return () => { - // persister.startAutoPersisting().then(() => persister.destroy()); - // }; - // }, []); - - const relationships = useCreateRelationships(store, (store) => { - const relationships = createRelationships(store); - - relationships.setRelationshipDefinition( - "humanOrganization", - "humans", - "organizations", - "organization_id", - ); - - relationships.setRelationshipDefinition( - "humanSessions", - "sessions", - "humans", - "user_id", - ); - - relationships.setRelationshipDefinition( - "participantHuman", - "session_participants", - "humans", - "human_id", - ); - - relationships.setRelationshipDefinition( - "participantSession", - "session_participants", - "sessions", - "session_id", - ); - - relationships.setRelationshipDefinition( - "userCalendars", - "calendars", - "humans", - "user_id", - ); - - relationships.setRelationshipDefinition( - "userEvents", - "events", - "humans", - "user_id", - ); - - relationships.setRelationshipDefinition( - "calendarEvents", - "events", - "calendars", - "calendar_id", - ); - - relationships.setRelationshipDefinition( - "sessionEvent", - "sessions", - "events", - "calendar_event_id", - ); - - return relationships; - }); - - useEffect(() => { - const sync = createBroadcastChannelSynchronizer( - store, - "hyprnote-tinybase-sync", - ); - - sync.startSync(); - - return () => { - sync.stopSync().then(() => sync?.destroy()); - }; - }, [store]); - - return ( - - {children} - - ); -} diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index 50b6d87500..f681645eda 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -311,7 +311,7 @@ msgstr "Add a description..." msgid "Add members" msgstr "Add members" -#: src/components/editor-area/note-header/chips/participants-chip.tsx:341 +#: src/components/editor-area/note-header/chips/participants-chip.tsx:311 msgid "Add participant" msgstr "Add participant" @@ -468,7 +468,7 @@ msgid "Continue" msgstr "Continue" #: src/routes/app.human.$id.tsx:532 -#: src/components/editor-area/note-header/chips/participants-chip.tsx:429 +#: src/components/editor-area/note-header/chips/participants-chip.tsx:399 msgid "Create" msgstr "Create" @@ -869,10 +869,9 @@ msgstr "Record me only" msgid "Recording Started" msgstr "Recording Started" -#. placeholder {0}: member.full_name #: src/components/editor-area/note-header/chips/participants-chip.tsx:222 -msgid "Remove {0} from list" -msgstr "Remove {0} from list" +#~ msgid "Remove {0} from list" +#~ msgstr "Remove {0} from list" #: src/components/settings/views/sound.tsx:56 msgid "Requesting..." diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 47e84990b8..701f267aab 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -311,7 +311,7 @@ msgstr "" msgid "Add members" msgstr "" -#: src/components/editor-area/note-header/chips/participants-chip.tsx:341 +#: src/components/editor-area/note-header/chips/participants-chip.tsx:311 msgid "Add participant" msgstr "" @@ -468,7 +468,7 @@ msgid "Continue" msgstr "" #: src/routes/app.human.$id.tsx:532 -#: src/components/editor-area/note-header/chips/participants-chip.tsx:429 +#: src/components/editor-area/note-header/chips/participants-chip.tsx:399 msgid "Create" msgstr "" @@ -869,10 +869,9 @@ msgstr "" msgid "Recording Started" msgstr "" -#. placeholder {0}: member.full_name #: src/components/editor-area/note-header/chips/participants-chip.tsx:222 -msgid "Remove {0} from list" -msgstr "" +#~ msgid "Remove {0} from list" +#~ msgstr "" #: src/components/settings/views/sound.tsx:56 msgid "Requesting..." diff --git a/apps/desktop/src/routes/app.tsx b/apps/desktop/src/routes/app.tsx index d376d86dbf..e43aa80316 100644 --- a/apps/desktop/src/routes/app.tsx +++ b/apps/desktop/src/routes/app.tsx @@ -15,7 +15,6 @@ import { RightPanelProvider, SearchProvider, SettingsProvider, - TinyBaseProvider, useLeftSidebar, useRightPanel, } from "@/contexts"; @@ -43,51 +42,49 @@ function Component() { return ( <> - - - - - - - - - - - -
- -
- + + + + + + + + + + +
+ +
+ - - - - - - - -
+ + + + + + +
- { - commands.setOnboardingNeeded(false); - router.invalidate(); - }} - /> -
-
-
-
-
-
-
-
- +
+ { + commands.setOnboardingNeeded(false); + router.invalidate(); + }} + /> + + + + + + + + {showNotifications && } diff --git a/crates/db-user/src/init.rs b/crates/db-user/src/init.rs index c2b97aab07..0472537c55 100644 --- a/crates/db-user/src/init.rs +++ b/crates/db-user/src/init.rs @@ -168,12 +168,6 @@ pub async fn seed(db: &UserDatabase, user_id: impl Into) -> Result<(), c ..Human::default() }; - let john = Human { - full_name: Some("John Jeong".to_string()), - email: Some("john@hyprnote.com".to_string()), - ..Human::default() - }; - let alex = Human { full_name: Some("Alex Karp".to_string()), email: Some("alex@hyprnote.com".to_string()), @@ -186,13 +180,7 @@ pub async fn seed(db: &UserDatabase, user_id: impl Into) -> Result<(), c ..Human::default() }; - let humans = vec![ - user.clone(), - bobby.clone(), - john.clone(), - alex.clone(), - jenny.clone(), - ]; + let humans = vec![user.clone(), bobby.clone(), alex.clone(), jenny.clone()]; let calendars = vec![Calendar { id: uuid::Uuid::new_v4().to_string(), diff --git a/packages/tiptap/package.json b/packages/tiptap/package.json index 0c905d79e4..d288cc1031 100644 --- a/packages/tiptap/package.json +++ b/packages/tiptap/package.json @@ -17,10 +17,13 @@ "@floating-ui/dom": "^1.7.0", "@hypr/plugin-db": "workspace:^", "@hypr/ui": "workspace:^", + "@hypr/utils": "workspace:^", "@remixicon/react": "^4.6.0", "@sereneinserenade/tiptap-search-and-replace": "^0.1.1", "@tanstack/react-query": "^5.76.1", + "@tanstack/react-router": "^1.120.5", "@tiptap/core": "^2.12.0", + "@tiptap/extension-bubble-menu": "^2.12.0", "@tiptap/extension-document": "^2.12.0", "@tiptap/extension-highlight": "^2.12.0", "@tiptap/extension-history": "^2.12.0", @@ -41,6 +44,7 @@ "@tiptap/suggestion": "^2.12.0", "clsx": "^2.1.1", "prosemirror-commands": "^1.7.1", + "prosemirror-model": "^1.25.1", "prosemirror-state": "^1.4.3", "tippy.js": "^6.3.7", "turndown": "^7.2.0" diff --git a/packages/tiptap/src/styles/transcript.css b/packages/tiptap/src/styles/transcript.css index 5baf7aaa58..d3a26db1e9 100644 --- a/packages/tiptap/src/styles/transcript.css +++ b/packages/tiptap/src/styles/transcript.css @@ -35,9 +35,9 @@ position: relative; display: inline; border-radius: 3px; - cursor: pointer; background-color: transparent; transition: background-color 0.15s ease, box-shadow 0.15s ease; + line-height: 1; } .transcript-word::after { @@ -49,22 +49,14 @@ } .transcript-word:hover { - background-color: #d9e8fb; + background-color: rgba(217, 232, 251, 0.7); box-shadow: 0 0 0 1px #b8d5fa; } -.transcript-word[data-time] { +.ProseMirror[contenteditable="false"] .transcript-word:hover { background-color: transparent; -} - -.transcript-word[data-time]:hover { - background-color: #e6f0ff; - box-shadow: 0 0 0 1px #b8d5fa; -} - -.transcript-word.selected { - background-color: #c0d8f9; - box-shadow: 0 0 0 1px #80b5f8; + box-shadow: none; + cursor: default; } .ProseMirror { diff --git a/packages/tiptap/src/transcript/index.tsx b/packages/tiptap/src/transcript/index.tsx index 1891fb5862..8dbf0b1ba4 100644 --- a/packages/tiptap/src/transcript/index.tsx +++ b/packages/tiptap/src/transcript/index.tsx @@ -2,6 +2,7 @@ import "../styles/transcript.css"; import { SearchAndReplace } from "@sereneinserenade/tiptap-search-and-replace"; import { type Editor as TiptapEditor } from "@tiptap/core"; +import BubbleMenu from "@tiptap/extension-bubble-menu"; import Document from "@tiptap/extension-document"; import History from "@tiptap/extension-history"; import Text from "@tiptap/extension-text"; @@ -11,34 +12,49 @@ import { forwardRef, useEffect } from "react"; import { SpeakerSplit, WordSplit } from "./extensions"; import { SpeakerNode, WordNode } from "./nodes"; import { fromEditorToWords, fromWordsToEditor, type Word } from "./utils"; +import type { SpeakerViewInnerComponent, SpeakerViewInnerProps } from "./views"; + +export { SpeakerViewInnerProps }; interface TranscriptEditorProps { editable?: boolean; - initialWords?: Word[]; + initialWords: Word[] | null; + onUpdate?: (words: Word[]) => void; + c: SpeakerViewInnerComponent; +} + +export interface TranscriptEditorRef { + editor: TiptapEditor | null; + getWords: () => Word[] | null; + setWords: (words: Word[]) => void; } -const TranscriptEditor = forwardRef< - { editor: TiptapEditor | null; getWords: () => Word[] | null }, - TranscriptEditorProps ->( - ({ initialWords, editable = true }, ref) => { +const TranscriptEditor = forwardRef( + ({ editable = true, c, onUpdate, initialWords }, ref) => { const extensions = [ Document.configure({ content: "speaker+" }), History, Text, WordNode, - SpeakerNode, + SpeakerNode(c), WordSplit, SpeakerSplit, SearchAndReplace.configure({ searchResultClass: "search-result", disableRegex: false, }), + BubbleMenu, ]; const editor = useEditor({ extensions, editable, + onUpdate: ({ editor }) => { + if (onUpdate) { + onUpdate(fromEditorToWords(editor.getJSON() as any)); + } + }, + content: initialWords ? fromWordsToEditor(initialWords) : undefined, editorProps: { attributes: { class: "tiptap-transcript", @@ -47,15 +63,22 @@ const TranscriptEditor = forwardRef< }); useEffect(() => { - if (ref && typeof ref === "object") { - (ref as any).current = { + if (ref && typeof ref === "object" && editor) { + ref.current = { editor, + setWords: (words: Word[]) => { + if (!editor) { + return; + } + + const content = fromWordsToEditor(words); + editor.commands.setContent(content); + }, getWords: () => { if (!editor) { return null; } - // @ts-expect-error: tiptap types - return fromEditorToWords(editor.getJSON()); + return fromEditorToWords(editor.getJSON() as any); }, }; } @@ -67,12 +90,6 @@ const TranscriptEditor = forwardRef< } }, [editor, editable]); - useEffect(() => { - if (editor) { - editor.commands.setContent(fromWordsToEditor(initialWords ?? [])); - } - }, [editor, initialWords]); - return (
diff --git a/packages/tiptap/src/transcript/nodes.ts b/packages/tiptap/src/transcript/nodes.ts index 56a7ae94fc..262b43ecd0 100644 --- a/packages/tiptap/src/transcript/nodes.ts +++ b/packages/tiptap/src/transcript/nodes.ts @@ -1,42 +1,234 @@ import { mergeAttributes, Node } from "@tiptap/core"; +import { CommandProps } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +import { Node as ProseNode } from "prosemirror-model"; -import { SpeakerView } from "./views"; +import { createSpeakerView, SpeakerViewInnerComponent } from "./views"; -export interface Speaker { - id: string; - name: string; -} +declare module "@tiptap/core" { + interface Commands { + speaker: { + updateSpeakerIndexToId: ( + speakerIndex: number, + speakerId: string, + speakerLabel?: string, + ) => ReturnType; -export const SpeakerNode = Node.create({ - name: "speaker", - group: "block", - content: "word*", - addAttributes() { - return { - speakerId: { - parseHTML: element => element.getAttribute("data-speaker-id"), - renderHTML: attributes => ({ "data-speaker-id": attributes.speakerId }), - }, + replaceSpeakerIdAtPos: ( + position: number, + newSpeakerId: string, + newSpeakerLabel?: string, + ) => ReturnType; + + replaceAllSpeakerIds: ( + oldSpeakerId: string, + newSpeakerId: string, + newSpeakerLabel?: string, + ) => ReturnType; + + replaceSpeakerIdsBefore: ( + position: number, + oldSpeakerId: string, + newSpeakerId: string, + newSpeakerLabel?: string, + ) => ReturnType; + + replaceSpeakerIdsAfter: ( + position: number, + oldSpeakerId: string, + newSpeakerId: string, + newSpeakerLabel?: string, + ) => ReturnType; }; - }, - parseHTML() { - return [{ tag: "div.transcript-speaker" }]; - }, - renderHTML({ HTMLAttributes, node }) { - return [ - "div", - mergeAttributes({ class: "transcript-speaker" }, HTMLAttributes), - ["div", { class: "transcript-speaker-id" }, node.attrs.speakerId ?? ""], - ]; - }, - addNodeView() { - return ReactNodeViewRenderer(SpeakerView); - }, -}); + } +} + +const implementCommands = { + updateSpeakerIndexToId: + (speakerIndex: number, speakerId: string, speakerLabel: string = "") => ({ tr, dispatch }: CommandProps) => { + if (!dispatch) { + return false; + } + let updated = false; + tr.doc.descendants((node: ProseNode, pos: number) => { + if (node.type.name === "speaker" && node.attrs["speaker-index"] === speakerIndex) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + "speaker-index": null, + "speaker-id": speakerId, + "speaker-label": speakerLabel, + }); + updated = true; + } + return true; + }); + + if (updated) { + dispatch(tr); + return true; + } + return false; + }, + + replaceSpeakerIdAtPos: + (position: number, newSpeakerId: string, newSpeakerLabel: string = "") => ({ tr, dispatch }: CommandProps) => { + if (!dispatch) { + return false; + } + const node = tr.doc.nodeAt(position); + if (!node || node.type.name !== "speaker") { + return false; + } + tr.setNodeMarkup(position, undefined, { + ...node.attrs, + "speaker-id": newSpeakerId, + "speaker-label": newSpeakerLabel, + }); + dispatch(tr); + return true; + }, + + replaceAllSpeakerIds: + (oldSpeakerId: string, newSpeakerId: string, newSpeakerLabel: string = "") => ({ tr, dispatch }: CommandProps) => { + if (!dispatch) { + return false; + } + let updated = false; + tr.doc.descendants((node: ProseNode, pos: number) => { + if (node.type.name === "speaker" && node.attrs["speaker-id"] === oldSpeakerId) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + "speaker-id": newSpeakerId, + "speaker-label": newSpeakerLabel, + }); + updated = true; + } + return true; + }); + if (updated) { + dispatch(tr); + return true; + } + return false; + }, + + replaceSpeakerIdsBefore: + (position: number, oldSpeakerId: string, newSpeakerId: string, newSpeakerLabel: string = "") => + ({ tr, dispatch }: CommandProps) => { + if (!dispatch) { + return false; + } + let updated = false; + tr.doc.descendants((node: ProseNode, pos: number) => { + if (pos >= position) { + return false; + } + if (node.type.name === "speaker" && node.attrs["speaker-id"] === oldSpeakerId) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + "speaker-id": newSpeakerId, + "speaker-label": newSpeakerLabel, + }); + updated = true; + } + return true; + }); + if (updated) { + dispatch(tr); + return true; + } + return false; + }, + + replaceSpeakerIdsAfter: + (position: number, oldSpeakerId: string, newSpeakerId: string, newSpeakerLabel: string = "") => + ({ tr, dispatch }: CommandProps) => { + if (!dispatch) { + return false; + } + let updated = false; + tr.doc.descendants((node: ProseNode, pos: number) => { + if (pos < position) { + return true; + } + if (node.type.name === "speaker" && node.attrs["speaker-id"] === oldSpeakerId) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + "speaker-id": newSpeakerId, + "speaker-label": newSpeakerLabel, + }); + updated = true; + } + return true; + }); + if (updated) { + dispatch(tr); + return true; + } + return false; + }, +}; + +export const SpeakerNode = (c: SpeakerViewInnerComponent) => { + return Node.create({ + name: "speaker", + group: "block", + content: "word*", + addAttributes() { + return { + "speaker-index": { + default: null, + parseHTML: element => { + const v = element.getAttribute("data-speaker-index"); + return v !== null ? Number(v) : null; + }, + renderHTML: attributes => ({ "data-speaker-index": attributes["speaker-index"] }), + }, + "speaker-id": { + default: null, + parseHTML: element => element.getAttribute("data-speaker-id"), + renderHTML: attributes => ({ "data-speaker-id": attributes["speaker-id"] }), + }, + "speaker-label": { + default: null, + parseHTML: element => element.getAttribute("data-speaker-label"), + renderHTML: attributes => ({ "data-speaker-label": attributes["speaker-label"] }), + }, + }; + }, + parseHTML() { + return [{ + tag: "div.transcript-speaker", + attrs: { "data-speaker-index": 0, "data-speaker-id": "", "data-speaker-label": "" }, + }]; + }, + renderHTML({ HTMLAttributes, node }) { + return [ + "div", + mergeAttributes( + { + class: "transcript-speaker", + "data-speaker-index": node.attrs["speaker-index"], + "data-speaker-id": node.attrs["speaker-id"], + "data-speaker-label": node.attrs["speaker-label"], + }, + HTMLAttributes, + ), + ]; + }, + addNodeView() { + return ReactNodeViewRenderer(createSpeakerView(c)); + }, + addCommands() { + return implementCommands as any; // casting because util object is compatible + }, + }); +}; + +const WORD_NODE_NAME = "word"; export const WordNode = Node.create({ - name: "word", + name: WORD_NODE_NAME, group: "inline", inline: true, atom: false, @@ -75,4 +267,44 @@ export const WordNode = Node.create({ renderHTML({ HTMLAttributes }) { return ["span", mergeAttributes({ class: "transcript-word" }, HTMLAttributes), 0]; }, + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { state, view } = editor; + const { selection } = state; + + if (selection.empty || selection.$from.pos === selection.$to.pos) { + return false; + } + + const wordsToDelete = new Set(); + + state.doc.nodesBetween(selection.from, selection.to, (node, pos) => { + if (node.type.name === WORD_NODE_NAME) { + wordsToDelete.add(pos); + } + return true; + }); + + if (wordsToDelete.size > 1) { + const tr = state.tr; + const positions = Array.from(wordsToDelete).sort((a: number, b: number) => b - a); + + for (const pos of positions) { + const $resolvedPos = state.doc.resolve(pos); + const nodeToDelete = $resolvedPos.nodeAfter; + + if (nodeToDelete && nodeToDelete.type.name === "word") { + tr.delete(pos, pos + nodeToDelete.nodeSize); + } + } + + view.dispatch(tr); + return true; + } + + return false; + }, + }; + }, }); diff --git a/packages/tiptap/src/transcript/utils.test.ts b/packages/tiptap/src/transcript/utils.test.ts index b64bdda135..fea2b8b75e 100644 --- a/packages/tiptap/src/transcript/utils.test.ts +++ b/packages/tiptap/src/transcript/utils.test.ts @@ -18,7 +18,37 @@ test("conversion", () => { }, ]; - const editorContent = fromWordsToEditor(words); - const words2 = fromEditorToWords(editorContent); + const editor = fromWordsToEditor(words); + expect(editor).toEqual({ + "type": "doc", + "content": [ + { + "type": "speaker", + "content": [ + { + "attrs": { + "confidence": 0.5, + "end_ms": 1000, + "start_ms": 0, + }, + "content": [ + { + "text": "Hello", + "type": "text", + }, + ], + "type": "word", + }, + ], + "attrs": { + "speaker-id": null, + "speaker-index": 0, + "speaker-label": null, + }, + }, + ], + }); + + const words2 = fromEditorToWords(editor); expect(words2).toEqual(words); }); diff --git a/packages/tiptap/src/transcript/utils.ts b/packages/tiptap/src/transcript/utils.ts index 1ad4dca988..04cbf80f18 100644 --- a/packages/tiptap/src/transcript/utils.ts +++ b/packages/tiptap/src/transcript/utils.ts @@ -1,17 +1,25 @@ import type { SpeakerIdentity, Word } from "@hypr/plugin-db"; -import { EditorContent } from "@tiptap/react"; +import { JSONContent } from "@tiptap/react"; export type { Word }; -type EditorContent = { - type: "doc"; +export type DocContent = { + type: string; content: SpeakerContent[]; }; +const SPEAKER_ID_ATTR = "speaker-id"; +const SPEAKER_INDEX_ATTR = "speaker-index"; +const SPEAKER_LABEL_ATTR = "speaker-label"; + type SpeakerContent = { type: "speaker"; - attrs: { "speaker-index": number | null; "speaker-id": string | null; "speaker-label": string | null }; content: WordContent[]; + attrs: { + [SPEAKER_INDEX_ATTR]: number | null; + [SPEAKER_ID_ATTR]: string | null; + [SPEAKER_LABEL_ATTR]: string | null; + }; }; type WordContent = { @@ -24,7 +32,7 @@ type WordContent = { }; }; -export const fromWordsToEditor = (words: Word[]): EditorContent => { +export const fromWordsToEditor = (words: Word[]): DocContent => { return { type: "doc", content: words.reduce<{ cur: SpeakerIdentity | null; acc: SpeakerContent[] }>((state, word) => { @@ -42,9 +50,9 @@ export const fromWordsToEditor = (words: Word[]): EditorContent => { state.acc.push({ type: "speaker", attrs: { - "speaker-index": word.speaker?.type === "unassigned" ? word.speaker.value?.index : null, - "speaker-id": word.speaker?.type === "assigned" ? word.speaker.value?.id : null, - "speaker-label": word.speaker?.type === "assigned" ? word.speaker.value?.label || "" : null, + [SPEAKER_INDEX_ATTR]: word.speaker?.type === "unassigned" ? word.speaker.value?.index : null, + [SPEAKER_ID_ATTR]: word.speaker?.type === "assigned" ? word.speaker.value?.id : null, + [SPEAKER_LABEL_ATTR]: word.speaker?.type === "assigned" ? word.speaker.value?.label || "" : null, }, content: [], }); @@ -67,7 +75,7 @@ export const fromWordsToEditor = (words: Word[]): EditorContent => { }; }; -export const fromEditorToWords = (content: EditorContent): Word[] => { +export const fromEditorToWords = (content: DocContent | JSONContent): Word[] => { if (!content?.content) { return []; } @@ -80,19 +88,21 @@ export const fromEditorToWords = (content: EditorContent): Word[] => { } let speaker: SpeakerIdentity | null = null; - if (speakerBlock.attrs["speaker-id"]) { + const attrs = speakerBlock.attrs || {}; + + if (attrs[SPEAKER_ID_ATTR]) { speaker = { type: "assigned", value: { - id: speakerBlock.attrs["speaker-id"], - label: speakerBlock.attrs["speaker-label"] ?? "", + id: attrs[SPEAKER_ID_ATTR], + label: attrs[SPEAKER_LABEL_ATTR] ?? "", }, }; - } else if (typeof speakerBlock.attrs["speaker-index"] === "number") { + } else if (typeof attrs[SPEAKER_INDEX_ATTR] === "number") { speaker = { type: "unassigned", value: { - index: speakerBlock.attrs["speaker-index"], + index: attrs[SPEAKER_INDEX_ATTR], }, }; } @@ -101,13 +111,13 @@ export const fromEditorToWords = (content: EditorContent): Word[] => { if (wordBlock.type !== "word" || !wordBlock.content?.[0]?.text) { continue; } - const attrs = wordBlock.attrs || {}; + const wordAttrs = wordBlock.attrs || {}; words.push({ text: wordBlock.content[0].text, speaker, - confidence: attrs.confidence ?? null, - start_ms: attrs.start_ms ?? null, - end_ms: attrs.end_ms ?? null, + confidence: wordAttrs.confidence ?? null, + start_ms: wordAttrs.start_ms ?? null, + end_ms: wordAttrs.end_ms ?? null, }); } } diff --git a/packages/tiptap/src/transcript/views.tsx b/packages/tiptap/src/transcript/views.tsx index 26beff33b6..90b94dd233 100644 --- a/packages/tiptap/src/transcript/views.tsx +++ b/packages/tiptap/src/transcript/views.tsx @@ -1,43 +1,39 @@ -import { useQuery } from "@tanstack/react-query"; +import { type Editor as TiptapEditor } from "@tiptap/core"; import { NodeViewContent, type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { type ComponentType, useState } from "react"; -import { commands as dbCommands, Human } from "@hypr/plugin-db"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; +export const createSpeakerView = (Comp: SpeakerViewInnerComponent): ComponentType => { + return ({ node, updateAttributes, editor }: NodeViewProps) => { + const [speakerId, setSpeakerId] = useState(node.attrs?.["speaker-id"] ?? undefined); + const speakerIndex = node.attrs?.["speaker-index"] ?? undefined; + const speakerLabel = node.attrs?.["speaker-label"] ?? undefined; -export const SpeakerView = ({ node, updateAttributes }: NodeViewProps) => { - const { data: participants } = useQuery({ - queryKey: ["participants", "22393beb-8acf-4577-b210-7211e1700d66"], - queryFn: () => dbCommands.sessionListParticipants("22393beb-8acf-4577-b210-7211e1700d66"), - }); + const onSpeakerIdChange = (speakerId: string) => { + setSpeakerId(speakerId); + updateAttributes({ "speaker-id": speakerId }); + }; - const { speakerId } = node.attrs as { speakerId: string }; - - const displayName = (participants ?? []).find((s) => s.id === speakerId)?.full_name ?? "NOT FOUND"; - - const handleChange = (speakerId: string) => { - updateAttributes({ speakerId }); + return ( + + + + + ); }; +}; - return ( - -
- -
- -
- -
-
- ); +export type SpeakerViewInnerProps = { + speakerId: string | undefined; + speakerIndex: number | undefined; + speakerLabel: string | undefined; + onSpeakerIdChange: (speakerId: string) => void; + editorRef?: TiptapEditor; }; + +export type SpeakerViewInnerComponent = (props: SpeakerViewInnerProps) => JSX.Element; diff --git a/packages/ui/src/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx index fcf6e41fd2..64a1ce0faf 100644 --- a/packages/ui/src/components/ui/popover.tsx +++ b/packages/ui/src/components/ui/popover.tsx @@ -5,7 +5,17 @@ import { cn } from "../../lib/utils"; const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +PopoverTrigger.displayName = PopoverPrimitive.Trigger.displayName; const PopoverArrow = PopoverPrimitive.Arrow; @@ -58,7 +68,7 @@ const PopoverContent = React.forwardRef< "data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2", "data-[side=top]:slide-in-from-bottom-2", - "focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "outline-none focus:outline-none", "transition-all", className, )} diff --git a/plugins/listener/src/fsm.rs b/plugins/listener/src/fsm.rs index 4dc9cee36a..f0dd1fb6c1 100644 --- a/plugins/listener/src/fsm.rs +++ b/plugins/listener/src/fsm.rs @@ -256,15 +256,18 @@ impl Session { futures_util::pin_mut!(listen_stream); while let Some(result) = listen_stream.next().await { - SessionEvent::Words { - words: result.words.clone(), + // We don't have to do this, and inefficient. But this is what works at the moment. + { + let updated_words = update_session(&app, &session.id, result.words) + .await + .unwrap(); + + SessionEvent::Words { + words: updated_words, + } + .emit(&app) } - .emit(&app) .unwrap(); - - update_session(&app, &session.id, result.words) - .await - .unwrap(); } tracing::info!("listen_stream_ended"); @@ -363,7 +366,7 @@ async fn update_session( app: &tauri::AppHandle, session_id: impl Into, words: Vec, -) -> Result<(), crate::Error> { +) -> Result, crate::Error> { use tauri_plugin_db::DatabasePluginExt; // TODO: not ideal. We might want to only do "update" everywhere instead of upserts. @@ -376,7 +379,7 @@ async fn update_session( session.words.extend(words); app.db_upsert_session(session.clone()).await.unwrap(); - Ok(()) + Ok(session.words) } pub enum StateEvent { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 369d79ba39..23ed472252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,9 +303,6 @@ importers: tauri-plugin-sentry-api: specifier: ^0.4.1 version: 0.4.1 - tinybase: - specifier: ^6.1.1 - version: 6.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.18.2) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -499,6 +496,9 @@ importers: '@hypr/ui': specifier: workspace:^ version: link:../ui + '@hypr/utils': + specifier: workspace:^ + version: link:../utils '@remixicon/react': specifier: ^4.6.0 version: 4.6.0(react@19.0.0) @@ -508,9 +508,15 @@ importers: '@tanstack/react-query': specifier: ^5.76.1 version: 5.76.1(react@19.0.0) + '@tanstack/react-router': + specifier: ^1.120.5 + version: 1.120.5(react-dom@18.3.1(react@19.0.0))(react@19.0.0) '@tiptap/core': specifier: ^2.12.0 version: 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/extension-bubble-menu': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) '@tiptap/extension-document': specifier: ^2.12.0 version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) @@ -571,6 +577,9 @@ importers: prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 + prosemirror-model: + specifier: ^1.25.1 + version: 1.25.1 prosemirror-state: specifier: ^1.4.3 version: 1.4.3 @@ -6997,68 +7006,6 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinybase@6.1.1: - resolution: {integrity: sha512-UL+K21Hu5V9ZSW7Por8uTlICnPR+m2/ve9DEOmuDhQwpMnqg9hLgYpthPkJKouql650slxcnRFJyBKIJ254tOw==} - peerDependencies: - '@automerge/automerge-repo': ^1.2.1 - '@cloudflare/workers-types': ^4.20250515.0 - '@electric-sql/pglite': ^0.2.17 - '@libsql/client': ^0.15.6 - '@powersync/common': ^1.30.0 - '@sqlite.org/sqlite-wasm': ^3.49.2-build1 - '@vlcn.io/crsqlite-wasm': ^0.16.0 - bun: ^1.2.13 - electric-sql: ^0.12.1 - expo: ^53.0.9 - expo-sqlite: ^15.2.10 - partykit: ^0.0.114 - partysocket: ^1.1.4 - postgres: ^3.4.5 - react: ^19.0.0 - react-dom: ^19.0.0 - sqlite3: ^5.1.7 - ws: ^8.18.2 - yjs: ^13.6.27 - peerDependenciesMeta: - '@automerge/automerge-repo': - optional: true - '@cloudflare/workers-types': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@powersync/common': - optional: true - '@sqlite.org/sqlite-wasm': - optional: true - '@vlcn.io/crsqlite-wasm': - optional: true - bun: - optional: true - electric-sql: - optional: true - expo: - optional: true - expo-sqlite: - optional: true - partykit: - optional: true - partysocket: - optional: true - postgres: - optional: true - react: - optional: true - react-dom: - optional: true - sqlite3: - optional: true - ws: - optional: true - yjs: - optional: true - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -9780,6 +9727,17 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 + '@tanstack/react-router@1.120.5(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': + dependencies: + '@tanstack/history': 1.115.0 + '@tanstack/react-store': 0.7.0(react-dom@18.3.1(react@19.0.0))(react@19.0.0) + '@tanstack/router-core': 1.120.5 + jsesc: 3.1.0 + react: 19.0.0 + react-dom: 18.3.1(react@19.0.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + '@tanstack/react-store@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/store': 0.7.0 @@ -9787,6 +9745,13 @@ snapshots: react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.5.0(react@18.3.1) + '@tanstack/react-store@0.7.0(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': + dependencies: + '@tanstack/store': 0.7.0 + react: 19.0.0 + react-dom: 18.3.1(react@19.0.0) + use-sync-external-store: 1.5.0(react@19.0.0) + '@tanstack/router-core@1.120.5': dependencies: '@tanstack/history': 1.115.0 @@ -14685,12 +14650,6 @@ snapshots: tiny-warning@1.0.3: {} - tinybase@6.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ws@8.18.2): - optionalDependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - ws: 8.18.2 - tinybench@2.9.0: {} tinyexec@0.3.2: {}