Implement chat create and refactor sidebar query

This commit is contained in:
Hakan Shehu
2024-10-02 01:55:15 +02:00
parent 371a7dc9a6
commit 24f8312e31
17 changed files with 408 additions and 80 deletions

View File

@@ -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 (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Icon name="add-line" className="mr-2 h-3 w-3 cursor-pointer" />
</PopoverTrigger>
<PopoverContent className="w-96 p-1">
<Command className="min-h-min" shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search users..."
className="h-9"
/>
<CommandEmpty>No user found.</CommandEmpty>
<CommandList>
<CommandGroup className="h-min max-h-96">
{data?.map((user) => (
<CommandItem
key={user.id}
onSelect={() => {
mutate(
{
userId: user.id,
},
{
onSuccess: (id) => {
workspace.navigateToNode(id);
},
},
);
setQuery('');
}}
>
<div className="flex w-full flex-row items-center gap-2">
<Avatar
id={user.id}
name={user.name}
avatar={user.avatar}
className="h-7 w-7"
/>
<div className="flex flex-grow flex-col">
<p className="text-sm">{user.name}</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -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[];

View File

@@ -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 (
<div
key={node.id}
className={cn(
'flex cursor-pointer items-center rounded-md p-1 text-sm text-foreground/80 hover:bg-gray-100',
isActive && 'bg-gray-100',
)}
onClick={() => {
workspace.navigateToNode(node.id);
}}
>
<Avatar id={node.id} avatar={node.avatar} name={node.name} size="small" />
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold',
)}
>
{node.name ?? 'Unnamed'}
</span>
{directCount > 0 && (
<span className="mr-1 rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
{directCount}
</span>
)}
{directCount == 0 && isUnread && (
<Icon
name="checkbox-blank-circle-fill"
className="mr-2 h-3 w-3 p-0.5 text-red-500"
/>
)}
</div>
);
};

View File

@@ -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 (
<div className="pt-2 first:pt-0">
<div className="flex items-center justify-between p-1 pb-2 text-xs text-muted-foreground">
<span>Chats</span>
<ChatCreatePopover />
</div>
<div className="flex flex-col gap-0.5">
{data?.map((chat) => <SidebarChatItem key={chat.id} node={chat} />)}
</div>
<div className="flex flex-col gap-0.5"></div>
</div>
);
};

View File

@@ -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 (
<div className="pt-2 first:pt-0">
<div className="flex items-center justify-between p-1 pb-2 text-xs text-muted-foreground">
@@ -15,9 +12,7 @@ export const SidebarSpaces = ({ spaces }: SidebarSpacesProps) => {
<SpaceCreateButton />
</div>
<div className="flex flex-col gap-0.5">
{spaces.map((space) => (
<SidebarSpaceItem node={space} key={space.id} />
))}
{data?.map((space) => <SidebarSpaceItem node={space} key={space.id} />)}
</div>
</div>
);

View File

@@ -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 <Spinner />;
}
return (
<div className="h-full max-h-screen w-full border-r border-gray-200">
<SidebarHeader />
@@ -25,8 +17,8 @@ export const Sidebar = () => {
<Icon name="inbox-line" className="mr-2 h-4 w-4" />
<span>Inbox</span>
</div>
<SidebarChats chats={data?.chats ?? []} />
<SidebarSpaces spaces={data?.spaces ?? []} />
<SidebarChats />
<SidebarSpaces />
</div>
</div>
);

View File

@@ -12,6 +12,7 @@ enum IdType {
Space = 'sp',
Page = 'pg',
Channel = 'ch',
Chat = 'ct',
Node = 'nd',
Message = 'ms',
Subscriber = 'sb',

View File

@@ -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;
},
});
};

View File

@@ -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<string, User>();
const authorMap = new Map<string, UserNode>();
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,

View File

@@ -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 {

View File

@@ -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<string, User>();
const authorMap = new Map<string, UserNode>();
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,

View File

@@ -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<QueryResult<ChatRow>, Error, SidebarChatNode[], string[]>({
queryKey: ['sidebar-chats', workspace.id],
queryFn: async ({ queryKey }) => {
const query = sql<ChatRow>`
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<ChatRow>): 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;
};

View File

@@ -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<QueryResult<SelectNode>, Error, SidebarQueryResult, string[]>(
return useQuery<QueryResult<SelectNode>, Error, SidebarSpaceNode[], string[]>(
{
queryKey: ['sidebar', workspace.id],
queryKey: ['sidebar-spaces', workspace.id],
queryFn: async ({ queryKey }) => {
const query = sql<SelectNode>`
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<SelectNode>): SidebarQueryResult => {
select: (data: QueryResult<SelectNode>): 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,
};
};

View File

@@ -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<QueryResult<SelectNode>, Error, UserNode[], string[]>({
queryKey: ['user-search', searchQuery],
enabled: searchQuery.length > 0,
queryFn: async ({ queryKey }) => {
const query = sql<SelectNode>`
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<SelectNode>): 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,
};
});
},
});
};

View File

@@ -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;

View File

@@ -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[];
};

View File

@@ -1,5 +1,6 @@
export type User = {
export type UserNode = {
id: string;
name: string;
avatar: string;
email: string;
avatar: string | null;
};