Use tanstack/db for node reactions

This commit is contained in:
Hakan Shehu
2025-11-27 15:49:56 -08:00
parent 1ebdc91e89
commit 1728b6eb49
18 changed files with 278 additions and 344 deletions

43
package-lock.json generated
View File

@@ -12308,14 +12308,14 @@
}
},
"node_modules/@tanstack/db": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.5.3.tgz",
"integrity": "sha512-LHzSKTbD0Pe1aJ9zw6bcy2Lhc7lBF2ay8iwYSNxnhxvW+0kTPAu7VQ4yaMDup/me0tN3moOnbnhymJqPG/PCjg==",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.5.8.tgz",
"integrity": "sha512-X6z/OFTzQ8tyOKiWCEqQPSH/w+pmEWack+EMPweAyssXvT6qbfBayRxSoKl93ZTTdgWHBpGlQE75+vVkT1Szqw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@tanstack/db-ivm": "0.1.13",
"@tanstack/pacer": "^0.16.3"
"@tanstack/pacer-lite": "^0.1.0"
},
"peerDependencies": {
"typescript": ">=4.7"
@@ -12334,19 +12334,6 @@
"typescript": ">=4.7"
}
},
"node_modules/@tanstack/devtools-event-client": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.3.5.tgz",
"integrity": "sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/history": {
"version": "1.133.28",
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.133.28.tgz",
@@ -12360,15 +12347,11 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/pacer": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.16.3.tgz",
"integrity": "sha512-hJGPODkjuUEncwHsFacLY6W5E7lmEU2FMf4Mh0kuxqUx3UsuneQX6ctRpoHBLlgdb7sqDieIaslQnivG3OAZ+A==",
"node_modules/@tanstack/pacer-lite": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.0.tgz",
"integrity": "sha512-a5A0PI0H4npUy7u3VOjOhdynXnRBna+mDvpt8ghDCVzS3Tgn8DlGzHlRqS2rKJP8ZcLuVO2qxlIIblhcoaiv8Q==",
"license": "MIT",
"dependencies": {
"@tanstack/devtools-event-client": "^0.3.5",
"@tanstack/store": "^0.8.0"
},
"engines": {
"node": ">=18"
},
@@ -12388,12 +12371,12 @@
}
},
"node_modules/@tanstack/react-db": {
"version": "0.1.47",
"resolved": "https://registry.npmjs.org/@tanstack/react-db/-/react-db-0.1.47.tgz",
"integrity": "sha512-kbp41od/SXJl+7mQr5Hn7TT0ticpmp9FV8JlhWHlGQD9ps85CfGv6FQN3m84RR06fC8m1U2usasNSx0qy2dqYA==",
"version": "0.1.52",
"resolved": "https://registry.npmjs.org/@tanstack/react-db/-/react-db-0.1.52.tgz",
"integrity": "sha512-luzzEe7Asb47gHMmONnsfmBX2ljtdkD7T6a+n/6Z+dbFQjqAqI9NNA9ewlchPjOXbkrjqTs4Wv7qKjbtdqGaVQ==",
"license": "MIT",
"dependencies": {
"@tanstack/db": "0.5.3",
"@tanstack/db": "0.5.8",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -32380,7 +32363,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-oauth/google": "^0.12.2",
"@tanstack/react-db": "^0.1.47",
"@tanstack/react-db": "^0.1.52",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-router": "^1.136.17",
"@tanstack/react-virtual": "^3.13.12",

View File

@@ -27,7 +27,6 @@ import { IconSvgGetQueryHandler } from './icons/icon-svg-get';
import { RadarDataGetQueryHandler } from './interactions/radar-data-get';
import { NodeListQueryHandler } from './nodes/node-list';
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
import { RecordFieldValueCountQueryHandler } from './records/record-field-value-count';
import { RecordSearchQueryHandler } from './records/record-search';
@@ -48,7 +47,6 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
'avatar.get': new AvatarGetQueryHandler(app),
'account.list': new AccountListQueryHandler(app),
'node.reaction.list': new NodeReactionsListQueryHandler(app),
'node.reactions.aggregate': new NodeReactionsAggregateQueryHandler(app),
'node.list': new NodeListQueryHandler(app),
'node.tree.get': new NodeTreeGetQueryHandler(app),
'record.field.value.count': new RecordFieldValueCountQueryHandler(app),

View File

@@ -89,15 +89,10 @@ export class NodeReactionsListQueryHandler
): Promise<NodeReaction[]> {
const workspace = this.getWorkspace(input.userId);
const offset = (input.page - 1) * input.count;
const reactions = await workspace.database
.selectFrom('node_reactions')
.selectAll()
.where('node_id', '=', input.nodeId)
.where('reaction', '=', input.reaction)
.orderBy('created_at', 'desc')
.limit(input.count)
.offset(offset)
.execute();
return reactions.map((row) => {

View File

@@ -1,127 +0,0 @@
import { sql } from 'kysely';
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib';
import { NodeReactionsAggregateQueryInput } from '@colanode/client/queries/nodes/node-reactions-aggregate';
import { Event } from '@colanode/client/types/events';
import { NodeReactionCount } from '@colanode/client/types/nodes';
interface NodeReactionsAggregateRow {
reaction: string;
count: number;
reacted: number;
}
export class NodeReactionsAggregateQueryHandler
extends WorkspaceQueryHandlerBase
implements QueryHandler<NodeReactionsAggregateQueryInput>
{
public async handleQuery(
input: NodeReactionsAggregateQueryInput
): Promise<NodeReactionCount[]> {
return this.fetchNodeReactions(input);
}
public async checkForChanges(
event: Event,
input: NodeReactionsAggregateQueryInput,
_: NodeReactionCount[]
): Promise<ChangeCheckResult<NodeReactionsAggregateQueryInput>> {
if (
event.type === 'workspace.deleted' &&
event.workspace.userId === input.userId
) {
return {
hasChanges: true,
result: [],
};
}
if (
event.type === 'node.reaction.created' &&
event.workspace.userId === input.userId &&
event.nodeReaction.nodeId === input.nodeId
) {
const newResult = await this.handleQuery(input);
return {
hasChanges: true,
result: newResult,
};
}
if (
event.type === 'node.reaction.deleted' &&
event.workspace.userId === input.userId &&
event.nodeReaction.nodeId === input.nodeId
) {
const newResult = await this.handleQuery(input);
return {
hasChanges: true,
result: newResult,
};
}
if (
event.type === 'node.created' &&
event.workspace.userId === input.userId &&
event.node.id === input.nodeId
) {
const newResult = await this.handleQuery(input);
return {
hasChanges: true,
result: newResult,
};
}
if (
event.type === 'node.deleted' &&
event.workspace.userId === input.userId &&
event.node.id === input.nodeId
) {
return {
hasChanges: true,
result: [],
};
}
return {
hasChanges: false,
};
}
private async fetchNodeReactions(
input: NodeReactionsAggregateQueryInput
): Promise<NodeReactionCount[]> {
const workspace = this.getWorkspace(input.userId);
const result = await sql<NodeReactionsAggregateRow>`
SELECT
reaction,
COUNT(reaction) as count,
MAX(CASE
WHEN collaborator_id = ${workspace.userId} THEN 1
ELSE 0
END) as reacted
FROM node_reactions
WHERE node_id = ${input.nodeId}
GROUP BY reaction
`.execute(workspace.database);
if (result.rows.length === 0) {
return [];
}
const counts = result.rows.map((row) => {
return {
reaction: row.reaction,
count: row.count,
reacted: row.reacted === 1,
};
});
return counts;
}
}

View File

@@ -17,7 +17,6 @@ export * from './icons/icon-list';
export * from './icons/icon-search';
export * from './interactions/radar-data-get';
export * from './nodes/node-reaction-list';
export * from './nodes/node-reactions-aggregate';
export * from './nodes/node-tree-get';
export * from './records/record-search';
export * from './users/user-list';

View File

@@ -3,10 +3,7 @@ import { NodeReaction } from '@colanode/client/types/nodes';
export type NodeReactionListQueryInput = {
type: 'node.reaction.list';
nodeId: string;
reaction: string;
userId: string;
page: number;
count: number;
};
declare module '@colanode/client/queries' {

View File

@@ -1,16 +0,0 @@
import { NodeReactionCount } from '@colanode/client/types/nodes';
export type NodeReactionsAggregateQueryInput = {
type: 'node.reactions.aggregate';
nodeId: string;
userId: string;
};
declare module '@colanode/client/queries' {
interface QueryMap {
'node.reactions.aggregate': {
input: NodeReactionsAggregateQueryInput;
output: NodeReactionCount[];
};
}
}

View File

@@ -40,7 +40,6 @@ export type NodeReaction = {
export type NodeReactionCount = {
reaction: string;
count: number;
reacted: boolean;
};
export type NodeReference = {

View File

@@ -44,7 +44,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-oauth/google": "^0.12.2",
"@tanstack/react-db": "^0.1.47",
"@tanstack/react-db": "^0.1.52",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-router": "^1.136.17",
"@tanstack/react-virtual": "^3.13.12",

View File

@@ -13,12 +13,14 @@ import {
LocalPageNode,
LocalRecordNode,
LocalSpaceNode,
NodeReaction,
Upload,
User,
} from '@colanode/client/types';
import { createAccountsCollection } from '@colanode/ui/collections/accounts';
import { createDownloadsCollection } from '@colanode/ui/collections/downloads';
import { createMetadataCollection } from '@colanode/ui/collections/metadata';
import { createNodeReactionsCollection } from '@colanode/ui/collections/node-reactions';
import { createNodesCollection } from '@colanode/ui/collections/nodes';
import { createServersCollection } from '@colanode/ui/collections/servers';
import { createTabsCollection } from '@colanode/ui/collections/tabs';
@@ -44,6 +46,7 @@ export class WorkspaceCollections {
public readonly messages: Collection<LocalMessageNode>;
public readonly folders: Collection<LocalFolderNode>;
public readonly pages: Collection<LocalPageNode>;
public readonly nodeReactions: Collection<NodeReaction, string>;
constructor(userId: string) {
this.userId = userId;
@@ -51,6 +54,7 @@ export class WorkspaceCollections {
this.downloads = createDownloadsCollection(userId);
this.uploads = createUploadsCollection(userId);
this.nodes = createNodesCollection(userId);
this.nodeReactions = createNodeReactionsCollection(userId);
this.databases = createLiveQueryCollection((q) =>
q

View File

@@ -0,0 +1,84 @@
import { createCollection, parseLoadSubsetOptions } from '@tanstack/react-db';
import { NodeReaction } from '@colanode/client/types';
import {
applyNodeReactionTransaction,
buildNodeReactionKey,
} from '@colanode/ui/lib/nodes';
export const createNodeReactionsCollection = (userId: string) => {
const loadedNodeIds = new Set<string>();
return createCollection<NodeReaction, string>({
syncMode: 'on-demand',
getKey(item) {
return buildNodeReactionKey(
item.nodeId,
item.collaboratorId,
item.reaction
);
},
sync: {
sync({ begin, write, commit }) {
const subscriptionId = window.eventBus.subscribe((event) => {
if (
event.type === 'node.reaction.created' &&
event.workspace.userId === userId &&
loadedNodeIds.has(event.nodeReaction.nodeId)
) {
begin();
write({ type: 'insert', value: event.nodeReaction });
commit();
} else if (
event.type === 'node.reaction.deleted' &&
event.workspace.userId === userId &&
loadedNodeIds.has(event.nodeReaction.nodeId)
) {
begin();
write({ type: 'delete', value: event.nodeReaction });
commit();
}
});
return {
cleanup: () => window.eventBus.unsubscribe(subscriptionId),
loadSubset: async (options) => {
const parsedOptions = parseLoadSubsetOptions(options);
const nodeId = parsedOptions.filters.find(
(filter) => filter.field.join('.') === 'nodeId'
)?.value;
if (!nodeId) {
return;
}
if (loadedNodeIds.has(nodeId)) {
return;
}
loadedNodeIds.add(nodeId);
const nodeReactions = await window.colanode.executeQuery({
type: 'node.reaction.list',
userId,
nodeId,
});
begin();
for (const nodeReaction of nodeReactions) {
write({ type: 'insert', value: nodeReaction });
}
commit();
},
};
},
},
onInsert: async ({ transaction }) => {
await applyNodeReactionTransaction(userId, transaction);
},
onUpdate: async ({ transaction }) => {
await applyNodeReactionTransaction(userId, transaction);
},
onDelete: async ({ transaction }) => {
await applyNodeReactionTransaction(userId, transaction);
},
});
};

View File

@@ -1,14 +1,13 @@
import { MessagesSquare, Reply, Trash2 } from 'lucide-react';
import { useCallback } from 'react';
import { toast } from 'sonner';
import { MessageQuickReaction } from '@colanode/ui/components/messages/message-quick-reaction';
import { MessageReactionCreatePopover } from '@colanode/ui/components/messages/message-reaction-create-popover';
import { useConversation } from '@colanode/ui/contexts/conversation';
import { useMessage } from '@colanode/ui/contexts/message';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { defaultEmojis } from '@colanode/ui/lib/assets';
import { buildNodeReactionKey } from '@colanode/ui/lib/nodes';
const MessageAction = ({ children }: { children: React.ReactNode }) => {
return (
@@ -22,28 +21,27 @@ export const MessageActions = () => {
const message = useMessage();
const workspace = useWorkspace();
const conversation = useConversation();
const { mutate, isPending } = useMutation();
const handleReactionClick = useCallback(
(reaction: string) => {
if (isPending) {
return;
}
mutate({
input: {
type: 'node.reaction.create',
const reactionKey = buildNodeReactionKey(
message.id,
workspace.userId,
reaction
);
if (workspace.collections.nodeReactions.has(reactionKey)) {
workspace.collections.nodeReactions.delete(reactionKey);
} else {
workspace.collections.nodeReactions.insert({
nodeId: message.id,
userId: workspace.userId,
collaboratorId: workspace.userId,
reaction,
rootId: conversation.rootId,
},
onError(error) {
toast.error(error.message);
},
});
createdAt: new Date().toISOString(),
});
}
},
[isPending, mutate, workspace.userId, message.id]
[workspace.userId, message.id, conversation.rootId]
);
return (
@@ -66,33 +64,14 @@ export const MessageActions = () => {
onClick={handleReactionClick}
/>
</MessageAction>
<div className="mx-1 h-6 w-[1px] bg-border" />
<div className="mx-1 h-6 w-px bg-border" />
{message.canReplyInThread && (
<MessageAction>
<MessagesSquare className="size-4 cursor-pointer" />
</MessageAction>
)}
<MessageAction>
<MessageReactionCreatePopover
onReactionClick={(reaction) => {
if (isPending) {
return;
}
mutate({
input: {
type: 'node.reaction.create',
nodeId: message.id,
userId: workspace.userId,
reaction,
rootId: conversation.rootId,
},
onError(error) {
toast.error(error.message);
},
});
}}
/>
<MessageReactionCreatePopover onReactionClick={handleReactionClick} />
</MessageAction>
{conversation.canCreateMessage && (
<MessageAction>

View File

@@ -1,7 +1,6 @@
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { MessagesSquare, Reply, Trash2 } from 'lucide-react';
import { useCallback } from 'react';
import { toast } from 'sonner';
import { LocalMessageNode } from '@colanode/client/types';
import { MessageQuickReaction } from '@colanode/ui/components/messages/message-quick-reaction';
@@ -15,8 +14,8 @@ import {
import { useConversation } from '@colanode/ui/contexts/conversation';
import { useMessage } from '@colanode/ui/contexts/message';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { defaultEmojis } from '@colanode/ui/lib/assets';
import { buildNodeReactionKey } from '@colanode/ui/lib/nodes';
import { cn } from '@colanode/ui/lib/utils';
interface MessageMenuMobileProps {
@@ -55,39 +54,30 @@ export const MessageMenuMobile = ({
const conversation = useConversation();
const message = useMessage();
const { mutate, isPending } = useMutation();
const canReplyInThread = false;
const handleReactionClick = useCallback(
(reaction: string) => {
if (isPending) {
return;
}
mutate({
input: {
type: 'node.reaction.create',
const reactionKey = buildNodeReactionKey(
message.id,
workspace.userId,
reaction
);
if (workspace.collections.nodeReactions.has(reactionKey)) {
workspace.collections.nodeReactions.delete(reactionKey);
} else {
workspace.collections.nodeReactions.insert({
nodeId: message.id,
userId: workspace.userId,
collaboratorId: workspace.userId,
reaction,
rootId: conversation.rootId,
},
onError(error) {
toast.error(error.message);
},
});
createdAt: new Date().toISOString(),
});
}
onOpenChange(false);
},
[
isPending,
mutate,
workspace.accountId,
message.id,
conversation.rootId,
onOpenChange,
]
[workspace.userId, message.id, conversation.rootId]
);
const handleReply = () => {
@@ -132,11 +122,7 @@ export const MessageMenuMobile = ({
</div>
<div className="flex items-center justify-center w-12 h-12 rounded-xl border hover:bg-accent transition-colors">
<MessageReactionCreatePopover
onReactionClick={(reaction) => {
if (!isPending) {
handleReactionClick(reaction);
}
}}
onReactionClick={handleReactionClick}
/>
</div>
</div>

View File

@@ -1,12 +1,9 @@
import {
inArray,
useLiveQuery as useLiveQueryTanstack,
} from '@tanstack/react-db';
import { eq, inArray, useLiveQuery } from '@tanstack/react-db';
import { NodeReactionCount, LocalMessageNode } from '@colanode/client/types';
import { EmojiElement } from '@colanode/ui/components/emojis/emoji-element';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useLiveQuery } from '@colanode/ui/hooks/use-live-query';
import { useLiveQuery as useColanodeLiveQuery } from '@colanode/ui/hooks/use-live-query';
interface MessageReactionCountTooltipContentProps {
message: LocalMessageNode;
@@ -19,25 +16,28 @@ export const MessageReactionCountTooltipContent = ({
}: MessageReactionCountTooltipContentProps) => {
const workspace = useWorkspace();
const emojiGetQuery = useLiveQuery({
const emojiGetQuery = useColanodeLiveQuery({
type: 'emoji.get.by.skin.id',
id: reactionCount.reaction,
});
const nodeReactionListQuery = useLiveQuery({
type: 'node.reaction.list',
nodeId: message.id,
reaction: reactionCount.reaction,
userId: workspace.userId,
page: 0,
count: 3,
});
const nodeReactionListQuery = useLiveQuery(
(q) =>
q
.from({ nodeReactions: workspace.collections.nodeReactions })
.where(({ nodeReactions }) => eq(nodeReactions.nodeId, message.id))
.where(({ nodeReactions }) =>
eq(nodeReactions.reaction, reactionCount.reaction)
)
.orderBy(({ nodeReactions }) => nodeReactions.createdAt, 'desc')
.limit(3),
[message.id, reactionCount.reaction]
);
const userIds =
nodeReactionListQuery.data?.map((reaction) => reaction.collaboratorId) ??
[];
const nodeReactions = nodeReactionListQuery.data ?? [];
const userIds = nodeReactions.map((reaction) => reaction.collaboratorId);
const usersQuery = useLiveQueryTanstack((q) =>
const usersQuery = useLiveQuery((q) =>
q
.from({ users: workspace.collections.users })
.where(({ users }) => inArray(users.id, userIds))
@@ -47,7 +47,8 @@ export const MessageReactionCountTooltipContent = ({
}))
);
const users = usersQuery.data.map((user) => user.customName ?? user.name);
const users =
usersQuery.data?.map((user) => user.customName ?? user.name) ?? [];
const emojiName = `:${emojiGetQuery.data?.code ?? emojiGetQuery.data?.name ?? reactionCount.reaction}:`;
return (

View File

@@ -1,17 +1,17 @@
import {
and,
eq,
inArray,
useLiveQuery as useLiveQueryTanstack,
useLiveInfiniteQuery,
useLiveQuery,
} from '@tanstack/react-db';
import { useState } from 'react';
import { InView } from 'react-intersection-observer';
import { NodeReactionListQueryInput } from '@colanode/client/queries';
import { NodeReactionCount, LocalMessageNode } from '@colanode/client/types';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useLiveQueries } from '@colanode/ui/hooks/use-live-queries';
const REACTIONS_PER_PAGE = 20;
const REACTIONS_PER_PAGE = 50;
interface MessageReactionCountsDialogListProps {
message: LocalMessageNode;
@@ -24,27 +24,29 @@ export const MessageReactionCountsDialogList = ({
}: MessageReactionCountsDialogListProps) => {
const workspace = useWorkspace();
const [lastPage, setLastPage] = useState<number>(1);
const inputs: NodeReactionListQueryInput[] = Array.from({
length: lastPage,
}).map((_, i) => ({
type: 'node.reaction.list',
nodeId: message.id,
reaction: reactionCount.reaction,
userId: workspace.userId,
page: i + 1,
count: REACTIONS_PER_PAGE,
}));
const result = useLiveQueries(inputs);
const reactions = result.flatMap((data) => data.data ?? []);
const isPending = result.some((data) => data.isPending);
const hasMore =
!isPending && reactions.length === lastPage * REACTIONS_PER_PAGE;
const reactionsQuery = useLiveInfiniteQuery(
(q) =>
q
.from({ nodeReactions: workspace.collections.nodeReactions })
.where(({ nodeReactions }) =>
and(
eq(nodeReactions.nodeId, message.id),
eq(nodeReactions.reaction, reactionCount.reaction)
)
)
.orderBy(({ nodeReactions }) => nodeReactions.createdAt, 'desc'),
{
pageSize: REACTIONS_PER_PAGE,
getNextPageParam: (lastPage, allPages) =>
lastPage.length === REACTIONS_PER_PAGE ? allPages.length : undefined,
},
[workspace.userId, message.id, reactionCount.reaction]
);
const reactions = reactionsQuery.data;
const userIds = reactions?.map((reaction) => reaction.collaboratorId) ?? [];
const usersQuery = useLiveQueryTanstack((q) =>
const usersQuery = useLiveQuery((q) =>
q
.from({ users: workspace.collections.users })
.where(({ users }) => inArray(users.id, userIds))
@@ -72,11 +74,15 @@ export const MessageReactionCountsDialogList = ({
<InView
rootMargin="200px"
onChange={(inView) => {
if (inView && hasMore && !isPending) {
setLastPage(lastPage + 1);
if (
inView &&
reactionsQuery.hasNextPage &&
!reactionsQuery.isFetchingNextPage
) {
reactionsQuery.fetchNextPage();
}
}}
></InView>
/>
</div>
);
};

View File

@@ -42,7 +42,7 @@ export const MessageReactionCountsDialog = ({
defaultValue={reactionCounts[0]!.reaction}
className="flex flex-row gap-4"
>
<TabsList>
<TabsList className="flex flex-col gap-4">
{reactionCounts.map((reactionCount) => (
<TabsTrigger
key={`tab-trigger-${reactionCount.reaction}`}

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { count, eq, useLiveQuery } from '@tanstack/react-db';
import { useCallback, useState } from 'react';
import { LocalMessageNode } from '@colanode/client/types';
import { EmojiElement } from '@colanode/ui/components/emojis/emoji-element';
import { MessageReactionCountTooltip } from '@colanode/ui/components/messages/message-reaction-count-tooltip';
import { MessageReactionCountsDialog } from '@colanode/ui/components/messages/message-reaction-counts-dialog';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useLiveQuery } from '@colanode/ui/hooks/use-live-query';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { buildNodeReactionKey } from '@colanode/ui/lib/nodes';
import { cn } from '@colanode/ui/lib/utils';
interface MessageReactionCountsProps {
@@ -20,15 +19,50 @@ export const MessageReactionCounts = ({
const workspace = useWorkspace();
const [openDialog, setOpenDialog] = useState(false);
const { mutate, isPending } = useMutation();
const handleReactionClick = useCallback((reaction: string) => {
const reactionKey = buildNodeReactionKey(
message.id,
workspace.userId,
reaction
);
if (workspace.collections.nodeReactions.has(reactionKey)) {
workspace.collections.nodeReactions.delete(reactionKey);
} else {
workspace.collections.nodeReactions.insert({
nodeId: message.id,
collaboratorId: workspace.userId,
reaction,
rootId: message.rootId,
createdAt: new Date().toISOString(),
});
}
}, []);
const nodeReactionsAggregateQuery = useLiveQuery({
type: 'node.reactions.aggregate',
nodeId: message.id,
userId: workspace.userId,
});
const reactionCountsQuery = useLiveQuery(
(q) =>
q
.from({ nodeReactions: workspace.collections.nodeReactions })
.where(({ nodeReactions }) => eq(nodeReactions.nodeId, message.id))
.groupBy(({ nodeReactions }) => nodeReactions.reaction)
.select(({ nodeReactions }) => ({
reaction: nodeReactions.reaction,
count: count(nodeReactions.reaction),
})),
[message.id]
);
const reactionCounts = nodeReactionsAggregateQuery.data ?? [];
const currentUserReactionsQuery = useLiveQuery(
(q) =>
q
.from({ nodeReactions: workspace.collections.nodeReactions })
.where(({ nodeReactions }) => eq(nodeReactions.nodeId, message.id))
.where(({ nodeReactions }) =>
eq(nodeReactions.collaboratorId, workspace.userId)
),
[message.id]
);
const reactionCounts = reactionCountsQuery.data ?? [];
if (reactionCounts.length === 0) {
return null;
}
@@ -40,7 +74,10 @@ export const MessageReactionCounts = ({
return null;
}
const hasReacted = reaction.reacted;
const hasReacted = currentUserReactionsQuery.data?.some(
(userReaction) => userReaction.reaction === reaction.reaction
);
return (
<MessageReactionCountTooltip
key={reaction.reaction}
@@ -57,37 +94,7 @@ export const MessageReactionCounts = ({
hasReacted && 'font-bold'
)}
onClick={() => {
if (isPending) {
return;
}
if (hasReacted) {
mutate({
input: {
type: 'node.reaction.delete',
nodeId: message.id,
userId: workspace.userId,
rootId: message.rootId,
reaction: reaction.reaction,
},
onError(error) {
toast.error(error.message);
},
});
} else {
mutate({
input: {
type: 'node.reaction.create',
nodeId: message.id,
userId: workspace.userId,
rootId: message.rootId,
reaction: reaction.reaction,
},
onError(error) {
toast.error(error.message);
},
});
}
handleReactionClick(reaction.reaction);
}}
>
<EmojiElement id={reaction.reaction} className="size-5" />

View File

@@ -2,7 +2,11 @@ import { OperationType, TransactionWithMutations } from '@tanstack/react-db';
import { cloneDeep } from 'lodash-es';
import { mapNodeAttributes } from '@colanode/client/lib';
import { LocalNode, NodeCollaborator } from '@colanode/client/types';
import {
LocalNode,
NodeCollaborator,
NodeReaction,
} from '@colanode/client/types';
import { extractNodeCollaborators, Node } from '@colanode/core';
export const buildNodeCollaborators = (nodes: Node[]): NodeCollaborator[] => {
@@ -55,3 +59,38 @@ export const applyNodeTransaction = async (
}
}
};
export const applyNodeReactionTransaction = async (
userId: string,
transaction: TransactionWithMutations<NodeReaction, OperationType>
) => {
for (const mutation of transaction.mutations) {
if (mutation.type === 'insert') {
const reaction = mutation.modified;
await window.colanode.executeMutation({
type: 'node.reaction.create',
userId,
nodeId: reaction.nodeId,
collaboratorId: reaction.collaboratorId,
reaction: reaction.reaction,
});
} else if (mutation.type === 'delete') {
const reaction = mutation.modified;
await window.colanode.executeMutation({
type: 'node.reaction.delete',
userId,
nodeId: reaction.nodeId,
collaboratorId: reaction.collaboratorId,
reaction: reaction.reaction,
});
}
}
};
export const buildNodeReactionKey = (
nodeId: string,
collaboratorId: string,
reaction: string
) => {
return `${nodeId}.${collaboratorId}.${reaction}`;
};