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
index 75f4b2150f..054087b21c 100644
--- 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
@@ -1,29 +1,11 @@
-import {
- CircleMinus,
- CornerDownLeft,
- Linkedin,
- MailIcon,
- SearchIcon,
-} from "lucide-react";
+import { X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useShallow } from "zustand/shallow";
-import { Avatar, AvatarFallback } from "@hypr/ui/components/ui/avatar";
+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";
-
-const NO_ORGANIZATION_ID = "__NO_ORGANIZATION__";
-
-function getInitials(name: string): string {
- return name
- .split(" ")
- .filter((part) => part.length > 0)
- .slice(0, 2)
- .map((part) => part[0].toUpperCase())
- .join("");
-}
function createHuman(store: any, userId: string, name: string) {
const humanId = crypto.randomUUID();
@@ -74,100 +56,14 @@ export function ParticipantsDisplay({ sessionId }: { sessionId: string }) {
main.STORE_ID,
) as string[];
- const grouped = useGroupedParticipants(sessionId);
-
- if (mappingIds.length === 0) {
- return (
-
- );
- }
-
return (
-
- {grouped.map(({ orgId, orgName, mappingIds }) => (
-
-
- {orgName ?? "No organization"}
-
-
- {mappingIds.map((mappingId) => (
-
- ))}
-
-
- ))}
-
-
+
);
}
-function useGroupedParticipants(sessionId: string) {
- const mappingIds = main.UI.useSliceRowIds(
- main.INDEXES.sessionParticipantsBySession,
- sessionId,
- main.STORE_ID,
- ) as string[];
-
- const queries = main.UI.useQueries(main.STORE_ID);
-
- return useMemo(() => {
- if (!queries) {
- return [];
- }
-
- const participantsByOrg: Record<
- string,
- {
- mappingId: string;
- orgId: string | undefined;
- orgName: string | undefined;
- }[]
- > = {};
-
- for (const mappingId of mappingIds) {
- const result = queries.getResultRow(
- main.QUERIES.sessionParticipantsWithDetails,
- mappingId,
- );
-
- if (!result) {
- continue;
- }
-
- const orgId = (result.org_id as string | undefined) || undefined;
- const orgName = result.org_name as string | undefined;
-
- const key = orgId ?? NO_ORGANIZATION_ID;
- if (!participantsByOrg[key]) {
- participantsByOrg[key] = [];
- }
- participantsByOrg[key].push({ mappingId, orgId, orgName });
- }
-
- return Object.entries(participantsByOrg)
- .map(([orgId, items]) => ({
- orgId,
- orgName: items[0]?.orgName,
- mappingIds: items.map((item) => item.mappingId),
- }))
- .sort((a, b) => {
- if (!a.orgName && b.orgName) {
- return 1;
- }
- if (a.orgName && !b.orgName) {
- return -1;
- }
- return (a.orgName || "").localeCompare(b.orgName || "");
- });
- }, [mappingIds, queries]);
-}
-
function useParticipantDetails(mappingId: string) {
const result = main.UI.useResultRow(
main.QUERIES.sessionParticipantsWithDetails,
@@ -268,17 +164,8 @@ function useRemoveParticipant({
}, [store, mappingId, assignedHumanId, sessionId]);
}
-function ParticipantItem({ mappingId }: { mappingId: string }) {
- const userId = main.UI.useValue("user_id", main.STORE_ID);
+function ParticipantChip({ mappingId }: { mappingId: string }) {
const details = useParticipantDetails(mappingId);
- const { tabs, openNew, updateContactsTabState, select } = useTabs(
- useShallow((state) => ({
- tabs: state.tabs,
- openNew: state.openNew,
- updateContactsTabState: state.updateContactsTabState,
- select: state.select,
- })),
- );
const assignedHumanId = details?.humanId;
const sessionId = details?.sessionId;
@@ -289,335 +176,49 @@ function ParticipantItem({ mappingId }: { mappingId: string }) {
sessionId,
});
- const handleOpenContact = useCallback(
- (humanId: string) => {
- const existingContactsTab = tabs.find((tab) => tab.type === "contacts");
-
- if (existingContactsTab) {
- updateContactsTabState(existingContactsTab, {
- selectedPerson: humanId,
- selectedOrganization: null,
- });
- select(existingContactsTab);
- } else {
- openNew({ type: "contacts", state: { selectedPerson: humanId } });
- }
- },
- [tabs, updateContactsTabState, select, openNew],
- );
-
if (!details) {
return null;
}
- const {
- humanId,
- humanName,
- humanEmail,
- humanJobTitle,
- humanLinkedinUsername,
- } = details;
+ const { humanName } = details;
return (
-
handleOpenContact(humanId)}
- className={cn([
- "flex items-center justify-between gap-2 py-2 px-3",
- "hover:bg-neutral-100 cursor-pointer group transition-colors",
- ])}
+
-
-
-
-
-
- {humanName ? getInitials(humanName) : "?"}
-
-
-
-
{
- e.stopPropagation();
- handleRemove();
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- e.stopPropagation();
- handleRemove();
- }
- }}
- className={cn([
- "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",
- ])}
- >
-
-
-
-
- {humanName ? (
-
- {humanName}
-
- ) : (
-
- {humanId === userId ? "You" : "Unknown"}
-
- )}
- {humanJobTitle && (
-
- {humanJobTitle}
-
- )}
-
-
-
-
-
- );
-}
-
-function ParticipantAddControl({ sessionId }: { sessionId: string }) {
- const [searchInput, setSearchInput] = useState("");
- const [selectedIndex, setSelectedIndex] = useState(-1);
- const inputRef = useRef
(null);
- const store = main.UI.useStore(main.STORE_ID);
- const userId = main.UI.useValue("user_id", main.STORE_ID);
-
- const normalizedQuery = searchInput.trim();
-
- useEffect(() => {
- if (inputRef.current) {
- inputRef.current.focus();
- }
- }, []);
-
- const handleCreateNew = useCallback(
- (name: string) => {
- if (!store || !userId) {
- return;
- }
-
- createAndLinkHuman(store, userId, sessionId, name);
- setSearchInput("");
- },
- [store, userId, sessionId],
- );
-
- const handleSubmit = (e: React.SyntheticEvent) => {
- e.preventDefault();
-
- if (normalizedQuery === "") {
- return;
- }
-
- handleCreateNew(normalizedQuery);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && selectedIndex === -1) {
- e.preventDefault();
- if (normalizedQuery === "") {
- return;
- }
- handleCreateNew(normalizedQuery);
- }
- };
-
- return (
-
+ {humanName || "Unknown"}
+
+
);
}
-function useParticipantCandidateKeyboardNav({
- query,
- sessionId,
- selectedIndex,
- onSelectedIndexChange,
- totalItems,
- candidateCount,
- candidates,
- onMutation,
- inputRef,
- store,
- userId,
-}: {
- query: string;
- sessionId: string;
- selectedIndex: number;
- onSelectedIndexChange: (index: number) => void;
- totalItems: number;
- candidateCount: number;
- candidates: Array<{ id: string; name: string }>;
- onMutation: () => void;
- inputRef: React.RefObject;
- store: any;
- userId: string | undefined;
-}) {
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (!query || totalItems === 0) {
- return;
- }
-
- if (e.key === "ArrowDown") {
- e.preventDefault();
- onSelectedIndexChange(
- selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0,
- );
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- onSelectedIndexChange(
- selectedIndex > 0 ? selectedIndex - 1 : totalItems - 1,
- );
- } else if (e.key === "Enter" && selectedIndex >= 0) {
- e.preventDefault();
- if (selectedIndex < candidateCount) {
- const candidate = candidates[selectedIndex];
- if (candidate && userId && store) {
- linkHumanToSession(store, userId, sessionId, candidate.id);
- onMutation();
- }
- } else {
- if (store && userId) {
- createAndLinkHuman(store, userId, sessionId, query);
- onMutation();
- }
- }
- } else if (e.key === "Escape") {
- onSelectedIndexChange(-1);
- inputRef.current?.focus();
- }
- };
-
- if (inputRef.current === document.activeElement && totalItems > 0) {
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }
- }, [
- selectedIndex,
- totalItems,
- candidateCount,
- query,
- candidates,
- onSelectedIndexChange,
- onMutation,
- inputRef,
- sessionId,
- store,
- userId,
- ]);
-
- useEffect(() => {
- onSelectedIndexChange(-1);
- }, [query, onSelectedIndexChange]);
-}
-
-function ParticipantCandidates({
- query,
+function ParticipantChipInput({
sessionId,
- onMutation,
- selectedIndex,
- onSelectedIndexChange,
- inputRef,
+ mappingIds,
}: {
- query: string;
sessionId: string;
- onMutation: () => void;
- selectedIndex: number;
- onSelectedIndexChange: (index: number) => void;
- inputRef: React.RefObject;
+ 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 existingParticipantIds = main.UI.useSliceRowIds(
- main.INDEXES.sessionParticipantsBySession,
- sessionId,
- main.STORE_ID,
- ) as string[];
-
const queries = main.UI.useQueries(main.STORE_ID);
const existingHumanIds = useMemo(() => {
@@ -626,7 +227,7 @@ function ParticipantCandidates({
}
const ids = new Set();
- for (const mappingId of existingParticipantIds) {
+ for (const mappingId of mappingIds) {
const result = queries.getResultRow(
main.QUERIES.sessionParticipantsWithDetails,
mappingId,
@@ -636,14 +237,10 @@ function ParticipantCandidates({
}
}
return ids;
- }, [existingParticipantIds, queries]);
+ }, [mappingIds, queries]);
const candidates = useMemo(() => {
- if (!query) {
- return [];
- }
-
- const searchLower = query.toLowerCase();
+ const searchLower = inputValue.toLowerCase();
return allHumanIds
.filter((humanId: string) => !existingHumanIds.has(humanId))
.map((humanId: string) => {
@@ -657,7 +254,7 @@ function ParticipantCandidates({
const nameMatch = name.toLowerCase().includes(searchLower);
const emailMatch = email.toLowerCase().includes(searchLower);
- if (!nameMatch && !emailMatch) {
+ if (inputValue && !nameMatch && !emailMatch) {
return null;
}
@@ -667,144 +264,155 @@ function ParticipantCandidates({
email,
orgId: human.org_id as string | undefined,
jobTitle: human.job_title as string | undefined,
+ isNew: false,
};
})
.filter((h): h is NonNullable => h !== null);
- }, [query, allHumanIds, existingHumanIds, store]);
-
- const candidateCount = candidates.length;
- const hasCreateOption = candidateCount === 0 && query;
- const totalItems = candidateCount + (hasCreateOption ? 1 : 0);
-
- useParticipantCandidateKeyboardNav({
- query,
- sessionId,
- selectedIndex,
- onSelectedIndexChange,
- totalItems,
- candidateCount,
- candidates,
- onMutation,
- inputRef,
- store,
- userId,
- });
-
- const handleCreateClick = useCallback(() => {
- if (!store || !userId) {
- return;
- }
-
- createAndLinkHuman(store, userId, sessionId, query);
- onMutation();
- }, [store, userId, sessionId, query, onMutation]);
-
- const handleSelectCandidate = useCallback(
- (candidateId: string) => {
+ }, [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;
}
- linkHumanToSession(store, userId, sessionId, candidateId);
- onMutation();
+ if (option.isNew) {
+ createAndLinkHuman(store, userId, sessionId, option.name);
+ } else {
+ linkHumanToSession(store, userId, sessionId, option.id);
+ }
+
+ setInputValue("");
+ setShowDropdown(false);
+ setSelectedIndex(0);
},
- [store, userId, sessionId, onMutation],
+ [store, userId, sessionId],
);
- if (!query) {
- return null;
- }
+ 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);
+ }
+ }
+ };
- return (
-
- {candidates.map(
- (
- candidate: {
- id: string;
- name: string;
- email: string;
- orgId: string | undefined;
- jobTitle: string | undefined;
- },
- index: number,
- ) => (
-
handleSelectCandidate(candidate.id)}
- />
- ),
- )}
+ const handleInputChange = (value: string) => {
+ setInputValue(value);
+ setShowDropdown(true);
+ setSelectedIndex(0);
+ };
- {hasCreateOption && (
-
- )}
-
- );
-}
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(event.target as Node)
+ ) {
+ setShowDropdown(false);
+ }
+ };
-function ParticipantCandidate({
- candidate,
- isSelected = false,
- onSelect,
-}: {
- candidate: {
- id: string;
- name: string;
- email: string;
- orgId: string | undefined;
- jobTitle: string | undefined;
- };
- isSelected?: boolean;
- onSelect: () => void;
-}) {
- const org = candidate.orgId
- ? main.UI.useRow("organizations", candidate.orgId, main.STORE_ID)
- : undefined;
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
return (
-