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/calendar/calendar-view.tsx b/apps/desktop/src/components/main/body/calendar/calendar-view.tsx index 32fcd55322..02c3ac47d7 100644 --- a/apps/desktop/src/components/main/body/calendar/calendar-view.tsx +++ b/apps/desktop/src/components/main/body/calendar/calendar-view.tsx @@ -687,7 +687,6 @@ function SessionPopoverContent({ sessionId }: { sessionId: string }) { function CalendarSidebarContent() { const isMacos = platform() === "macos"; const calendar = usePermission("calendar"); - const contacts = usePermission("contacts"); const visibleProviders = PROVIDERS.filter( (p) => p.platform === "all" || (p.platform === "macos" && isMacos), @@ -731,29 +730,16 @@ function CalendarSidebarContent() { {provider.id === "apple" && (
- {(calendar.status !== "authorized" || - contacts.status !== "authorized") && ( + {calendar.status !== "authorized" && (
- {calendar.status !== "authorized" && ( - - )} - {contacts.status !== "authorized" && ( - - )} +
)} {calendar.status === "authorized" && ( 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..41e55b1fe8 --- /dev/null +++ b/apps/desktop/src/components/main/body/contacts/contacts-list.tsx @@ -0,0 +1,657 @@ +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 * as main from "../../../../store/tinybase/store/main"; +import { ColumnHeader, getInitials, type SortOption } from "./shared"; + +type ContactItem = + | { kind: "person"; id: string } + | { kind: "organization"; id: string }; + +export function ContactsListColumn({ + selected, + setSelected, +}: { + selected: ContactsSelection | null; + setSelected: (value: ContactsSelection | null) => void; +}) { + const [showNewPerson, setShowNewPerson] = useState(false); + const [showNewOrg, setShowNewOrg] = 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)} + /> + )} + {showNewOrg && ( + setShowNewOrg(false)} + onCancel={() => setShowNewOrg(false)} + /> + )} + {pinnedItems.length > 0 && ( + i.id)} + onReorder={handleReorderPinned} + className="flex flex-col" + > + {pinnedItems.map((item) => ( + + {item.kind === "person" ? ( + + setSelected({ type: "person", id: item.id }) + } + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + /> + )} + + ))} + + )} + {pinnedItems.length > 0 && nonPinnedItems.length > 0 && ( +
+ )} + {nonPinnedItems.map((item) => + item.kind === "person" ? ( + setSelected({ type: "person", id: item.id })} + /> + ) : ( + + setSelected({ type: "organization", id: item.id }) + } + /> + ), + )} +
+
+
+ ); +} + +function PersonItem({ + humanId, + active, + onClick, +}: { + humanId: string; + active: boolean; + onClick: () => 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 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", + ])} + > +
+ + {getInitials(personName || personEmail)} + +
+
+
+ {personName || personEmail || "Unnamed"} +
+ {personEmail && personName && ( +
{personEmail}
+ )} +
+ +
+ ); +} + +function OrganizationItem({ + organizationId, + active, + onClick, +}: { + organizationId: string; + active: boolean; + onClick: () => 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 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: undefined, + }); + } 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() && ( + + )} +
+
+
+ ); +} + +function NewOrgForm({ + 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/index.tsx b/apps/desktop/src/components/main/body/contacts/index.tsx index f7ae11489f..763bf4efa1 100644 --- a/apps/desktop/src/components/main/body/contacts/index.tsx +++ b/apps/desktop/src/components/main/body/contacts/index.tsx @@ -2,6 +2,7 @@ import { Contact2Icon } from "lucide-react"; import { useCallback, useEffect } from "react"; import { useShallow } from "zustand/shallow"; +import type { ContactsSelection } from "@hypr/plugin-windows"; import { ResizableHandle, ResizablePanel, @@ -12,10 +13,9 @@ import * as main from "../../../../store/tinybase/store/main"; import { type Tab, useTabs } from "../../../../store/zustand/tabs"; import { StandardTabWrapper } from "../index"; import { type TabItem, TabItemBase } from "../shared"; +import { ContactsListColumn } from "./contacts-list"; import { DetailsColumn } from "./details"; import { OrganizationDetailsColumn } from "./organization-details"; -import { OrganizationsColumn } from "./organizations"; -import { PeopleColumn, useSortedHumanIds } from "./people"; export const TabItemContact: TabItem> = ({ tab, @@ -67,26 +67,11 @@ function ContactView({ tab }: { tab: Extract }) { })), ); - const { selectedOrganization, selectedPerson } = tab.state; + const selected = tab.state.selected; - const setSelectedOrganization = useCallback( - (value: string | null) => { - updateContactsTabState(tab, { - ...tab.state, - selectedOrganization: value, - // Clear selected person when selecting an organization - selectedPerson: value ? null : tab.state.selectedPerson, - }); - }, - [updateContactsTabState, tab], - ); - - const setSelectedPerson = useCallback( - (value: string | null) => { - updateContactsTabState(tab, { - ...tab.state, - selectedPerson: value, - }); + const setSelected = useCallback( + (value: ContactsSelection | null) => { + updateContactsTabState(tab, { selected: value }); }, [updateContactsTabState, tab], ); @@ -108,8 +93,9 @@ function ContactView({ tab }: { tab: Extract }) { (id: string) => { invalidateResource("humans", id); deletePersonFromStore(id); + setSelected(null); }, - [invalidateResource, deletePersonFromStore], + [invalidateResource, deletePersonFromStore, setSelected], ); const deleteOrganizationFromStore = main.UI.useDelRowCallback( @@ -122,52 +108,44 @@ function ContactView({ tab }: { tab: Extract }) { (id: string) => { invalidateResource("organizations" as const, id); deleteOrganizationFromStore(id); + setSelected(null); }, - [invalidateResource, deleteOrganizationFromStore], + [invalidateResource, deleteOrganizationFromStore, setSelected], ); - // Get the list of humanIds to auto-select the first person (only when no org is selected) - const { humanIds } = useSortedHumanIds(selectedOrganization); + const allHumanIds = main.UI.useResultSortedRowIds( + main.QUERIES.visibleHumans, + "name", + false, + 0, + undefined, + main.STORE_ID, + ); - // Auto-select first person on load if no person is selected and no org is selected useEffect(() => { - if (!selectedOrganization && !selectedPerson && humanIds.length > 0) { - setSelectedPerson(humanIds[0]); + if (!selected && allHumanIds.length > 0) { + setSelected({ type: "person", id: allHumanIds[0] }); } - }, [humanIds, selectedPerson, selectedOrganization, setSelectedPerson]); - - const isViewingOrgDetails = selectedOrganization && !selectedPerson; + }, [allHumanIds, selected, setSelected]); return ( - - - - - - + + - - {selectedOrganization && !selectedPerson ? ( - // Show organization details when org is selected but no person is selected + + {selected?.type === "organization" ? ( + setSelected({ type: "person", id: personId }) + } /> ) : ( - // Show person details when a person is selected or no org is selected diff --git a/apps/desktop/src/components/main/body/contacts/organizations.tsx b/apps/desktop/src/components/main/body/contacts/organizations.tsx index c9690810fa..266b6ab04f 100644 --- a/apps/desktop/src/components/main/body/contacts/organizations.tsx +++ b/apps/desktop/src/components/main/body/contacts/organizations.tsx @@ -1,5 +1,6 @@ -import { Building2, CornerDownLeft, User } from "lucide-react"; -import React, { useState } from "react"; +import { Building2, CornerDownLeft, Pin, User } from "lucide-react"; +import { Reorder } from "motion/react"; +import React, { useCallback, useMemo, useState } from "react"; import { cn } from "@hypr/utils"; @@ -17,22 +18,41 @@ export function OrganizationsColumn({ }) { const [showNewOrg, setShowNewOrg] = useState(false); const [searchValue, setSearchValue] = useState(""); - const { organizationIds, sortOption, setSortOption } = + const { pinnedIds, unpinnedIds, sortOption, setSortOption } = useSortedOrganizationIds(); const allOrgs = main.UI.useTable("organizations", main.STORE_ID); + const store = main.UI.useStore(main.STORE_ID); - const filteredOrganizationIds = React.useMemo(() => { - if (!searchValue.trim()) { - return organizationIds; - } + const filteredPinnedIds = useMemo(() => { + if (!searchValue.trim()) return pinnedIds; + const q = searchValue.toLowerCase(); + return pinnedIds.filter((id) => { + const nameLower = (allOrgs[id]?.name ?? "").toLowerCase(); + return nameLower.includes(q); + }); + }, [pinnedIds, searchValue, allOrgs]); - return organizationIds.filter((id) => { - const org = allOrgs[id]; - const nameLower = (org?.name ?? "").toLowerCase(); - return nameLower.includes(searchValue.toLowerCase()); + const filteredUnpinnedIds = useMemo(() => { + if (!searchValue.trim()) return unpinnedIds; + const q = searchValue.toLowerCase(); + return unpinnedIds.filter((id) => { + const nameLower = (allOrgs[id]?.name ?? "").toLowerCase(); + return nameLower.includes(q); }); - }, [organizationIds, searchValue, allOrgs]); + }, [unpinnedIds, searchValue, allOrgs]); + + const handleReorderPinned = useCallback( + (newOrder: string[]) => { + if (!store) return; + store.transaction(() => { + newOrder.forEach((id, index) => { + store.setCell("organizations", id, "pin_order", index); + }); + }); + }, + [store], + ); return (
@@ -62,7 +82,31 @@ export function OrganizationsColumn({ onCancel={() => setShowNewOrg(false)} /> )} - {filteredOrganizationIds.map((orgId) => ( + {filteredPinnedIds.length > 0 && ( + + {filteredPinnedIds.map((orgId) => ( + + + + ))} + + )} + {filteredPinnedIds.length > 0 && filteredUnpinnedIds.length > 0 && ( +
+ )} + {filteredUnpinnedIds.map((orgId) => ( { + const pinned = sortedIds.filter((id) => allOrgs[id]?.pinned); + const unpinned = sortedIds.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 { pinnedIds: sortedPinned, unpinnedIds: unpinned }; + }, [sortedIds, allOrgs]); + + return { pinnedIds, unpinnedIds, sortOption, setSortOption }; } function OrganizationItem({ @@ -143,6 +202,39 @@ function OrganizationItem({ organizationId, main.STORE_ID, ); + const isPinned = Boolean(organization.pinned); + const store = main.UI.useStore(main.STORE_ID); + + 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: undefined, + }); + } else { + const allOrgs = store.getTable("organizations"); + const maxOrder = Object.values(allOrgs).reduce((max, o) => { + const order = (o.pin_order as number | undefined) ?? 0; + return Math.max(max, order); + }, 0); + store.setPartialRow("organizations", organizationId, { + pinned: true, + pin_order: maxOrder + 1, + }); + } + }, + [store, organizationId], + ); + if (!organization) { return null; } @@ -155,13 +247,33 @@ function OrganizationItem({ isSelected && isViewingDetails ? "border-black" : "border-transparent", ])} > - +

{organization.name}

+ +
); } diff --git a/apps/desktop/src/components/main/body/contacts/people.tsx b/apps/desktop/src/components/main/body/contacts/people.tsx index 4646066b95..78ac5801ea 100644 --- a/apps/desktop/src/components/main/body/contacts/people.tsx +++ b/apps/desktop/src/components/main/body/contacts/people.tsx @@ -104,6 +104,9 @@ export function PeopleColumn({ ))} )} + {filteredPinnedIds.length > 0 && filteredUnpinnedIds.length > 0 && ( +
+ )} {filteredUnpinnedIds.map((humanId) => ( { if (!name) { @@ -35,28 +36,44 @@ export function SortDropdown({ setSortOption: (option: SortOption) => void; }) { return ( - + + + + + + setSortOption(value as SortOption)} + > + + A-Z + + + Z-A + + + Oldest + + + Newest + + + + ); } @@ -67,6 +84,8 @@ export function ColumnHeader({ onAdd, searchValue, onSearchChange, + showSearch: showSearchProp, + onShowSearchChange, }: { title: string; sortOption?: SortOption; @@ -74,8 +93,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/empty/index.tsx b/apps/desktop/src/components/main/body/empty/index.tsx index 82a645f016..e4b0c7e511 100644 --- a/apps/desktop/src/components/main/body/empty/index.tsx +++ b/apps/desktop/src/components/main/body/empty/index.tsx @@ -1,5 +1,6 @@ import { AppWindowIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Kbd } from "@hypr/ui/components/ui/kbd"; @@ -51,6 +52,57 @@ export function TabContentEmpty({ ); } +const TIPS = [ + { text: "Press ⌘⇧N to create a new note and start listening immediately" }, + { text: "Use ⌘K to quickly search across all your notes" }, + { + text: "Hyprnote works fully offline — set up Ollama or LM Studio in AI Settings", + }, + { + text: "Press ⌘⇧J to open AI Chat and ask follow-up questions about your notes", + }, + { + text: "Use templates to get structured summaries tailored to your meeting type", + }, + { text: "Press ⌘⇧T to reopen the last tab you closed" }, + { + text: "Connect your Apple Calendar to automatically see upcoming meetings", + }, +]; + +function RotatingTip() { + const [index, setIndex] = useState(() => + Math.floor(Math.random() * TIPS.length), + ); + + useEffect(() => { + const interval = setInterval(() => { + setIndex((prev) => (prev + 1) % TIPS.length); + }, 5000); + return () => clearInterval(interval); + }, []); + + return ( +
+ Did you know? +
+ + + {TIPS[index].text} + + +
+
+ ); +} + function EmptyView() { const newNote = useNewNote({ behavior: "current" }); const openCurrent = useTabs((state) => state.openCurrent); @@ -85,7 +137,7 @@ function EmptyView() { ); return ( -
+
+
+ +
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 f4a618d857..c8ba32e6bb 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 902bb158c9..0a5073fed6 100644 --- a/apps/desktop/src/components/main/sidebar/profile/index.tsx +++ b/apps/desktop/src/components/main/sidebar/profile/index.tsx @@ -105,8 +105,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 93729c6776..d2fa49ee20 100644 --- a/apps/desktop/src/components/main/sidebar/search/item.tsx +++ b/apps/desktop/src/components/main/sidebar/search/item.tsx @@ -283,13 +283,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 4159503f5f..3900001e23 100644 --- a/apps/desktop/src/components/settings/general/permissions.tsx +++ b/apps/desktop/src/components/settings/general/permissions.tsx @@ -132,6 +132,7 @@ export function Permissions() { const mic = usePermission("microphone"); const systemAudio = usePermission("systemAudio"); const accessibility = usePermission("accessibility"); + const contacts = usePermission("contacts"); return (
@@ -164,6 +165,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..d9366a2508 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", @@ -81,6 +82,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([ "a@example.com", @@ -98,6 +100,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([]); }); @@ -112,6 +115,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual([ "a@example.com", @@ -129,6 +133,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "", pinned: false, + created_at: "", }); expect(result.frontmatter.emails).toEqual(["a@example.com"]); }); @@ -143,6 +148,7 @@ describe("humanToFrontmatter", () => { linkedin_username: "", memo: "Some notes", pinned: false, + created_at: "", }); expect(result.body).toBe("Some notes"); }); @@ -150,6 +156,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,6 +168,7 @@ 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", 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..519ed510db 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,16 @@ 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, }); }); @@ -24,21 +27,61 @@ describe("frontmatterToOrganization", () => { const result = frontmatterToOrganization({}, ""); expect(result).toEqual({ user_id: "", + created_at: undefined, name: "", + pinned: false, + }); + }); + + 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, }); }); }); 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, + }, + 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, }, 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 b750a4ec95..2e4af8a748 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, } }