diff --git a/apps/desktop/src/components/devtool/seed/shared/organization.ts b/apps/desktop/src/components/devtool/seed/shared/organization.ts index 5ce1fa687e..c988176675 100644 --- a/apps/desktop/src/components/devtool/seed/shared/organization.ts +++ b/apps/desktop/src/components/devtool/seed/shared/organization.ts @@ -9,5 +9,6 @@ export const createOrganization = () => ({ data: { user_id: DEFAULT_USER_ID, name: faker.company.name(), + pinned: false, } satisfies Organization, }); diff --git a/apps/desktop/src/components/main/body/advanced-search/index.tsx b/apps/desktop/src/components/main/body/advanced-search/index.tsx index c863a0d440..89cf4aa1e7 100644 --- a/apps/desktop/src/components/main/body/advanced-search/index.tsx +++ b/apps/desktop/src/components/main/body/advanced-search/index.tsx @@ -68,12 +68,12 @@ function SearchView({ tab }: { tab: Extract }) { } else if (type === "human") { openNew({ type: "contacts", - state: { selectedPerson: id, selectedOrganization: null }, + state: { selected: { type: "person", id } }, }); } else if (type === "organization") { openNew({ type: "contacts", - state: { selectedOrganization: id, selectedPerson: null }, + state: { selected: { type: "organization", id } }, }); } }, diff --git a/apps/desktop/src/components/main/body/contacts/contacts-list.tsx b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx new file mode 100644 index 0000000000..d66bdda2ba --- /dev/null +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -0,0 +1,638 @@ +import { Facehash } from "facehash"; +import { Building2, CornerDownLeft, Pin } from "lucide-react"; +import { Reorder } from "motion/react"; +import React, { useCallback, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import type { ContactsSelection } from "@hypr/plugin-windows"; +import { cn } from "@hypr/utils"; + +import { useNativeContextMenu } from "../../../../hooks/useNativeContextMenu"; +import * as main from "../../../../store/tinybase/store/main"; +import { ColumnHeader, type SortOption } from "./shared"; + +type ContactItem = + | { kind: "person"; id: string } + | { kind: "organization"; id: string }; + +export function ContactsListColumn({ + selected, + setSelected, + onDeletePerson, + onDeleteOrganization, +}: { + selected: ContactsSelection | null; + setSelected: (value: ContactsSelection | null) => void; + onDeletePerson: (id: string) => void; + onDeleteOrganization: (id: string) => void; +}) { + const [showNewPerson, setShowNewPerson] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [sortOption, setSortOption] = useState("alphabetical"); + const [showSearch, setShowSearch] = useState(false); + + useHotkeys( + "mod+f", + () => setShowSearch(true), + { preventDefault: true, enableOnFormTags: true }, + [setShowSearch], + ); + + const allHumans = main.UI.useTable("humans", main.STORE_ID); + const allOrgs = main.UI.useTable("organizations", main.STORE_ID); + const store = main.UI.useStore(main.STORE_ID); + + const alphabeticalHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); + const reverseAlphabeticalHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "name", + true, + 0, + undefined, + main.STORE_ID, + ); + const newestHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "created_at", + true, + 0, + undefined, + main.STORE_ID, + ); + const oldestHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "created_at", + false, + 0, + undefined, + main.STORE_ID, + ); + + const alphabeticalOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); + const reverseAlphabeticalOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "name", + true, + 0, + undefined, + main.STORE_ID, + ); + const newestOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "created_at", + true, + 0, + undefined, + main.STORE_ID, + ); + const oldestOrgIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleOrganizations, + "created_at", + false, + 0, + undefined, + main.STORE_ID, + ); + + const sortedHumanIds = + sortOption === "alphabetical" + ? alphabeticalHumanIds + : sortOption === "reverse-alphabetical" + ? reverseAlphabeticalHumanIds + : sortOption === "newest" + ? newestHumanIds + : oldestHumanIds; + + const sortedOrgIds = + sortOption === "alphabetical" + ? alphabeticalOrgIds + : sortOption === "reverse-alphabetical" + ? reverseAlphabeticalOrgIds + : sortOption === "newest" + ? newestOrgIds + : oldestOrgIds; + + const { pinnedHumanIds, unpinnedHumanIds } = useMemo(() => { + const pinned = sortedHumanIds.filter((id) => allHumans[id]?.pinned); + const unpinned = sortedHumanIds.filter((id) => !allHumans[id]?.pinned); + + const sortedPinned = [...pinned].sort((a, b) => { + const orderA = + (allHumans[a]?.pin_order as number | undefined) ?? Infinity; + const orderB = + (allHumans[b]?.pin_order as number | undefined) ?? Infinity; + return orderA - orderB; + }); + + return { pinnedHumanIds: sortedPinned, unpinnedHumanIds: unpinned }; + }, [sortedHumanIds, allHumans]); + + const { pinnedOrgIds, unpinnedOrgIds } = useMemo(() => { + const pinned = sortedOrgIds.filter((id) => allOrgs[id]?.pinned); + const unpinned = sortedOrgIds.filter((id) => !allOrgs[id]?.pinned); + + const sortedPinned = [...pinned].sort((a, b) => { + const orderA = (allOrgs[a]?.pin_order as number | undefined) ?? Infinity; + const orderB = (allOrgs[b]?.pin_order as number | undefined) ?? Infinity; + return orderA - orderB; + }); + + return { pinnedOrgIds: sortedPinned, unpinnedOrgIds: unpinned }; + }, [sortedOrgIds, allOrgs]); + + const { pinnedItems, nonPinnedItems } = useMemo(() => { + const q = searchValue.toLowerCase().trim(); + + const filterHuman = (id: string) => { + if (!q) return true; + const human = allHumans[id]; + const name = (human?.name ?? "").toLowerCase(); + const email = (human?.email ?? "").toLowerCase(); + return name.includes(q) || email.includes(q); + }; + + const filterOrg = (id: string) => { + if (!q) return true; + const name = (allOrgs[id]?.name ?? "").toLowerCase(); + return name.includes(q); + }; + + const allPinned = [ + ...pinnedHumanIds.filter(filterHuman).map((id) => ({ + kind: "person" as const, + id, + pin_order: (allHumans[id]?.pin_order as number | undefined) ?? Infinity, + })), + ...pinnedOrgIds.filter(filterOrg).map((id) => ({ + kind: "organization" as const, + id, + pin_order: (allOrgs[id]?.pin_order as number | undefined) ?? Infinity, + })), + ] + .sort((a, b) => a.pin_order - b.pin_order) + .map(({ kind, id }) => ({ kind, id })); + + const unpinnedOrgs: ContactItem[] = unpinnedOrgIds + .filter(filterOrg) + .map((id) => ({ kind: "organization" as const, id })); + + const unpinnedPeople: ContactItem[] = unpinnedHumanIds + .filter(filterHuman) + .map((id) => ({ kind: "person" as const, id })); + + return { + pinnedItems: allPinned, + nonPinnedItems: [...unpinnedOrgs, ...unpinnedPeople], + }; + }, [ + pinnedHumanIds, + unpinnedHumanIds, + pinnedOrgIds, + unpinnedOrgIds, + allOrgs, + allHumans, + searchValue, + ]); + + const handleReorderPinned = useCallback( + (newOrder: string[]) => { + if (!store) return; + store.transaction(() => { + newOrder.forEach((id, index) => { + const item = pinnedItems.find((i) => i.id === id); + if (item?.kind === "person") { + store.setCell("humans", id, "pin_order", index); + } else if (item?.kind === "organization") { + store.setCell("organizations", id, "pin_order", index); + } + }); + }); + }, + [store, pinnedItems], + ); + + const handleAdd = useCallback(() => { + setShowNewPerson(true); + }, []); + + const isActive = (item: ContactItem) => { + if (!selected) return false; + return selected.type === item.kind && selected.id === item.id; + }; + + return ( +
+ +
+
+ {showNewPerson && ( + { + setShowNewPerson(false); + setSelected({ type: "person", id: humanId }); + }} + onCancel={() => setShowNewPerson(false)} + /> + )} + {pinnedItems.length > 0 && !searchValue.trim() && ( + i.id)} + onReorder={handleReorderPinned} + className="flex flex-col" + > + {pinnedItems.map((item) => ( + + {item.kind === "person" ? ( + + setSelected({ type: "person", id: item.id }) + } + onDelete={onDeletePerson} + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + onDelete={onDeleteOrganization} + /> + )} + + ))} + + )} + {pinnedItems.length > 0 && searchValue.trim() && ( +
+ {pinnedItems.map((item) => + item.kind === "person" ? ( + setSelected({ type: "person", id: item.id })} + onDelete={onDeletePerson} + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + onDelete={onDeleteOrganization} + /> + ), + )} +
+ )} + {pinnedItems.length > 0 && nonPinnedItems.length > 0 && ( +
+ )} + {nonPinnedItems.map((item) => + item.kind === "person" ? ( + setSelected({ type: "person", id: item.id })} + onDelete={onDeletePerson} + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + onDelete={onDeleteOrganization} + /> + ), + )} +
+
+
+ ); +} + +function PersonItem({ + humanId, + active, + onClick, + onDelete, +}: { + humanId: string; + active: boolean; + onClick: () => void; + onDelete?: (id: string) => void; +}) { + const person = main.UI.useRow("humans", humanId, main.STORE_ID); + const isPinned = Boolean(person.pinned); + const personName = String(person.name ?? ""); + const personEmail = String(person.email ?? ""); + + const store = main.UI.useStore(main.STORE_ID); + + const showContextMenu = useNativeContextMenu([ + { + id: "delete-person", + text: "Delete Contact", + action: () => onDelete?.(humanId), + }, + ]); + + const handleTogglePin = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!store) return; + + const currentPinned = store.getCell("humans", humanId, "pinned"); + if (currentPinned) { + store.setPartialRow("humans", humanId, { + pinned: false, + pin_order: 0, + }); + } else { + const allHumans = store.getTable("humans"); + const allOrgs = store.getTable("organizations"); + const maxHumanOrder = Object.values(allHumans).reduce((max, h) => { + const order = (h.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + const maxOrgOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + store.setPartialRow("humans", humanId, { + pinned: true, + pin_order: Math.max(maxHumanOrder, maxOrgOrder) + 1, + }); + } + }, + [store, humanId], + ); + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn([ + "group w-full text-left px-3 py-2 rounded-md text-sm border hover:bg-neutral-100 transition-colors flex items-center gap-2 bg-white overflow-hidden", + active ? "border-neutral-500 bg-neutral-100" : "border-transparent", + ])} + > +
+ +
+
+
+ {personName || personEmail || "Unnamed"} +
+ {personEmail && personName && ( +
{personEmail}
+ )} +
+ +
+ ); +} + +function OrganizationItem({ + organizationId, + active, + onClick, + onDelete, +}: { + organizationId: string; + active: boolean; + onClick: () => void; + onDelete?: (id: string) => void; +}) { + const organization = main.UI.useRow( + "organizations", + organizationId, + main.STORE_ID, + ); + const isPinned = Boolean(organization.pinned); + const store = main.UI.useStore(main.STORE_ID); + + const showContextMenu = useNativeContextMenu([ + { + id: "delete-org", + text: "Delete Organization", + action: () => onDelete?.(organizationId), + }, + ]); + + const handleTogglePin = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!store) return; + + const currentPinned = store.getCell( + "organizations", + organizationId, + "pinned", + ); + if (currentPinned) { + store.setPartialRow("organizations", organizationId, { + pinned: false, + pin_order: 0, + }); + } else { + const allOrgs = store.getTable("organizations"); + const allHumans = store.getTable("humans"); + const maxOrgOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + const maxHumanOrder = Object.values(allHumans).reduce((max, h) => { + const order = (h.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + store.setPartialRow("organizations", organizationId, { + pinned: true, + pin_order: Math.max(maxOrgOrder, maxHumanOrder) + 1, + }); + } + }, + [store, organizationId], + ); + + if (!organization) { + return null; + } + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn([ + "group w-full text-left px-3 py-2 rounded-md text-sm border hover:bg-neutral-100 transition-colors flex items-center gap-2 overflow-hidden", + active ? "border-neutral-500 bg-neutral-100" : "border-transparent", + ])} + > +
+ +
+
+
{organization.name}
+
+ +
+ ); +} + +function NewPersonForm({ + onSave, + onCancel, +}: { + onSave: (humanId: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const userId = main.UI.useValue("user_id", main.STORE_ID); + + 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: "", + memo: "", + pinned: false, + }), + [userId], + main.STORE_ID, + ); + + const handleAdd = () => { + const humanId = crypto.randomUUID(); + createHuman({ humanId, name: name.trim() }); + setName(""); + onSave(humanId); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + } + if (e.key === "Escape") { + onCancel(); + } + }; + + return ( +
+
+
+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add person" + className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" + autoFocus + /> + {name.trim() && ( + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/components/main/body/contacts/details.tsx b/apps/desktop/src/components/main/body/contacts/details.tsx index 18c5f34d0f..bd49c86a05 100644 --- a/apps/desktop/src/components/main/body/contacts/details.tsx +++ b/apps/desktop/src/components/main/body/contacts/details.tsx @@ -3,7 +3,7 @@ import { Building2, CircleMinus, FileText, - Pin, + Plus, SearchIcon, } from "lucide-react"; import React, { useCallback, useState } from "react"; @@ -21,11 +21,9 @@ import * as main from "../../../../store/tinybase/store/main"; export function DetailsColumn({ selectedHumanId, - handleDeletePerson, handleSessionClick, }: { selectedHumanId?: string | null; - handleDeletePerson: (id: string) => void; handleSessionClick: (id: string) => void; }) { const selectedPersonData = main.UI.useRow( @@ -33,8 +31,6 @@ export function DetailsColumn({ selectedHumanId ?? "", main.STORE_ID, ); - const isPinned = selectedPersonData.pinned as boolean | undefined; - const mappingIdsByHuman = main.UI.useSliceRowIds( main.INDEXES.sessionsByHuman, selectedHumanId ?? "", @@ -110,28 +106,6 @@ export function DetailsColumn({ const store = main.UI.useStore(main.STORE_ID); - const handleTogglePin = useCallback(() => { - if (!store || !selectedHumanId) return; - - const currentPinned = store.getCell("humans", selectedHumanId, "pinned"); - if (currentPinned) { - store.setPartialRow("humans", selectedHumanId, { - pinned: false, - pin_order: undefined, - }); - } else { - const allHumans = store.getTable("humans"); - const maxOrder = Object.values(allHumans).reduce((max, h) => { - const order = (h.pin_order as number | undefined) ?? 0; - return Math.max(max, order); - }, 0); - store.setPartialRow("humans", selectedHumanId, { - pinned: true, - pin_order: maxOrder + 1, - }); - } - }, [store, selectedHumanId]); - const handleMergeContacts = useCallback( (duplicateId: string) => { if (!store || !selectedHumanId) return; @@ -209,41 +183,18 @@ export function DetailsColumn({
{selectedPersonData && selectedHumanId ? ( <> -
-
-
- -
-
-
-
- - -
-
-
+
+
+
@@ -299,7 +250,13 @@ export function DetailsColumn({
)} -
+
+
+
Name
+
+ +
+
@@ -368,39 +325,6 @@ export function DetailsColumn({
-
-
-
-

- Danger Zone -

-
-
-
-
-

- Delete this contact -

-

- This action cannot be undone -

-
- -
-
-
-
-
@@ -432,7 +356,7 @@ function EditablePersonNameField({ personId }: { personId: string }) { value={(value as string) || ""} onChange={handleChange} placeholder="Name" - className="border-none shadow-none p-0 h-8 text-lg font-semibold focus-visible:ring-0 focus-visible:ring-offset-0" + className="border-none shadow-none p-0 h-7 text-base focus-visible:ring-0 focus-visible:ring-offset-0" /> ); } @@ -544,7 +468,7 @@ function EditablePersonMemoField({ personId }: { personId: string }) { value={(value as string) || ""} onChange={handleChange} placeholder="Add notes about this contact..." - className="border-none shadow-none p-2 min-h-[80px] text-base focus-visible:ring-0 focus-visible:ring-offset-0 resize-none" + className="border-none shadow-none px-0 py-2 min-h-[80px] text-base focus-visible:ring-0 focus-visible:ring-offset-0 resize-none" rows={3} />
@@ -579,8 +503,8 @@ function EditPersonOrganizationSelector({ personId }: { personId: string }) { return ( -
- {organization ? ( +
+ {organization?.name ? (
{organization.name} @@ -594,8 +518,9 @@ function EditPersonOrganizationSelector({ personId }: { personId: string }) {
) : ( - - Select organization + + + Add organization )}
@@ -619,6 +544,7 @@ function OrganizationControl({ closePopover: () => void; }) { const [searchTerm, setSearchTerm] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const userId = main.UI.useValue("user_id", main.STORE_ID); const organizationsData = main.UI.useResultTable( @@ -639,6 +565,9 @@ function OrganizationControl({ ) : allOrganizations; + const showCreateOption = searchTerm.trim() && organizations.length === 0; + const itemCount = organizations.length + (showCreateOption ? 1 : 0); + const createOrganization = main.UI.useSetRowCallback( "organizations", (p: { name: string; orgId: string }) => p.orgId, @@ -663,8 +592,19 @@ function OrganizationControl({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + if (e.key === "ArrowDown") { e.preventDefault(); + setHighlightedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < organizations.length) { + selectOrganization(organizations[highlightedIndex].id); + } else if (showCreateOption) { + handleCreateOrganization(); + } } }; @@ -686,7 +626,10 @@ function OrganizationControl({ setSearchTerm(e.target.value)} + onChange={(e) => { + setSearchTerm(e.target.value); + setHighlightedIndex(-1); + }} onKeyDown={handleKeyDown} placeholder="Search or add company" className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:ring-offset-0" @@ -695,12 +638,18 @@ function OrganizationControl({ {searchTerm.trim() && (
- {organizations.map((org: any) => ( + {organizations.map((org: any, index: number) => ( ))} - {organizations.length === 0 && ( + {showCreateOption && ( -
-
-
-
-
@@ -220,7 +186,7 @@ function EditableOrganizationNameField({ value={(value as string) || ""} onChange={handleChange} placeholder="Organization name" - className="border-none shadow-none p-0 h-8 text-lg font-semibold focus-visible:ring-0 focus-visible:ring-offset-0" + className="border-none shadow-none p-0 h-7 text-base focus-visible:ring-0 focus-visible:ring-offset-0" /> ); } diff --git a/apps/desktop/src/components/main/body/contacts/organizations.tsx b/apps/desktop/src/components/main/body/contacts/organizations.tsx deleted file mode 100644 index c9690810fa..0000000000 --- a/apps/desktop/src/components/main/body/contacts/organizations.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { Building2, CornerDownLeft, User } from "lucide-react"; -import React, { useState } from "react"; - -import { cn } from "@hypr/utils"; - -import * as main from "../../../../store/tinybase/store/main"; -import { ColumnHeader, type SortOption } from "./shared"; - -export function OrganizationsColumn({ - selectedOrganization, - setSelectedOrganization, - isViewingOrgDetails, -}: { - selectedOrganization: string | null; - setSelectedOrganization: (id: string | null) => void; - isViewingOrgDetails: boolean; -}) { - const [showNewOrg, setShowNewOrg] = useState(false); - const [searchValue, setSearchValue] = useState(""); - const { organizationIds, sortOption, setSortOption } = - useSortedOrganizationIds(); - - const allOrgs = main.UI.useTable("organizations", main.STORE_ID); - - const filteredOrganizationIds = React.useMemo(() => { - if (!searchValue.trim()) { - return organizationIds; - } - - return organizationIds.filter((id) => { - const org = allOrgs[id]; - const nameLower = (org?.name ?? "").toLowerCase(); - return nameLower.includes(searchValue.toLowerCase()); - }); - }, [organizationIds, searchValue, allOrgs]); - - return ( -
- setShowNewOrg(true)} - searchValue={searchValue} - onSearchChange={setSearchValue} - /> -
-
- - {showNewOrg && ( - setShowNewOrg(false)} - onCancel={() => setShowNewOrg(false)} - /> - )} - {filteredOrganizationIds.map((orgId) => ( - - ))} -
-
-
- ); -} - -function useSortedOrganizationIds() { - const [sortOption, setSortOption] = useState("alphabetical"); - - const alphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "name", - false, - 0, - undefined, - main.STORE_ID, - ); - const reverseAlphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "name", - true, - 0, - undefined, - main.STORE_ID, - ); - const newestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "created_at", - true, - 0, - undefined, - main.STORE_ID, - ); - const oldestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleOrganizations, - "created_at", - false, - 0, - undefined, - main.STORE_ID, - ); - - const organizationIds = - sortOption === "alphabetical" - ? alphabeticalIds - : sortOption === "reverse-alphabetical" - ? reverseAlphabeticalIds - : sortOption === "newest" - ? newestIds - : oldestIds; - - return { organizationIds, sortOption, setSortOption }; -} - -function OrganizationItem({ - organizationId, - isSelected, - isViewingDetails, - setSelectedOrganization, -}: { - organizationId: string; - isSelected: boolean; - isViewingDetails: boolean; - setSelectedOrganization: (id: string | null) => void; -}) { - const organization = main.UI.useRow( - "organizations", - organizationId, - main.STORE_ID, - ); - if (!organization) { - return null; - } - - return ( -
- -
- ); -} - -function NewOrganizationForm({ - onSave, - onCancel, -}: { - onSave: () => void; - onCancel: () => void; -}) { - const [name, setName] = useState(""); - const userId = main.UI.useValue("user_id", main.STORE_ID); - - const handleAdd = main.UI.useAddRowCallback( - "organizations", - () => ({ - user_id: userId || "", - name: name.trim(), - created_at: new Date().toISOString(), - }), - [name, userId], - main.STORE_ID, - () => { - setName(""); - onSave(); - }, - ); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - } - if (e.key === "Escape") { - onCancel(); - } - }; - - return ( -
-
-
- setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Add organization" - className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" - autoFocus - /> - {name.trim() && ( - - )} -
-
-
- ); -} diff --git a/apps/desktop/src/components/main/body/contacts/people.tsx b/apps/desktop/src/components/main/body/contacts/people.tsx deleted file mode 100644 index 1d282f75fd..0000000000 --- a/apps/desktop/src/components/main/body/contacts/people.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { Facehash } from "facehash"; -import { CornerDownLeft, Pin } from "lucide-react"; -import { Reorder } from "motion/react"; -import React, { useCallback, useMemo, useState } from "react"; - -import { cn } from "@hypr/utils"; - -import * as main from "../../../../store/tinybase/store/main"; -import { ColumnHeader, type SortOption } from "./shared"; - -export function PeopleColumn({ - currentOrgId, - currentHumanId, - setSelectedPerson, -}: { - currentOrgId?: string | null; - currentHumanId?: string | null; - setSelectedPerson: (id: string | null) => void; -}) { - const [showNewPerson, setShowNewPerson] = useState(false); - const [searchValue, setSearchValue] = useState(""); - const { humanIds, pinnedIds, unpinnedIds, sortOption, setSortOption } = - useSortedHumanIds(currentOrgId); - - const allHumans = main.UI.useTable("humans", main.STORE_ID); - const store = main.UI.useStore(main.STORE_ID); - - const filteredHumanIds = useMemo(() => { - if (!searchValue.trim()) { - return humanIds; - } - - return humanIds.filter((id) => { - const human = allHumans[id]; - const q = searchValue.toLowerCase(); - const name = (human?.name ?? "").toLowerCase(); - const email = (human?.email ?? "").toLowerCase(); - return name.includes(q) || email.includes(q); - }); - }, [humanIds, searchValue, allHumans]); - - const filteredPinnedIds = useMemo(() => { - if (!searchValue.trim()) { - return pinnedIds; - } - return pinnedIds.filter((id) => filteredHumanIds.includes(id)); - }, [pinnedIds, filteredHumanIds, searchValue]); - - const filteredUnpinnedIds = useMemo(() => { - if (!searchValue.trim()) { - return unpinnedIds; - } - return unpinnedIds.filter((id) => filteredHumanIds.includes(id)); - }, [unpinnedIds, filteredHumanIds, searchValue]); - - const handleReorderPinned = useCallback( - (newOrder: string[]) => { - if (!store) return; - store.transaction(() => { - newOrder.forEach((id, index) => { - store.setCell("humans", id, "pin_order", index); - }); - }); - }, - [store], - ); - - return ( -
- setShowNewPerson(true)} - searchValue={searchValue} - onSearchChange={setSearchValue} - /> -
-
- {showNewPerson && ( - { - setShowNewPerson(false); - setSelectedPerson(humanId); - }} - onCancel={() => setShowNewPerson(false)} - /> - )} - {filteredPinnedIds.length > 0 && ( - - {filteredPinnedIds.map((humanId) => ( - - - - ))} - - )} - {filteredUnpinnedIds.map((humanId) => ( - - ))} -
-
-
- ); -} - -export function useSortedHumanIds(currentOrgId?: string | null) { - const [sortOption, setSortOption] = useState("alphabetical"); - - const allAlphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "name", - false, - 0, - undefined, - main.STORE_ID, - ); - const allReverseAlphabeticalIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "name", - true, - 0, - undefined, - main.STORE_ID, - ); - const allNewestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "created_at", - true, - 0, - undefined, - main.STORE_ID, - ); - const allOldestIds = main.UI.useResultSortedRowIds( - main.QUERIES.visibleHumans, - "created_at", - false, - 0, - undefined, - main.STORE_ID, - ); - - const thisOrgHumanIds = main.UI.useSliceRowIds( - main.INDEXES.humansByOrg, - currentOrgId ?? "", - main.STORE_ID, - ); - - const allHumans = main.UI.useTable("humans", main.STORE_ID); - - const sortedIds = currentOrgId - ? (sortOption === "alphabetical" - ? allAlphabeticalIds - : sortOption === "reverse-alphabetical" - ? allReverseAlphabeticalIds - : sortOption === "newest" - ? allNewestIds - : allOldestIds - ).filter((id) => thisOrgHumanIds.includes(id)) - : sortOption === "alphabetical" - ? allAlphabeticalIds - : sortOption === "reverse-alphabetical" - ? allReverseAlphabeticalIds - : sortOption === "newest" - ? allNewestIds - : allOldestIds; - - const { humanIds, pinnedIds, unpinnedIds } = useMemo(() => { - const pinned = sortedIds.filter((id) => allHumans[id]?.pinned); - const unpinned = sortedIds.filter((id) => !allHumans[id]?.pinned); - - const sortedPinned = [...pinned].sort((a, b) => { - const orderA = - (allHumans[a]?.pin_order as number | undefined) ?? Infinity; - const orderB = - (allHumans[b]?.pin_order as number | undefined) ?? Infinity; - return orderA - orderB; - }); - - return { - humanIds: [...sortedPinned, ...unpinned], - pinnedIds: sortedPinned, - unpinnedIds: unpinned, - }; - }, [sortedIds, allHumans]); - - return { humanIds, pinnedIds, unpinnedIds, sortOption, setSortOption }; -} - -function PersonItem({ - humanId, - active, - setSelectedPerson, -}: { - humanId: string; - active: boolean; - setSelectedPerson: (id: string | null) => void; -}) { - const person = main.UI.useRow("humans", humanId, main.STORE_ID); - const isPinned = Boolean(person.pinned); - const personName = String(person.name ?? ""); - const personEmail = String(person.email ?? ""); - - const store = main.UI.useStore(main.STORE_ID); - - const handleTogglePin = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (!store) return; - - const currentPinned = store.getCell("humans", humanId, "pinned"); - if (currentPinned) { - store.setPartialRow("humans", humanId, { - pinned: false, - pin_order: undefined, - }); - } else { - const allHumans = store.getTable("humans"); - const maxOrder = Object.values(allHumans).reduce((max, h) => { - const order = (h.pin_order as number | undefined) ?? 0; - return Math.max(max, order); - }, 0); - store.setPartialRow("humans", humanId, { - pinned: true, - pin_order: maxOrder + 1, - }); - } - }, - [store, humanId], - ); - - return ( - - - ); -} - -function NewPersonForm({ - currentOrgId, - onSave, - onCancel, -}: { - currentOrgId?: string | null; - onSave: (humanId: string) => void; - onCancel: () => void; -}) { - const [name, setName] = useState(""); - const userId = main.UI.useValue("user_id", main.STORE_ID); - - 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: currentOrgId || "", - job_title: "", - linkedin_username: "", - memo: "", - pinned: false, - }), - [userId, currentOrgId], - main.STORE_ID, - ); - - const handleAdd = () => { - const humanId = crypto.randomUUID(); - createHuman({ humanId, name: name.trim() }); - setName(""); - onSave(humanId); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - if (name.trim()) { - handleAdd(); - } - } - if (e.key === "Escape") { - onCancel(); - } - }; - - return ( -
-
-
- setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Add person" - className="w-full bg-transparent text-sm focus:outline-hidden placeholder:text-neutral-400" - autoFocus - /> - {name.trim() && ( - - )} -
-
-
- ); -} diff --git a/apps/desktop/src/components/main/body/contacts/shared.tsx b/apps/desktop/src/components/main/body/contacts/shared.tsx index 16cb5b0369..009f7988f3 100644 --- a/apps/desktop/src/components/main/body/contacts/shared.tsx +++ b/apps/desktop/src/components/main/body/contacts/shared.tsx @@ -3,23 +3,12 @@ import { useState } from "react"; import { Button } from "@hypr/ui/components/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@hypr/ui/components/ui/select"; - -export const getInitials = (name?: string | null) => { - if (!name) { - return "?"; - } - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2); -}; + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@hypr/ui/components/ui/dropdown-menu"; export type SortOption = | "alphabetical" @@ -35,28 +24,44 @@ export function SortDropdown({ setSortOption: (option: SortOption) => void; }) { return ( - + + + + + + setSortOption(value as SortOption)} + > + + A-Z + + + Z-A + + + Oldest + + + Newest + + + + ); } @@ -67,6 +72,8 @@ export function ColumnHeader({ onAdd, searchValue, onSearchChange, + showSearch: showSearchProp, + onShowSearchChange, }: { title: string; sortOption?: SortOption; @@ -74,8 +81,12 @@ export function ColumnHeader({ onAdd: () => void; searchValue?: string; onSearchChange?: (value: string) => void; + showSearch?: boolean; + onShowSearchChange?: (show: boolean) => void; }) { - const [showSearch, setShowSearch] = useState(false); + const [showSearchInternal, setShowSearchInternal] = useState(false); + const showSearch = showSearchProp ?? showSearchInternal; + const setShowSearch = onShowSearchChange ?? setShowSearchInternal; const handleSearchToggle = () => { if (showSearch) { diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index d34c7b8e81..94df85b0d5 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -979,7 +979,7 @@ function useTabsShortcuts() { () => openNew({ type: "contacts", - state: { selectedOrganization: null, selectedPerson: null }, + state: { selected: null }, }), { preventDefault: true, diff --git a/apps/desktop/src/components/main/body/search.tsx b/apps/desktop/src/components/main/body/search.tsx index 87b1df446c..33cbf1e4d0 100644 --- a/apps/desktop/src/components/main/body/search.tsx +++ b/apps/desktop/src/components/main/body/search.tsx @@ -168,16 +168,14 @@ function ExpandedSearch({ onBlur }: { onBlur?: () => void }) { openNew({ type: "contacts", state: { - selectedPerson: item.id, - selectedOrganization: null, + selected: { type: "person", id: item.id }, }, }); } else if (item.type === "organization") { openNew({ type: "contacts", state: { - selectedOrganization: item.id, - selectedPerson: null, + selected: { type: "organization", id: item.id }, }, }); } 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 index 07d65e98f6..6ed1ca65af 100644 --- 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 @@ -29,7 +29,7 @@ export function ParticipantChip({ mappingId }: { mappingId: string }) { if (assignedHumanId) { useTabs.getState().openNew({ type: "contacts", - state: { selectedOrganization: null, selectedPerson: assignedHumanId }, + state: { selected: { type: "person", id: assignedHumanId } }, }); } }, [assignedHumanId]); diff --git a/apps/desktop/src/components/main/sidebar/profile/index.tsx b/apps/desktop/src/components/main/sidebar/profile/index.tsx index c6c4439e95..6af5227f19 100644 --- a/apps/desktop/src/components/main/sidebar/profile/index.tsx +++ b/apps/desktop/src/components/main/sidebar/profile/index.tsx @@ -106,8 +106,7 @@ export function ProfileSection({ onExpandChange }: ProfileSectionProps = {}) { openNew({ type: "contacts", state: { - selectedOrganization: null, - selectedPerson: null, + selected: null, }, }); closeMenu(); diff --git a/apps/desktop/src/components/main/sidebar/search/item.tsx b/apps/desktop/src/components/main/sidebar/search/item.tsx index 8e2fdd9d6c..2b6447acaf 100644 --- a/apps/desktop/src/components/main/sidebar/search/item.tsx +++ b/apps/desktop/src/components/main/sidebar/search/item.tsx @@ -282,13 +282,13 @@ function getTab(result: SearchResult): TabInput | null { if (result.type === "human") { return { type: "contacts", - state: { selectedPerson: result.id, selectedOrganization: null }, + state: { selected: { type: "person", id: result.id } }, }; } if (result.type === "organization") { return { type: "contacts", - state: { selectedOrganization: result.id, selectedPerson: null }, + state: { selected: { type: "organization", id: result.id } }, }; } diff --git a/apps/desktop/src/components/settings/general/permissions.tsx b/apps/desktop/src/components/settings/general/permissions.tsx index 71234adbdd..2e742e98a1 100644 --- a/apps/desktop/src/components/settings/general/permissions.tsx +++ b/apps/desktop/src/components/settings/general/permissions.tsx @@ -131,6 +131,7 @@ export function Permissions() { const mic = usePermission("microphone"); const systemAudio = usePermission("systemAudio"); const accessibility = usePermission("accessibility"); + const contacts = usePermission("contacts"); return (
@@ -163,6 +164,15 @@ export function Permissions() { onReset={accessibility.reset} onOpen={accessibility.open} /> +
); diff --git a/apps/desktop/src/store/tinybase/persister/human/transform.test.ts b/apps/desktop/src/store/tinybase/persister/human/transform.test.ts index 2bdbf80f86..735a93d539 100644 --- a/apps/desktop/src/store/tinybase/persister/human/transform.test.ts +++ b/apps/desktop/src/store/tinybase/persister/human/transform.test.ts @@ -59,6 +59,7 @@ describe("frontmatterToHuman", () => { ); expect(result).toEqual({ user_id: "user-1", + created_at: undefined, name: "John Doe", email: "john@example.com", org_id: "org-1", @@ -66,6 +67,7 @@ describe("frontmatterToHuman", () => { linkedin_username: "johndoe", memo: "Notes", pinned: false, + pin_order: undefined, }); }); }); @@ -81,6 +83,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([ "a@example.com", @@ -98,6 +101,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([]); }); @@ -112,6 +116,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([ "a@example.com", @@ -129,6 +134,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual(["a@example.com"]); }); @@ -143,6 +149,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "Some notes", pinned: false, + created_at: "", }); expect(result.body).toBe("Some notes"); }); @@ -150,6 +157,7 @@ describe("humanToFrontmatter", () => { test("converts all fields correctly", () => { const result = humanToFrontmatter({ user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "John Doe", email: "john@example.com", org_id: "org-1", @@ -161,12 +169,14 @@ describe("humanToFrontmatter", () => { expect(result).toEqual({ frontmatter: { user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "John Doe", emails: ["john@example.com"], org_id: "org-1", job_title: "Engineer", linkedin_username: "johndoe", pinned: false, + pin_order: 0, }, body: "Notes", }); diff --git a/apps/desktop/src/store/tinybase/persister/human/transform.ts b/apps/desktop/src/store/tinybase/persister/human/transform.ts index 1c4f9dc4fa..7c5acde1b1 100644 --- a/apps/desktop/src/store/tinybase/persister/human/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/human/transform.ts @@ -27,12 +27,17 @@ function frontmatterToStore( ): HumanFrontmatter { return { user_id: String(frontmatter.user_id ?? ""), + created_at: frontmatter.created_at + ? String(frontmatter.created_at) + : undefined, name: String(frontmatter.name ?? ""), email: emailsToStore(frontmatter), org_id: String(frontmatter.org_id ?? ""), job_title: String(frontmatter.job_title ?? ""), linkedin_username: String(frontmatter.linkedin_username ?? ""), pinned: Boolean(frontmatter.pinned ?? false), + pin_order: + frontmatter.pin_order != null ? Number(frontmatter.pin_order) : undefined, }; } @@ -41,12 +46,14 @@ function storeToFrontmatter( ): Record { return { user_id: store.user_id ?? "", + created_at: store.created_at ?? "", name: store.name ?? "", emails: emailToFrontmatter(store.email), org_id: store.org_id ?? "", job_title: store.job_title ?? "", linkedin_username: store.linkedin_username ?? "", pinned: store.pinned ?? false, + pin_order: store.pin_order ?? 0, }; } diff --git a/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts b/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts index 8d3e2109fe..e85b40d966 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/transform.test.ts @@ -10,13 +10,17 @@ describe("frontmatterToOrganization", () => { const result = frontmatterToOrganization( { user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", }, "", ); expect(result).toEqual({ user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", + pinned: false, + pin_order: undefined, }); }); @@ -24,21 +28,65 @@ describe("frontmatterToOrganization", () => { const result = frontmatterToOrganization({}, ""); expect(result).toEqual({ user_id: "", + created_at: undefined, name: "", + pinned: false, + pin_order: undefined, + }); + }); + + test("preserves pinned state", () => { + const result = frontmatterToOrganization( + { + user_id: "user-1", + name: "Acme Corp", + pinned: true, + }, + "", + ); + expect(result).toEqual({ + user_id: "user-1", + created_at: undefined, + name: "Acme Corp", + pinned: true, + pin_order: undefined, }); }); }); describe("organizationToFrontmatter", () => { test("converts organization storage to frontmatter", () => { + const result = organizationToFrontmatter({ + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + pinned: false, + }); + expect(result).toEqual({ + frontmatter: { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + pinned: false, + pin_order: 0, + }, + body: "", + }); + }); + + test("converts pinned organization to frontmatter", () => { const result = organizationToFrontmatter({ user_id: "user-1", name: "Acme Corp", + pinned: true, }); expect(result).toEqual({ frontmatter: { user_id: "user-1", + created_at: "", name: "Acme Corp", + pinned: true, + pin_order: 0, }, body: "", }); diff --git a/apps/desktop/src/store/tinybase/persister/organization/transform.ts b/apps/desktop/src/store/tinybase/persister/organization/transform.ts index 372064f2a0..d7ba1e2003 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/transform.ts @@ -7,7 +7,13 @@ export function frontmatterToOrganization( ): OrganizationStorage { return { user_id: String(frontmatter.user_id ?? ""), + created_at: frontmatter.created_at + ? String(frontmatter.created_at) + : undefined, name: String(frontmatter.name ?? ""), + pinned: Boolean(frontmatter.pinned ?? false), + pin_order: + frontmatter.pin_order != null ? Number(frontmatter.pin_order) : undefined, }; } @@ -17,8 +23,11 @@ export function organizationToFrontmatter(org: OrganizationStorage): { } { return { frontmatter: { - name: org.name ?? "", user_id: org.user_id ?? "", + created_at: org.created_at ?? "", + name: org.name ?? "", + pinned: org.pinned ?? false, + pin_order: org.pin_order ?? 0, }, body: "", }; diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index 946cf84928..33d9a9f1dc 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -112,6 +112,7 @@ export const StoreComponent = () => { }, ) .setQueryDefinition(QUERIES.visibleHumans, "humans", ({ select }) => { + select("created_at"); select("name"); select("email"); select("org_id"); @@ -124,7 +125,10 @@ export const StoreComponent = () => { QUERIES.visibleOrganizations, "organizations", ({ select }) => { + select("created_at"); select("name"); + select("pinned"); + select("pin_order"); }, ) .setQueryDefinition( @@ -403,6 +407,7 @@ interface _QueryResultRows { folder_id: string; }; visibleHumans: { + created_at: string; name: string; email: string; org_id: string; @@ -412,7 +417,10 @@ interface _QueryResultRows { pin_order: number; }; visibleOrganizations: { + created_at: string; name: string; + pinned: boolean; + pin_order: number; }; visibleTemplates: { title: string; diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index bd4849016e..339770d187 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -4,6 +4,7 @@ import type { ChangelogState, ChatShortcutsState, ChatState, + ContactsSelection, ContactsState, EditorView, ExtensionsState, @@ -20,6 +21,7 @@ export type { ChangelogState, ChatShortcutsState, ChatState, + ContactsSelection, ContactsState, EditorView, ExtensionsState, @@ -130,8 +132,7 @@ export const getDefaultState = (tab: TabInput): Tab => { ...base, type: "contacts", state: tab.state ?? { - selectedOrganization: null, - selectedPerson: null, + selected: null, }, }; case "templates": diff --git a/apps/desktop/src/store/zustand/tabs/state.test.ts b/apps/desktop/src/store/zustand/tabs/state.test.ts index 0e842a3c51..a559aa18e3 100644 --- a/apps/desktop/src/store/zustand/tabs/state.test.ts +++ b/apps/desktop/src/store/zustand/tabs/state.test.ts @@ -84,9 +84,8 @@ describe("State Updater Actions", () => { describe("updateContactsTabState", () => { const newContactsState = { - selectedOrganization: "org-1", - selectedPerson: "person-1", - } as const; + selected: { type: "person" as const, id: "person-1" }, + }; test("updates contacts tab and current tab state", () => { const contacts = createContactsTab({ active: true }); diff --git a/apps/desktop/src/store/zustand/tabs/test-utils.ts b/apps/desktop/src/store/zustand/tabs/test-utils.ts index 02800b6593..ad8f869c0d 100644 --- a/apps/desktop/src/store/zustand/tabs/test-utils.ts +++ b/apps/desktop/src/store/zustand/tabs/test-utils.ts @@ -36,8 +36,7 @@ export const createContactsTab = ( pinned: overrides.pinned ?? false, slotId: id(), state: { - selectedOrganization: null, - selectedPerson: null, + selected: null, ...overrides.state, }, }); diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index f5c4d6c84f..a62eb5997e 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -41,6 +41,7 @@ export const tableSchemaForTinybase = { } as const satisfies InferTinyBaseSchema, humans: { user_id: { type: "string" }, + created_at: { type: "string" }, name: { type: "string" }, email: { type: "string" }, org_id: { type: "string" }, @@ -52,7 +53,10 @@ export const tableSchemaForTinybase = { } as const satisfies InferTinyBaseSchema, organizations: { user_id: { type: "string" }, + created_at: { type: "string" }, name: { type: "string" }, + pinned: { type: "boolean" }, + pin_order: { type: "number" }, } as const satisfies InferTinyBaseSchema, calendars: { user_id: { type: "string" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index ce2389cff6..8511dd7952 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -4,6 +4,7 @@ import { jsonObject, type ToStorageType } from "./shared"; export const humanSchema = z.object({ user_id: z.string(), + created_at: z.preprocess((val) => val ?? undefined, z.string().optional()), name: z.string(), email: z.string(), org_id: z.string(), @@ -89,7 +90,10 @@ export const calendarSchema = z.object({ export const organizationSchema = z.object({ user_id: z.string(), + created_at: z.preprocess((val) => val ?? undefined, z.string().optional()), name: z.string(), + pinned: z.preprocess((val) => val ?? false, z.boolean()), + pin_order: z.preprocess((val) => val ?? undefined, z.number().optional()), }); export const sessionSchema = z.object({ diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index 18a08372da..dfa3de0724 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -75,7 +75,8 @@ export type AppWindow = { type: "main" } | { type: "control" } export type ChangelogState = { previous: string | null; current: string } export type ChatShortcutsState = { isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } export type ChatState = { groupId: string | null; initialMessage: string | null } -export type ContactsState = { selectedOrganization: string | null; selectedPerson: string | null } +export type ContactsSelection = { type: "person"; id: string } | { type: "organization"; id: string } +export type ContactsState = { selected: ContactsSelection | null } export type EditorView = { type: "raw" } | { type: "transcript" } | { type: "enhanced"; id: string } | { type: "attachments" } export type ExtensionsState = { selectedExtension: string | null } export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> diff --git a/plugins/windows/src/tab/state.rs b/plugins/windows/src/tab/state.rs index bfb80b4acc..0c89bd0a5c 100644 --- a/plugins/windows/src/tab/state.rs +++ b/plugins/windows/src/tab/state.rs @@ -19,10 +19,19 @@ crate::common_derives! { } } +crate::common_derives! { + #[serde(tag = "type")] + pub enum ContactsSelection { + #[serde(rename = "person")] + Person { id: String }, + #[serde(rename = "organization")] + Organization { id: String }, + } +} + crate::common_derives! { pub struct ContactsState { - pub selected_organization: Option, - pub selected_person: Option, + pub selected: Option, } }