diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx deleted file mode 100644 index 4d7428a7a0..0000000000 --- a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import { X } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import type { SpeakerHintStorage, Transcript } from "@hypr/store"; -import { Badge } from "@hypr/ui/components/ui/badge"; -import { Button } from "@hypr/ui/components/ui/button"; -import { cn } from "@hypr/utils"; - -import * as main from "../../../../../../store/tinybase/main"; -import { useTabs } from "../../../../../../store/zustand/tabs/index"; - -function createHuman(store: any, userId: string, name: string) { - const humanId = crypto.randomUUID(); - store.setRow("humans", humanId, { - user_id: userId, - created_at: new Date().toISOString(), - name, - email: "", - org_id: "", - job_title: "", - linkedin_username: "", - is_user: false, - memo: "", - }); - return humanId; -} - -function linkHumanToSession( - store: any, - userId: string, - sessionId: string, - humanId: string, -) { - const mappingId = crypto.randomUUID(); - store.setRow("mapping_session_participant", mappingId, { - user_id: userId, - created_at: new Date().toISOString(), - session_id: sessionId, - human_id: humanId, - }); -} - -function createAndLinkHuman( - store: any, - userId: string, - sessionId: string, - name: string, -) { - const humanId = createHuman(store, userId, name); - linkHumanToSession(store, userId, sessionId, humanId); - return humanId; -} - -export function ParticipantsDisplay({ sessionId }: { sessionId: string }) { - const mappingIds = main.UI.useSliceRowIds( - main.INDEXES.sessionParticipantsBySession, - sessionId, - main.STORE_ID, - ) as string[]; - - return ( -
-
- -
- ); -} - -function useParticipantDetails(mappingId: string) { - const result = main.UI.useResultRow( - main.QUERIES.sessionParticipantsWithDetails, - mappingId, - main.STORE_ID, - ); - - if (!result) { - return null; - } - - return { - mappingId, - humanId: result.human_id as string, - humanName: (result.human_name as string) || "", - humanEmail: (result.human_email as string | undefined) || undefined, - humanJobTitle: (result.human_job_title as string | undefined) || undefined, - humanLinkedinUsername: - (result.human_linkedin_username as string | undefined) || undefined, - humanIsUser: result.human_is_user as boolean, - orgId: (result.org_id as string | undefined) || undefined, - orgName: result.org_name as string | undefined, - sessionId: result.session_id as string, - }; -} - -function parseHumanIdFromHintValue(value: unknown): string | undefined { - const data = - typeof value === "string" - ? (() => { - try { - return JSON.parse(value); - } catch { - return undefined; - } - })() - : value; - - if (data && typeof data === "object" && "human_id" in data) { - const humanId = (data as Record).human_id; - return typeof humanId === "string" ? humanId : undefined; - } - - return undefined; -} - -function useRemoveParticipant({ - mappingId, - assignedHumanId, - sessionId, -}: { - mappingId: string; - assignedHumanId: string | undefined; - sessionId: string | undefined; -}) { - const store = main.UI.useStore(main.STORE_ID); - - return useCallback(() => { - if (!store) { - return; - } - - if (assignedHumanId && sessionId) { - const hintIdsToDelete: string[] = []; - - store.forEachRow("speaker_hints", (hintId, _forEachCell) => { - const hint = store.getRow("speaker_hints", hintId) as - | SpeakerHintStorage - | undefined; - if (!hint || hint.type !== "user_speaker_assignment") { - return; - } - - const transcriptId = hint.transcript_id; - if (typeof transcriptId !== "string") { - return; - } - - const transcript = store.getRow("transcripts", transcriptId) as - | Transcript - | undefined; - if (!transcript || transcript.session_id !== sessionId) { - return; - } - - const hintHumanId = parseHumanIdFromHintValue(hint.value); - if (hintHumanId === assignedHumanId) { - hintIdsToDelete.push(hintId); - } - }); - - hintIdsToDelete.forEach((hintId) => { - store.delRow("speaker_hints", hintId); - }); - } - - store.delRow("mapping_session_participant", mappingId); - }, [store, mappingId, assignedHumanId, sessionId]); -} - -function ParticipantChip({ mappingId }: { mappingId: string }) { - const details = useParticipantDetails(mappingId); - const openNew = useTabs.getState().openNew; - - const assignedHumanId = details?.humanId; - const sessionId = details?.sessionId; - - const handleRemove = useRemoveParticipant({ - mappingId, - assignedHumanId, - sessionId, - }); - - const handleClick = useCallback(() => { - if (assignedHumanId) { - openNew({ - type: "contacts", - state: { selectedOrganization: null, selectedPerson: assignedHumanId }, - }); - } - }, [openNew, assignedHumanId]); - - if (!details) { - return null; - } - - const { humanName } = details; - - return ( - - {humanName || "Unknown"} - - - ); -} - -function ParticipantChipInput({ - sessionId, - mappingIds, -}: { - sessionId: string; - mappingIds: string[]; -}) { - const [inputValue, setInputValue] = useState(""); - const [showDropdown, setShowDropdown] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef(null); - const containerRef = useRef(null); - const store = main.UI.useStore(main.STORE_ID); - const userId = main.UI.useValue("user_id", main.STORE_ID); - const allHumanIds = main.UI.useRowIds("humans", main.STORE_ID) as string[]; - const queries = main.UI.useQueries(main.STORE_ID); - - const existingHumanIds = useMemo(() => { - if (!queries) { - return new Set(); - } - - const ids = new Set(); - for (const mappingId of mappingIds) { - const result = queries.getResultRow( - main.QUERIES.sessionParticipantsWithDetails, - mappingId, - ); - if (result?.human_id) { - ids.add(result.human_id as string); - } - } - return ids; - }, [mappingIds, queries]); - - const candidates = useMemo(() => { - const searchLower = inputValue.toLowerCase(); - return allHumanIds - .filter((humanId: string) => !existingHumanIds.has(humanId)) - .map((humanId: string) => { - const human = store?.getRow("humans", humanId); - if (!human) { - return null; - } - - const name = (human.name || "") as string; - const email = (human.email || "") as string; - const nameMatch = name.toLowerCase().includes(searchLower); - const emailMatch = email.toLowerCase().includes(searchLower); - - if (inputValue && !nameMatch && !emailMatch) { - return null; - } - - return { - id: humanId, - name, - email, - orgId: human.org_id as string | undefined, - jobTitle: human.job_title as string | undefined, - isNew: false, - }; - }) - .filter((h): h is NonNullable => h !== null); - }, [inputValue, allHumanIds, existingHumanIds, store]); - - const showCustomOption = - inputValue.trim() && - !candidates.some((c) => c.name.toLowerCase() === inputValue.toLowerCase()); - - const dropdownOptions = showCustomOption - ? [ - { - id: "new", - name: inputValue.trim(), - isNew: true, - email: "", - orgId: undefined, - jobTitle: undefined, - }, - ...candidates, - ] - : candidates; - - const handleAddParticipant = useCallback( - (option: { - id: string; - name: string; - isNew?: boolean; - email?: string; - orgId?: string; - jobTitle?: string; - }) => { - if (!store || !userId) { - return; - } - - if (option.isNew) { - createAndLinkHuman(store, userId, sessionId, option.name); - } else { - linkHumanToSession(store, userId, sessionId, option.id); - } - - setInputValue(""); - setShowDropdown(false); - setSelectedIndex(0); - inputRef.current?.focus(); - }, - [store, userId, sessionId], - ); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && inputValue.trim()) { - e.preventDefault(); - if (dropdownOptions.length > 0) { - handleAddParticipant(dropdownOptions[selectedIndex]); - } - } else if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((prev) => - prev < dropdownOptions.length - 1 ? prev + 1 : prev, - ); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (e.key === "Escape") { - setShowDropdown(false); - setSelectedIndex(0); - } else if (e.key === "Backspace" && !inputValue && mappingIds.length > 0) { - const lastMappingId = mappingIds[mappingIds.length - 1]; - if (store) { - store.delRow("mapping_session_participant", lastMappingId); - } - } - }; - - const handleInputChange = (value: string) => { - setInputValue(value); - setShowDropdown(true); - setSelectedIndex(0); - }; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setShowDropdown(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - return ( -
-
inputRef.current?.focus()} - > - {mappingIds.map((mappingId) => ( - - ))} - handleInputChange(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={() => setShowDropdown(true)} - /> -
- {showDropdown && inputValue.trim() && dropdownOptions.length > 0 && ( -
-
- {dropdownOptions.map((option, index) => ( - - ))} -
-
- )} -
- ); -} diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx new file mode 100644 index 0000000000..17b859d9ab --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx @@ -0,0 +1,158 @@ +import { X } from "lucide-react"; +import { useCallback } from "react"; + +import type { SpeakerHintStorage } from "@hypr/store"; +import { Badge } from "@hypr/ui/components/ui/badge"; +import { Button } from "@hypr/ui/components/ui/button"; + +import * as main from "../../../../../../../store/tinybase/main"; +import { useTabs } from "../../../../../../../store/zustand/tabs/index"; + +export function ParticipantChip({ mappingId }: { mappingId: string }) { + const details = useParticipantDetails(mappingId); + + const assignedHumanId = details?.humanId; + const sessionId = details?.sessionId; + + const handleRemove = useRemoveParticipant({ + mappingId, + assignedHumanId, + sessionId, + }); + + const handleClick = useCallback(() => { + if (assignedHumanId) { + useTabs.getState().openNew({ + type: "contacts", + state: { selectedOrganization: null, selectedPerson: assignedHumanId }, + }); + } + }, [assignedHumanId]); + + if (!details) { + return null; + } + + const { humanName } = details; + + return ( + + {humanName || "Unknown"} + + + ); +} + +function useParticipantDetails(mappingId: string) { + const result = main.UI.useResultRow( + main.QUERIES.sessionParticipantsWithDetails, + mappingId, + main.STORE_ID, + ); + + if (!result) { + return null; + } + + return { + mappingId, + humanId: result.human_id as string, + humanName: (result.human_name as string) || "", + humanEmail: (result.human_email as string | undefined) || undefined, + humanJobTitle: (result.human_job_title as string | undefined) || undefined, + humanLinkedinUsername: + (result.human_linkedin_username as string | undefined) || undefined, + humanIsUser: result.human_is_user as boolean, + orgId: (result.org_id as string | undefined) || undefined, + orgName: result.org_name as string | undefined, + sessionId: result.session_id as string, + }; +} + +function parseHumanIdFromHintValue(value: unknown): string | undefined { + let data = value; + if (typeof value === "string") { + try { + data = JSON.parse(value); + } catch { + return undefined; + } + } + + if (data && typeof data === "object" && "human_id" in data) { + const humanId = (data as Record).human_id; + return typeof humanId === "string" ? humanId : undefined; + } + + return undefined; +} + +function useRemoveParticipant({ + mappingId, + assignedHumanId, + sessionId, +}: { + mappingId: string; + assignedHumanId: string | undefined; + sessionId: string | undefined; +}) { + const store = main.UI.useStore(main.STORE_ID); + const indexes = main.UI.useIndexes(main.STORE_ID); + + return useCallback(() => { + if (!store) { + return; + } + + if (assignedHumanId && sessionId && indexes) { + const hintIdsToDelete: string[] = []; + + const transcriptIds = indexes.getSliceRowIds( + main.INDEXES.transcriptBySession, + sessionId, + ); + + for (const transcriptId of transcriptIds) { + const hintIds = indexes.getSliceRowIds( + main.INDEXES.speakerHintsByTranscript, + transcriptId, + ); + + for (const hintId of hintIds) { + const hint = store.getRow("speaker_hints", hintId) as + | SpeakerHintStorage + | undefined; + if (!hint || hint.type !== "user_speaker_assignment") { + continue; + } + + const hintHumanId = parseHumanIdFromHintValue(hint.value); + if (hintHumanId === assignedHumanId) { + hintIdsToDelete.push(hintId); + } + } + } + + for (const hintId of hintIdsToDelete) { + store.delRow("speaker_hints", hintId); + } + } + + store.delRow("mapping_session_participant", mappingId); + }, [store, indexes, mappingId, assignedHumanId, sessionId]); +} diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx new file mode 100644 index 0000000000..1c750a423d --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx @@ -0,0 +1,61 @@ +import { cn } from "@hypr/utils"; + +type DropdownOption = { + id: string; + name: string; + isNew?: boolean; + email?: string; + orgId?: string; + jobTitle?: string; +}; + +export function ParticipantDropdown({ + options, + selectedIndex, + onSelect, + onHover, +}: { + options: DropdownOption[]; + selectedIndex: number; + onSelect: (option: DropdownOption) => void; + onHover: (index: number) => void; +}) { + if (options.length === 0) { + return null; + } + + return ( +
+
+ {options.map((option, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx new file mode 100644 index 0000000000..9c82602832 --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx @@ -0,0 +1,10 @@ +import { ParticipantInput } from "./input"; + +export function ParticipantsDisplay({ sessionId }: { sessionId: string }) { + return ( +
+
+ +
+ ); +} diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx new file mode 100644 index 0000000000..dce951b133 --- /dev/null +++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx @@ -0,0 +1,338 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useAutoCloser } from "../../../../../../../hooks/useAutoCloser"; +import * as main from "../../../../../../../store/tinybase/main"; +import { ParticipantChip } from "./chip"; +import { ParticipantDropdown } from "./dropdown"; + +export function ParticipantInput({ sessionId }: { sessionId: string }) { + const { + inputValue, + showDropdown, + setShowDropdown, + selectedIndex, + setSelectedIndex, + mappingIds, + dropdownOptions, + handleAddParticipant, + handleInputChange, + deleteLastParticipant, + resetInput, + } = useParticipantInput(sessionId); + + const inputRef = useRef(null); + const containerRef = useAutoCloser(() => setShowDropdown(false), { + esc: false, + outside: true, + }); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue.trim()) { + e.preventDefault(); + if (dropdownOptions.length > 0) { + handleAddParticipant(dropdownOptions[selectedIndex]); + inputRef.current?.focus(); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => + prev < dropdownOptions.length - 1 ? prev + 1 : prev, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === "Escape") { + resetInput(); + } else if (e.key === "Backspace" && !inputValue) { + deleteLastParticipant(); + } + }; + + const handleSelect = (option: Candidate) => { + handleAddParticipant(option); + inputRef.current?.focus(); + }; + + return ( +
+
inputRef.current?.focus()} + > + {mappingIds.map((mappingId) => ( + + ))} + + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => setShowDropdown(true)} + /> +
+ + {showDropdown && inputValue.trim() && ( + + )} +
+ ); +} + +type Candidate = { + id: string; + name: string; + email?: string; + orgId?: string; + jobTitle?: string; + isNew?: boolean; +}; + +function useSessionParticipants(sessionId: string) { + const queries = main.UI.useQueries(main.STORE_ID); + + const mappingIds = main.UI.useSliceRowIds( + main.INDEXES.sessionParticipantsBySession, + sessionId, + main.STORE_ID, + ) as string[]; + + const existingHumanIds = useMemo(() => { + if (!queries) { + return new Set(); + } + + const ids = new Set(); + for (const mappingId of mappingIds) { + const result = queries.getResultRow( + main.QUERIES.sessionParticipantsWithDetails, + mappingId, + ); + if (result?.human_id) { + ids.add(result.human_id as string); + } + } + return ids; + }, [mappingIds, queries]); + + return { mappingIds, existingHumanIds }; +} + +function useCandidateSearch( + inputValue: string, + existingHumanIds: Set, +): Candidate[] { + const store = main.UI.useStore(main.STORE_ID); + const allHumanIds = main.UI.useRowIds("humans", main.STORE_ID) as string[]; + + return useMemo(() => { + const searchLower = inputValue.toLowerCase(); + return allHumanIds + .filter((humanId: string) => !existingHumanIds.has(humanId)) + .map((humanId: string) => { + const human = store?.getRow("humans", humanId); + if (!human) { + return null; + } + + const name = (human.name || "") as string; + const email = (human.email || "") as string; + const nameMatch = name.toLowerCase().includes(searchLower); + const emailMatch = email.toLowerCase().includes(searchLower); + + if (inputValue && !nameMatch && !emailMatch) { + return null; + } + + return { + id: humanId, + name, + email, + orgId: human.org_id as string | undefined, + jobTitle: human.job_title as string | undefined, + isNew: false, + }; + }) + .filter((h): h is NonNullable => h !== null); + }, [inputValue, allHumanIds, existingHumanIds, store]); +} + +function useDropdownOptions( + inputValue: string, + candidates: Candidate[], +): Candidate[] { + return useMemo(() => { + const showCustomOption = + inputValue.trim() && + !candidates.some( + (c) => c.name.toLowerCase() === inputValue.toLowerCase(), + ); + + if (!showCustomOption) { + return candidates; + } + + return [ + { + id: "new", + name: inputValue.trim(), + isNew: true, + email: "", + orgId: undefined, + jobTitle: undefined, + }, + ...candidates, + ]; + }, [inputValue, candidates]); +} + +function useParticipantMutations(sessionId: string, mappingIds: string[]) { + const store = main.UI.useStore(main.STORE_ID); + const userId = main.UI.useValue("user_id", main.STORE_ID); + + const createHuman = useCreateHuman(userId || ""); + const linkHumanToSession = useLinkHumanToSession(userId || "", sessionId); + + const addParticipant = useCallback( + (option: Candidate) => { + if (!userId) { + return; + } + + if (option.isNew) { + const humanId = createHuman(option.name); + linkHumanToSession(humanId); + } else { + linkHumanToSession(option.id); + } + }, + [userId, createHuman, linkHumanToSession], + ); + + const deleteLastParticipant = useCallback(() => { + if (mappingIds.length > 0 && store) { + const lastMappingId = mappingIds[mappingIds.length - 1]; + store.delRow("mapping_session_participant", lastMappingId); + } + }, [mappingIds, store]); + + return { addParticipant, deleteLastParticipant }; +} + +function useParticipantInput(sessionId: string) { + const [inputValue, setInputValue] = useState(""); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const { mappingIds, existingHumanIds } = useSessionParticipants(sessionId); + const candidates = useCandidateSearch(inputValue, existingHumanIds); + const dropdownOptions = useDropdownOptions(inputValue, candidates); + const { addParticipant, deleteLastParticipant } = useParticipantMutations( + sessionId, + mappingIds, + ); + + useEffect(() => { + if (selectedIndex >= dropdownOptions.length && dropdownOptions.length > 0) { + setSelectedIndex(dropdownOptions.length - 1); + } else if (dropdownOptions.length === 0) { + setSelectedIndex(0); + } + }, [dropdownOptions.length, selectedIndex]); + + const resetInput = useCallback(() => { + setInputValue(""); + setShowDropdown(false); + setSelectedIndex(0); + }, []); + + const handleAddParticipant = useCallback( + (option: Candidate) => { + addParticipant(option); + resetInput(); + }, + [addParticipant, resetInput], + ); + + const handleInputChange = useCallback((value: string) => { + setInputValue(value); + setShowDropdown(true); + setSelectedIndex(0); + }, []); + + return { + inputValue, + showDropdown, + setShowDropdown, + selectedIndex, + setSelectedIndex, + mappingIds, + dropdownOptions, + handleAddParticipant, + handleInputChange, + deleteLastParticipant, + resetInput, + }; +} + +function useLinkHumanToSession( + userId: string, + sessionId: string, +): (humanId: string) => void { + const linkMapping = main.UI.useSetRowCallback( + "mapping_session_participant", + () => crypto.randomUUID(), + (p: { humanId: string }) => ({ + user_id: userId, + created_at: new Date().toISOString(), + session_id: sessionId, + human_id: p.humanId, + }), + [userId, sessionId], + main.STORE_ID, + ); + + return useCallback( + (humanId: string) => { + linkMapping({ humanId }); + }, + [linkMapping], + ); +} + +function useCreateHuman(userId: string): (name: string) => string { + const createHuman = main.UI.useSetRowCallback( + "humans", + (p: { name: string; humanId: string }) => p.humanId, + (p: { name: string; humanId: string }) => ({ + user_id: userId, + created_at: new Date().toISOString(), + name: p.name, + email: "", + org_id: "", + job_title: "", + linkedin_username: "", + is_user: false, + memo: "", + }), + [userId], + main.STORE_ID, + ); + + return useCallback( + (name: string) => { + const humanId = crypto.randomUUID(); + createHuman({ name, humanId }); + return humanId; + }, + [createHuman], + ); +} diff --git a/apps/desktop/src/hooks/useAutoCloser.ts b/apps/desktop/src/hooks/useAutoCloser.ts index 0a9e3c73a4..190286c9b4 100644 --- a/apps/desktop/src/hooks/useAutoCloser.ts +++ b/apps/desktop/src/hooks/useAutoCloser.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useOnClickOutside } from "usehooks-ts"; export function useAutoCloser( onClose: () => void, @@ -11,30 +12,14 @@ export function useAutoCloser( outside?: boolean; }, ) { - const ref = useRef(null); + const ref = useRef(null!); const handleClose = useCallback(() => { onClose(); }, [onClose]); useHotkeys("esc", handleClose, { enabled: esc }, [handleClose]); - - useEffect(() => { - if (!outside) { - return; - } - - const handleClickOutside = (event: MouseEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) { - handleClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [handleClose, outside]); + useOnClickOutside(ref, outside ? handleClose : () => {}); return ref; }