From 24f8312e310f8021926aaf4b5935e5e1fb324aa3 Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Wed, 2 Oct 2024 01:55:15 +0200 Subject: [PATCH] Implement chat create and refactor sidebar query --- .../components/chats/chat-create-popover.tsx | 84 ++++++++++++++ .../node-collaborator-search.tsx | 2 +- .../workspaces/sidebars/sidebar-chat-item.tsx | 53 +++++++++ .../workspaces/sidebars/sidebar-chats.tsx | 15 ++- .../workspaces/sidebars/sidebar-spaces.tsx | 13 +-- .../workspaces/sidebars/sidebar.tsx | 12 +- desktop/src/lib/id.ts | 1 + .../mutations/use-chat-create-mutation.tsx | 76 +++++++++++++ desktop/src/queries/use-messages-query.tsx | 7 +- desktop/src/queries/use-record-query.tsx | 2 + desktop/src/queries/use-records-query.tsx | 7 +- .../src/queries/use-sidebar-chats-query.tsx | 103 ++++++++++++++++++ ...query.tsx => use-sidebar-spaces-query.tsx} | 58 +++------- desktop/src/queries/use-user-search-query.tsx | 42 +++++++ desktop/src/types/databases.ts | 4 +- desktop/src/types/messages.ts | 4 +- desktop/src/types/users.ts | 5 +- 17 files changed, 408 insertions(+), 80 deletions(-) create mode 100644 desktop/src/components/chats/chat-create-popover.tsx create mode 100644 desktop/src/components/workspaces/sidebars/sidebar-chat-item.tsx create mode 100644 desktop/src/mutations/use-chat-create-mutation.tsx create mode 100644 desktop/src/queries/use-sidebar-chats-query.tsx rename desktop/src/queries/{use-sidebar-query.tsx => use-sidebar-spaces-query.tsx} (55%) create mode 100644 desktop/src/queries/use-user-search-query.tsx diff --git a/desktop/src/components/chats/chat-create-popover.tsx b/desktop/src/components/chats/chat-create-popover.tsx new file mode 100644 index 00000000..c592dd26 --- /dev/null +++ b/desktop/src/components/chats/chat-create-popover.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Icon } from '@/components/ui/icon'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Avatar } from '@/components/ui/avatar'; +import { useUserSearchQuery } from '@/queries/use-user-search-query'; +import { useChatCreateMutation } from '@/mutations/use-chat-create-mutation'; +import { useWorkspace } from '@/contexts/workspace'; + +export const ChatCreatePopover = () => { + const workspace = useWorkspace(); + + const [query, setQuery] = React.useState(''); + const [open, setOpen] = React.useState(false); + const { data } = useUserSearchQuery(query); + const { mutate } = useChatCreateMutation(); + + return ( + + + + + + + + No user found. + + + {data?.map((user) => ( + { + mutate( + { + userId: user.id, + }, + { + onSuccess: (id) => { + workspace.navigateToNode(id); + }, + }, + ); + setQuery(''); + }} + > +
+ +
+

{user.name}

+

+ {user.email} +

+
+
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/desktop/src/components/collaborators/node-collaborator-search.tsx b/desktop/src/components/collaborators/node-collaborator-search.tsx index d499aa5e..73bf04a6 100644 --- a/desktop/src/components/collaborators/node-collaborator-search.tsx +++ b/desktop/src/components/collaborators/node-collaborator-search.tsx @@ -16,8 +16,8 @@ import { } from '@/components/ui/command'; import { Icon } from '@/components/ui/icon'; import { Badge } from '@/components/ui/badge'; +import { Avatar } from '@/components/ui/avatar'; import { useNodeCollaboratorSearchQuery } from '@/queries/use-node-collaborator-search-query'; -import { Avatar } from '../ui/avatar'; interface NodeCollaboratorSearchProps { excluded: string[]; diff --git a/desktop/src/components/workspaces/sidebars/sidebar-chat-item.tsx b/desktop/src/components/workspaces/sidebars/sidebar-chat-item.tsx new file mode 100644 index 00000000..0fe2a903 --- /dev/null +++ b/desktop/src/components/workspaces/sidebars/sidebar-chat-item.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useWorkspace } from '@/contexts/workspace'; +import { SidebarChatNode } from '@/types/workspaces'; +import { cn } from '@/lib/utils'; +import { Avatar } from '@/components/ui/avatar'; +import { Icon } from '@/components/ui/icon'; + +interface SidebarChatItemProps { + node: SidebarChatNode; +} + +export const SidebarChatItem = ({ + node, +}: SidebarChatItemProps): React.ReactNode => { + const workspace = useWorkspace(); + const isActive = false; + const isUnread = false; + const directCount = 0; + + return ( +
{ + workspace.navigateToNode(node.id); + }} + > + + + {node.name ?? 'Unnamed'} + + {directCount > 0 && ( + + {directCount} + + )} + {directCount == 0 && isUnread && ( + + )} +
+ ); +}; diff --git a/desktop/src/components/workspaces/sidebars/sidebar-chats.tsx b/desktop/src/components/workspaces/sidebars/sidebar-chats.tsx index 4008b079..be88a5bb 100644 --- a/desktop/src/components/workspaces/sidebars/sidebar-chats.tsx +++ b/desktop/src/components/workspaces/sidebars/sidebar-chats.tsx @@ -1,17 +1,20 @@ import React from 'react'; -import { SidebarChatNode } from '@/types/workspaces'; +import { ChatCreatePopover } from '@/components/chats/chat-create-popover'; +import { useSidebarChatsQuery } from '@/queries/use-sidebar-chats-query'; +import { SidebarChatItem } from './sidebar-chat-item'; -interface SidebarChatsProps { - chats: SidebarChatNode[]; -} +export const SidebarChats = () => { + const { data } = useSidebarChatsQuery(); -export const SidebarChats = ({ chats }: SidebarChatsProps) => { return (
Chats + +
+
+ {data?.map((chat) => )}
-
); }; diff --git a/desktop/src/components/workspaces/sidebars/sidebar-spaces.tsx b/desktop/src/components/workspaces/sidebars/sidebar-spaces.tsx index 151cfa41..0541fc37 100644 --- a/desktop/src/components/workspaces/sidebars/sidebar-spaces.tsx +++ b/desktop/src/components/workspaces/sidebars/sidebar-spaces.tsx @@ -1,13 +1,10 @@ import React from 'react'; import { SpaceCreateButton } from '@/components/spaces/space-create-button'; -import { SidebarSpaceNode } from '@/types/workspaces'; import { SidebarSpaceItem } from '@/components/workspaces/sidebars/sidebar-space-item'; +import { useSidebarSpacesQuery } from '@/queries/use-sidebar-spaces-query'; -interface SidebarSpacesProps { - spaces: SidebarSpaceNode[]; -} - -export const SidebarSpaces = ({ spaces }: SidebarSpacesProps) => { +export const SidebarSpaces = () => { + const { data } = useSidebarSpacesQuery(); return (
@@ -15,9 +12,7 @@ export const SidebarSpaces = ({ spaces }: SidebarSpacesProps) => {
- {spaces.map((space) => ( - - ))} + {data?.map((space) => )}
); diff --git a/desktop/src/components/workspaces/sidebars/sidebar.tsx b/desktop/src/components/workspaces/sidebars/sidebar.tsx index 148694ad..8b9f3363 100644 --- a/desktop/src/components/workspaces/sidebars/sidebar.tsx +++ b/desktop/src/components/workspaces/sidebars/sidebar.tsx @@ -2,17 +2,9 @@ import React from 'react'; import { SidebarHeader } from '@/components/workspaces/sidebars/sidebar-header'; import { SidebarSpaces } from '@/components/workspaces/sidebars/sidebar-spaces'; import { SidebarChats } from '@/components/workspaces/sidebars/sidebar-chats'; -import { Spinner } from '@/components/ui/spinner'; import { Icon } from '@/components/ui/icon'; -import { useSidebarQuery } from '@/queries/use-sidebar-query'; export const Sidebar = () => { - const { data, isPending } = useSidebarQuery(); - - if (isPending) { - return ; - } - return (
@@ -25,8 +17,8 @@ export const Sidebar = () => { Inbox
- - + + ); diff --git a/desktop/src/lib/id.ts b/desktop/src/lib/id.ts index f8f4ced1..c587ba92 100644 --- a/desktop/src/lib/id.ts +++ b/desktop/src/lib/id.ts @@ -12,6 +12,7 @@ enum IdType { Space = 'sp', Page = 'pg', Channel = 'ch', + Chat = 'ct', Node = 'nd', Message = 'ms', Subscriber = 'sb', diff --git a/desktop/src/mutations/use-chat-create-mutation.tsx b/desktop/src/mutations/use-chat-create-mutation.tsx new file mode 100644 index 00000000..1d091e6f --- /dev/null +++ b/desktop/src/mutations/use-chat-create-mutation.tsx @@ -0,0 +1,76 @@ +import { useWorkspace } from '@/contexts/workspace'; +import { NodeTypes } from '@/lib/constants'; +import { NeuronId } from '@/lib/id'; +import { buildNodeInsertMutation } from '@/lib/nodes'; +import { useMutation } from '@tanstack/react-query'; + +interface CreateChatInput { + userId: string; +} + +export const useChatCreateMutation = () => { + const workspace = useWorkspace(); + + return useMutation({ + mutationFn: async (input: CreateChatInput) => { + const existingChats = workspace.schema + .selectFrom('nodes') + .where('type', '=', NodeTypes.Chat) + .where( + 'id', + 'in', + workspace.schema + .selectFrom('node_collaborators') + .select('node_id') + .where('collaborator_id', 'in', [workspace.userId, input.userId]) + .groupBy('node_id') + .having(workspace.schema.fn.count('collaborator_id'), '=', 2), + ) + .selectAll() + .compile(); + + const result = await workspace.query(existingChats); + if (result.rows.length > 0) { + const chat = result.rows[0]; + return chat.id; + } + + const id = NeuronId.generate(NeuronId.Type.Chat); + const insertChatQuery = buildNodeInsertMutation( + workspace.schema, + workspace.userId, + { + id: id, + attributes: { + type: NodeTypes.Chat, + }, + }, + ); + + const insertCollaboratorsQuery = workspace.schema + .insertInto('node_collaborators') + .values([ + { + node_id: id, + collaborator_id: workspace.userId, + role: 'owner', + created_at: new Date().toISOString(), + created_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }, + { + node_id: id, + collaborator_id: input.userId, + role: 'owner', + created_at: new Date().toISOString(), + created_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }, + ]) + .compile(); + + await workspace.mutate([insertChatQuery, insertCollaboratorsQuery]); + return id; + }, + }); +}; diff --git a/desktop/src/queries/use-messages-query.tsx b/desktop/src/queries/use-messages-query.tsx index 9542bfe6..3c1b879a 100644 --- a/desktop/src/queries/use-messages-query.tsx +++ b/desktop/src/queries/use-messages-query.tsx @@ -4,7 +4,7 @@ import { NodeTypes } from '@/lib/constants'; import { buildNodeWithChildren, mapNode } from '@/lib/nodes'; import { compareString } from '@/lib/utils'; import { MessageNode, MessageReactionCount } from '@/types/messages'; -import { User } from '@/types/users'; +import { UserNode } from '@/types/users'; import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; import { QueryResult, sql } from 'kysely'; @@ -132,15 +132,17 @@ const buildMessages = (rows: MessageRow[]): MessageNode[] => { .map(mapNode); const messages: MessageNode[] = []; - const authorMap = new Map(); + const authorMap = new Map(); for (const authorRow of authorRows) { const authorNode = mapNode(authorRow); const name = authorNode.attributes.name; + const email = authorNode.attributes.email; const avatar = authorNode.attributes.avatar; authorMap.set(authorRow.id, { id: authorRow.id, name: name ?? 'Unknown User', + email, avatar, }); } @@ -163,6 +165,7 @@ const buildMessages = (rows: MessageRow[]): MessageNode[] => { author: author ?? { id: messageNode.createdBy, name: 'Unknown User', + email: 'unknown@neuron.com', avatar: null, }, content: children, diff --git a/desktop/src/queries/use-record-query.tsx b/desktop/src/queries/use-record-query.tsx index 030222ef..63e4173b 100644 --- a/desktop/src/queries/use-record-query.tsx +++ b/desktop/src/queries/use-record-query.tsx @@ -57,11 +57,13 @@ const buildRecord = ( id: authorNode.id, name: authorNode.attributes.name, avatar: authorNode.attributes.avatar, + email: authorNode.attributes.email, } : { id: recordNode.createdBy, name: 'Unknown User', avatar: null, + email: 'unknown@neuron.com', }; return { diff --git a/desktop/src/queries/use-records-query.tsx b/desktop/src/queries/use-records-query.tsx index e7066482..62eb56c3 100644 --- a/desktop/src/queries/use-records-query.tsx +++ b/desktop/src/queries/use-records-query.tsx @@ -21,7 +21,7 @@ import { ViewFilter, ViewSort, } from '@/types/databases'; -import { User } from '@/types/users'; +import { UserNode } from '@/types/users'; import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; import { sha256 } from 'js-sha256'; import { QueryResult, sql } from 'kysely'; @@ -113,15 +113,17 @@ const buildRecords = (rows: SelectNode[]): RecordNode[] => { const authorNodes = nodes.filter((node) => node.type === NodeTypes.User); const records: RecordNode[] = []; - const authorMap = new Map(); + const authorMap = new Map(); for (const author of authorNodes) { const name = author.attributes.name; const avatar = author.attributes.avatar; + const email = author.attributes.email; authorMap.set(author.id, { id: author.id, name: name ?? 'Unknown User', + email, avatar, }); } @@ -139,6 +141,7 @@ const buildRecords = (rows: SelectNode[]): RecordNode[] => { createdBy: author ?? { id: node.createdBy, name: 'Unknown User', + email: 'unknown@neuron.com', avatar: null, }, versionId: node.versionId, diff --git a/desktop/src/queries/use-sidebar-chats-query.tsx b/desktop/src/queries/use-sidebar-chats-query.tsx new file mode 100644 index 00000000..b806ad9c --- /dev/null +++ b/desktop/src/queries/use-sidebar-chats-query.tsx @@ -0,0 +1,103 @@ +import { useWorkspace } from '@/contexts/workspace'; +import { SelectNode } from '@/electron/schemas/workspace'; +import { NodeTypes } from '@/lib/constants'; +import { mapNode } from '@/lib/nodes'; +import { SidebarChatNode } from '@/types/workspaces'; +import { useQuery } from '@tanstack/react-query'; +import { QueryResult, sql } from 'kysely'; + +type ChatRow = SelectNode & { + collaborators: string; +}; + +export const useSidebarChatsQuery = () => { + const workspace = useWorkspace(); + + return useQuery, Error, SidebarChatNode[], string[]>({ + queryKey: ['sidebar-chats', workspace.id], + queryFn: async ({ queryKey }) => { + const query = sql` + WITH chat_nodes AS ( + SELECT * + FROM nodes + WHERE parent_id IS NULL AND type = ${NodeTypes.Chat} + ), + collaborator_nodes AS ( + SELECT * + FROM nodes + WHERE id IN + ( + SELECT collaborator_id + FROM node_collaborators + WHERE collaborator_id != ${workspace.userId} + AND node_id IN + ( + SELECT id + FROM chat_nodes + ) + ) + ), + all_nodes AS ( + SELECT * FROM chat_nodes + UNION ALL + SELECT * FROM collaborator_nodes + ), + chat_collaborators AS ( + SELECT + nc.node_id, + json_group_array(nc.collaborator_id) AS collaborators + FROM node_collaborators nc + WHERE nc.node_id IN (SELECT id FROM chat_nodes) + AND nc.collaborator_id != ${workspace.userId} + GROUP BY nc.node_id + ) + SELECT + n.*, + COALESCE(cc.collaborators, json('[]')) AS collaborators + FROM all_nodes n + LEFT JOIN chat_collaborators cc ON n.id = cc.node_id + `.compile(workspace.schema); + + return await workspace.queryAndSubscribe({ + key: queryKey, + query, + }); + }, + select: (data: QueryResult): SidebarChatNode[] => { + const rows = data?.rows ?? []; + return buildSidebarSpaceNodes(rows); + }, + }); +}; + +const buildSidebarSpaceNodes = (rows: ChatRow[]): SidebarChatNode[] => { + const chats: SidebarChatNode[] = []; + + for (const row of rows) { + if (row.type !== NodeTypes.Chat) { + continue; + } + + const chatNode = mapNode(row); + const collaboratorIds = JSON.parse(row.collaborators) as string[]; + if (!collaboratorIds || collaboratorIds.length === 0) { + continue; + } + + const collaboratorId = collaboratorIds[0]; + const collaboratorRow = rows.find((r) => r.id === collaboratorId); + if (!collaboratorRow) { + continue; + } + + const collaboratorNode = mapNode(collaboratorRow); + chats.push({ + id: chatNode.id, + type: chatNode.type, + name: collaboratorNode.attributes.name, + avatar: chatNode.attributes.avatar, + }); + } + + return chats; +}; diff --git a/desktop/src/queries/use-sidebar-query.tsx b/desktop/src/queries/use-sidebar-spaces-query.tsx similarity index 55% rename from desktop/src/queries/use-sidebar-query.tsx rename to desktop/src/queries/use-sidebar-spaces-query.tsx index 62968d5c..c2f30deb 100644 --- a/desktop/src/queries/use-sidebar-query.tsx +++ b/desktop/src/queries/use-sidebar-spaces-query.tsx @@ -3,25 +3,16 @@ import { SelectNode } from '@/electron/schemas/workspace'; import { NodeTypes } from '@/lib/constants'; import { mapNode } from '@/lib/nodes'; import { LocalNode } from '@/types/nodes'; -import { - SidebarChatNode, - SidebarNode, - SidebarSpaceNode, -} from '@/types/workspaces'; +import { SidebarNode, SidebarSpaceNode } from '@/types/workspaces'; import { useQuery } from '@tanstack/react-query'; import { QueryResult, sql } from 'kysely'; -export type SidebarQueryResult = { - spaces: SidebarSpaceNode[]; - chats: SidebarChatNode[]; -}; - -export const useSidebarQuery = () => { +export const useSidebarSpacesQuery = () => { const workspace = useWorkspace(); - return useQuery, Error, SidebarQueryResult, string[]>( + return useQuery, Error, SidebarSpaceNode[], string[]>( { - queryKey: ['sidebar', workspace.id], + queryKey: ['sidebar-spaces', workspace.id], queryFn: async ({ queryKey }) => { const query = sql` WITH space_nodes AS ( @@ -33,17 +24,10 @@ export const useSidebarQuery = () => { SELECT * FROM nodes WHERE parent_id IN (SELECT id FROM space_nodes) - ), - chat_nodes AS ( - SELECT * - FROM nodes - WHERE parent_id IS NULL AND type = ${NodeTypes.Chat} ) SELECT * FROM space_nodes UNION ALL - SELECT * FROM space_children_nodes - UNION ALL - SELECT * FROM chat_nodes; + SELECT * FROM space_children_nodes; `.compile(workspace.schema); return await workspace.queryAndSubscribe({ @@ -51,33 +35,28 @@ export const useSidebarQuery = () => { query, }); }, - select: (data: QueryResult): SidebarQueryResult => { + select: (data: QueryResult): SidebarSpaceNode[] => { const rows = data?.rows ?? []; - return buildSidebarNodes(rows); + return buildSidebarSpaceNodes(rows); }, }, ); }; -const buildSidebarNodes = (rows: SelectNode[]): SidebarQueryResult => { +const buildSidebarSpaceNodes = (rows: SelectNode[]): SidebarSpaceNode[] => { const nodes: LocalNode[] = rows.map(mapNode); - const spaces: SidebarSpaceNode[] = []; - const chats: SidebarChatNode[] = []; for (const node of nodes) { - if (node.type === NodeTypes.Space) { - const children = nodes.filter((n) => n.parentId === node.id); - spaces.push(buildSpaceNode(node, children)); - } else if (node.type === NodeTypes.Chat) { - chats.push(buildChatNode(node)); + if (node.type !== NodeTypes.Space) { + continue; } + + const children = nodes.filter((n) => n.parentId === node.id); + spaces.push(buildSpaceNode(node, children)); } - return { - spaces, - chats, - }; + return spaces; }; const buildSpaceNode = ( @@ -101,12 +80,3 @@ const buildSidearNode = (node: LocalNode): SidebarNode => { avatar: node.attributes.avatar ?? null, }; }; - -const buildChatNode = (node: LocalNode): SidebarChatNode => { - return { - id: node.id, - type: node.type, - name: node.attributes.name ?? null, - avatar: node.attributes.avatar ?? null, - }; -}; diff --git a/desktop/src/queries/use-user-search-query.tsx b/desktop/src/queries/use-user-search-query.tsx new file mode 100644 index 00000000..acb45483 --- /dev/null +++ b/desktop/src/queries/use-user-search-query.tsx @@ -0,0 +1,42 @@ +import { useWorkspace } from '@/contexts/workspace'; +import { SelectNode } from '@/electron/schemas/workspace'; +import { NodeTypes } from '@/lib/constants'; +import { UserNode } from '@/types/users'; +import { useQuery } from '@tanstack/react-query'; +import { QueryResult, sql } from 'kysely'; + +export const useUserSearchQuery = (searchQuery: string) => { + const workspace = useWorkspace(); + + return useQuery, Error, UserNode[], string[]>({ + queryKey: ['user-search', searchQuery], + enabled: searchQuery.length > 0, + queryFn: async ({ queryKey }) => { + const query = sql` + SELECT n.* + FROM nodes n + JOIN node_names nn ON n.id = nn.id + WHERE n.type = ${NodeTypes.User} + AND n.id != ${workspace.userId} + AND nn.name MATCH ${searchQuery + '*'} + `.compile(workspace.schema); + + return await workspace.queryAndSubscribe({ + key: queryKey, + query, + }); + }, + select: (data: QueryResult): UserNode[] => { + const rows = data?.rows ?? []; + return rows.map((row) => { + const attributes = JSON.parse(row.attributes); + return { + id: row.id, + name: attributes.name, + email: attributes.email, + avatar: attributes.avatar, + }; + }); + }, + }); +}; diff --git a/desktop/src/types/databases.ts b/desktop/src/types/databases.ts index 388bfe51..605992a9 100644 --- a/desktop/src/types/databases.ts +++ b/desktop/src/types/databases.ts @@ -1,4 +1,4 @@ -import { User } from '@/types/users'; +import { UserNode } from '@/types/users'; export type DatabaseNode = { id: string; @@ -173,7 +173,7 @@ export type RecordNode = { parentId: string; index: string; createdAt: Date; - createdBy: User; + createdBy: UserNode; versionId: string; attributes: any; diff --git a/desktop/src/types/messages.ts b/desktop/src/types/messages.ts index 5c4ea402..cef2eed2 100644 --- a/desktop/src/types/messages.ts +++ b/desktop/src/types/messages.ts @@ -1,11 +1,11 @@ import { LocalNodeWithChildren } from '@/types/nodes'; -import { User } from '@/types/users'; +import { UserNode } from '@/types/users'; export type MessageNode = { id: string; content: LocalNodeWithChildren[]; createdAt: string; - author: User; + author: UserNode; reactionCounts: MessageReactionCount[]; userReactions: string[]; }; diff --git a/desktop/src/types/users.ts b/desktop/src/types/users.ts index fbfc64c2..01aa5c05 100644 --- a/desktop/src/types/users.ts +++ b/desktop/src/types/users.ts @@ -1,5 +1,6 @@ -export type User = { +export type UserNode = { id: string; name: string; - avatar: string; + email: string; + avatar: string | null; };