mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 03:37:51 +01:00
Use tanstack/db for node reactions
This commit is contained in:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ export type NodeReaction = {
|
||||
export type NodeReactionCount = {
|
||||
reaction: string;
|
||||
count: number;
|
||||
reacted: boolean;
|
||||
};
|
||||
|
||||
export type NodeReference = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
84
packages/ui/src/collections/node-reactions.ts
Normal file
84
packages/ui/src/collections/node-reactions.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user