Implement mentions

This commit is contained in:
Hakan Shehu
2025-04-28 19:28:58 +02:00
parent 9e69f29858
commit 1d739879c3
66 changed files with 1931 additions and 718 deletions

View File

@@ -26,6 +26,7 @@
"@electron-forge/plugin-vite": "^7.8.0", "@electron-forge/plugin-vite": "^7.8.0",
"@electron-forge/publisher-github": "^7.8.0", "@electron-forge/publisher-github": "^7.8.0",
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@types/async-lock": "^1.4.2",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2", "@types/electron-squirrel-startup": "^1.0.2",
"@types/is-hotkey": "^0.1.10", "@types/is-hotkey": "^0.1.10",
@@ -85,6 +86,7 @@
"@tiptap/extension-underline": "^2.11.7", "@tiptap/extension-underline": "^2.11.7",
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/suggestion": "^2.11.7", "@tiptap/suggestion": "^2.11.7",
"async-lock": "^1.4.1",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"bufferutil": "^4.0.9", "bufferutil": "^4.0.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -8,14 +8,14 @@ export const createNodeInteractionsTable: Migration = {
.addColumn('collaborator_id', 'text', (col) => col.notNull()) .addColumn('collaborator_id', 'text', (col) => col.notNull())
.addColumn('root_id', 'text', (col) => col.notNull()) .addColumn('root_id', 'text', (col) => col.notNull())
.addColumn('revision', 'integer', (col) => col.notNull()) .addColumn('revision', 'integer', (col) => col.notNull())
.addPrimaryKeyConstraint('node_interactions_pkey', [
'node_id',
'collaborator_id',
])
.addColumn('first_seen_at', 'text') .addColumn('first_seen_at', 'text')
.addColumn('last_seen_at', 'text') .addColumn('last_seen_at', 'text')
.addColumn('first_opened_at', 'text') .addColumn('first_opened_at', 'text')
.addColumn('last_opened_at', 'text') .addColumn('last_opened_at', 'text')
.addPrimaryKeyConstraint('node_interactions_pkey', [
'node_id',
'collaborator_id',
])
.execute(); .execute();
}, },
down: async (db) => { down: async (db) => {

View File

@@ -0,0 +1,21 @@
import { Migration } from 'kysely';
export const createNodeReferencesTable: Migration = {
up: async (db) => {
await db.schema
.createTable('node_references')
.addColumn('node_id', 'text', (col) =>
col.notNull().references('nodes.id').onDelete('cascade')
)
.addColumn('reference_id', 'text', (col) => col.notNull())
.addColumn('inner_id', 'text', (col) => col.notNull())
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('created_by', 'text', (col) => col.notNull())
.addPrimaryKeyConstraint('node_references_pkey', ['node_id', 'type'])
.execute();
},
down: async (db) => {
await db.schema.dropTable('node_references').execute();
},
};

View File

@@ -0,0 +1,20 @@
import { Migration } from 'kysely';
export const createNodeCountersTable: Migration = {
up: async (db) => {
await db.schema
.createTable('node_counters')
.addColumn('node_id', 'text', (col) =>
col.notNull().references('nodes.id').onDelete('cascade')
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('count', 'integer', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.addPrimaryKeyConstraint('node_counters_pkey', ['node_id', 'type'])
.execute();
},
down: async (db) => {
await db.schema.dropTable('node_counters').execute();
},
};

View File

@@ -17,6 +17,8 @@ import { createMutationsTable } from './00014-create-mutations-table';
import { createTombstonesTable } from './00015-create-tombstones-table'; import { createTombstonesTable } from './00015-create-tombstones-table';
import { createCursorsTable } from './00016-create-cursors-table'; import { createCursorsTable } from './00016-create-cursors-table';
import { createMetadataTable } from './00017-create-metadata-table'; import { createMetadataTable } from './00017-create-metadata-table';
import { createNodeReferencesTable } from './00018-create-node-references-table';
import { createNodeCountersTable } from './00019-create-node-counters-table';
export const workspaceDatabaseMigrations: Record<string, Migration> = { export const workspaceDatabaseMigrations: Record<string, Migration> = {
'00001-create-users-table': createUsersTable, '00001-create-users-table': createUsersTable,
@@ -36,4 +38,6 @@ export const workspaceDatabaseMigrations: Record<string, Migration> = {
'00015-create-tombstones-table': createTombstonesTable, '00015-create-tombstones-table': createTombstonesTable,
'00016-create-cursors-table': createCursorsTable, '00016-create-cursors-table': createCursorsTable,
'00017-create-metadata-table': createMetadataTable, '00017-create-metadata-table': createMetadataTable,
'00018-create-node-references-table': createNodeReferencesTable,
'00019-create-node-counters-table': createNodeCountersTable,
}; };

View File

@@ -7,6 +7,7 @@ import {
import { ColumnType, Insertable, Selectable, Updateable } from 'kysely'; import { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
import { DownloadStatus, UploadStatus } from '@/shared/types/files'; import { DownloadStatus, UploadStatus } from '@/shared/types/files';
import { NodeCounterType } from '@/shared/types/nodes';
interface UserTable { interface UserTable {
id: ColumnType<string, string, never>; id: ColumnType<string, string, never>;
@@ -93,6 +94,31 @@ export type SelectNodeReaction = Selectable<NodeReactionTable>;
export type CreateNodeReaction = Insertable<NodeReactionTable>; export type CreateNodeReaction = Insertable<NodeReactionTable>;
export type UpdateNodeReaction = Updateable<NodeReactionTable>; export type UpdateNodeReaction = Updateable<NodeReactionTable>;
interface NodeReferenceTable {
node_id: ColumnType<string, string, never>;
reference_id: ColumnType<string, string, never>;
inner_id: ColumnType<string, string, never>;
type: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
created_by: ColumnType<string, string, never>;
}
export type SelectNodeReference = Selectable<NodeReferenceTable>;
export type CreateNodeReference = Insertable<NodeReferenceTable>;
export type UpdateNodeReference = Updateable<NodeReferenceTable>;
interface NodeCounterTable {
node_id: ColumnType<string, string, never>;
type: ColumnType<NodeCounterType, NodeCounterType, never>;
count: ColumnType<number, number, number>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectNodeCounter = Selectable<NodeCounterTable>;
export type CreateNodeCounter = Insertable<NodeCounterTable>;
export type UpdateNodeCounter = Updateable<NodeCounterTable>;
interface NodeTextTable { interface NodeTextTable {
id: ColumnType<string, string, never>; id: ColumnType<string, string, never>;
name: ColumnType<string | null, string | null, string | null>; name: ColumnType<string | null, string | null, string | null>;
@@ -244,6 +270,8 @@ export interface WorkspaceDatabaseSchema {
node_interactions: NodeInteractionTable; node_interactions: NodeInteractionTable;
node_updates: NodeUpdateTable; node_updates: NodeUpdateTable;
node_reactions: NodeReactionTable; node_reactions: NodeReactionTable;
node_references: NodeReferenceTable;
node_counters: NodeCounterTable;
node_texts: NodeTextTable; node_texts: NodeTextTable;
documents: DocumentTable; documents: DocumentTable;
document_states: DocumentStateTable; document_states: DocumentStateTable;

View File

@@ -22,6 +22,7 @@ import {
SelectDocument, SelectDocument,
SelectDocumentState, SelectDocumentState,
SelectDocumentUpdate, SelectDocumentUpdate,
SelectNodeReference,
} from '@/main/databases/workspace'; } from '@/main/databases/workspace';
import { import {
Account, Account,
@@ -36,7 +37,12 @@ import {
WorkspaceMetadata, WorkspaceMetadata,
WorkspaceMetadataKey, WorkspaceMetadataKey,
} from '@/shared/types/workspaces'; } from '@/shared/types/workspaces';
import { LocalNode, NodeInteraction, NodeReaction } from '@/shared/types/nodes'; import {
LocalNode,
NodeInteraction,
NodeReaction,
NodeReference,
} from '@/shared/types/nodes';
import { Emoji } from '@/shared/types/emojis'; import { Emoji } from '@/shared/types/emojis';
import { Icon } from '@/shared/types/icons'; import { Icon } from '@/shared/types/icons';
import { AppMetadata, AppMetadataKey } from '@/shared/types/apps'; import { AppMetadata, AppMetadataKey } from '@/shared/types/apps';
@@ -251,3 +257,12 @@ export const mapWorkspaceMetadata = (
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
}; };
export const mapNodeReference = (row: SelectNodeReference): NodeReference => {
return {
nodeId: row.node_id,
referenceId: row.reference_id,
innerId: row.inner_id,
type: row.type,
};
};

View File

@@ -109,6 +109,11 @@ export class NodeMarkOpenedMutationHandler
throw new Error('Failed to create node interaction'); throw new Error('Failed to create node interaction');
} }
await workspace.nodeCounters.checkCountersForUpdatedNodeInteraction(
createdInteraction,
existingInteraction
);
workspace.mutations.triggerSync(); workspace.mutations.triggerSync();
eventBus.publish({ eventBus.publish({

View File

@@ -110,6 +110,11 @@ export class NodeMarkSeenMutationHandler
throw new Error('Failed to create node interaction'); throw new Error('Failed to create node interaction');
} }
await workspace.nodeCounters.checkCountersForUpdatedNodeInteraction(
createdInteraction,
existingInteraction
);
workspace.mutations.triggerSync(); workspace.mutations.triggerSync();
eventBus.publish({ eventBus.publish({

View File

@@ -35,22 +35,22 @@ export class NotificationService {
return; return;
} }
let importantCount = 0; let hasUnread = false;
let hasUnseenChanges = false; let unreadCount = 0;
for (const account of accounts) { for (const account of accounts) {
const workspaces = account.getWorkspaces(); const workspaces = account.getWorkspaces();
for (const workspace of workspaces) { for (const workspace of workspaces) {
const radarData = workspace.radar.getData(); const radarData = workspace.radar.getData();
importantCount += radarData.importantCount; hasUnread = hasUnread || radarData.state.hasUnread;
hasUnseenChanges = hasUnseenChanges || radarData.hasUnseenChanges; unreadCount = unreadCount + radarData.state.unreadCount;
} }
} }
if (importantCount > 0) { if (unreadCount > 0) {
app.dock.setBadge(importantCount.toString()); app.dock.setBadge(unreadCount.toString());
} else if (hasUnseenChanges) { } else if (hasUnread) {
app.dock.setBadge('·'); app.dock.setBadge('·');
} else { } else {
app.dock.setBadge(''); app.dock.setBadge('');

View File

@@ -2,22 +2,46 @@ import { SyncCollaborationData, createDebugger } from '@colanode/core';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { SelectCollaboration } from '@/main/databases/workspace';
export class CollaborationService { export class CollaborationService {
private readonly debug = createDebugger('desktop:service:collaboration'); private readonly debug = createDebugger('desktop:service:collaboration');
private readonly workspace: WorkspaceService; private readonly workspace: WorkspaceService;
private readonly collaborations = new Map<string, SelectCollaboration>();
constructor(workspace: WorkspaceService) { constructor(workspace: WorkspaceService) {
this.workspace = workspace; this.workspace = workspace;
} }
public async init() {
const collaborations = await this.workspace.database
.selectFrom('collaborations')
.selectAll()
.execute();
for (const collaboration of collaborations) {
this.collaborations.set(collaboration.node_id, collaboration);
}
}
public getActiveCollaborations() {
return Array.from(this.collaborations.values()).filter(
(collaboration) => !collaboration.deleted_at
);
}
public getCollaboration(nodeId: string) {
return this.collaborations.get(nodeId);
}
public async syncServerCollaboration(collaboration: SyncCollaborationData) { public async syncServerCollaboration(collaboration: SyncCollaborationData) {
this.debug( this.debug(
`Applying server collaboration: ${collaboration.nodeId} for workspace ${this.workspace.id}` `Applying server collaboration: ${collaboration.nodeId} for workspace ${this.workspace.id}`
); );
await this.workspace.database const upsertedCollaboration = await this.workspace.database
.insertInto('collaborations') .insertInto('collaborations')
.returningAll()
.values({ .values({
node_id: collaboration.nodeId, node_id: collaboration.nodeId,
role: collaboration.role, role: collaboration.role,
@@ -37,9 +61,16 @@ export class CollaborationService {
}) })
.where('revision', '<', BigInt(collaboration.revision)) .where('revision', '<', BigInt(collaboration.revision))
) )
.execute(); .executeTakeFirst();
this.collaborations.set(
collaboration.nodeId,
upsertedCollaboration as SelectCollaboration
);
if (collaboration.deletedAt) { if (collaboration.deletedAt) {
this.collaborations.delete(collaboration.nodeId);
await this.workspace.database await this.workspace.database
.deleteFrom('nodes') .deleteFrom('nodes')
.where('root_id', '=', collaboration.nodeId) .where('root_id', '=', collaboration.nodeId)
@@ -51,7 +82,7 @@ export class CollaborationService {
.execute(); .execute();
await this.workspace.database await this.workspace.database
.deleteFrom('node_interactions') .deleteFrom('node_reactions')
.where('root_id', '=', collaboration.nodeId) .where('root_id', '=', collaboration.nodeId)
.execute(); .execute();

View File

@@ -2,6 +2,7 @@ import {
CanUpdateDocumentContext, CanUpdateDocumentContext,
createDebugger, createDebugger,
DocumentContent, DocumentContent,
extractBlocksMentions,
extractDocumentText, extractDocumentText,
generateId, generateId,
getNodeModel, getNodeModel,
@@ -15,12 +16,20 @@ import { encodeState, YDoc } from '@colanode/crdt';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { fetchNodeTree } from '@/main/lib/utils'; import { fetchNodeTree } from '@/main/lib/utils';
import { SelectDocument } from '@/main/databases/workspace'; import {
CreateNodeReference,
SelectDocument,
} from '@/main/databases/workspace';
import { import {
mapDocument, mapDocument,
mapDocumentState, mapDocumentState,
mapDocumentUpdate, mapDocumentUpdate,
mapNodeReference,
} from '@/main/lib/mappers'; } from '@/main/lib/mappers';
import {
applyMentionUpdates,
checkMentionChanges,
} from '@/shared/lib/mentions';
const UPDATE_RETRIES_LIMIT = 10; const UPDATE_RETRIES_LIMIT = 10;
@@ -115,10 +124,20 @@ export class DocumentService {
const updateId = generateId(IdType.Update); const updateId = generateId(IdType.Update);
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
const text = extractDocumentText(id, content); const text = extractDocumentText(id, content);
const mentions = extractBlocksMentions(id, content.blocks) ?? [];
const nodeReferencesToCreate: CreateNodeReference[] = mentions.map(
(mention) => ({
node_id: id,
reference_id: mention.target,
inner_id: mention.id,
type: 'mention',
created_at: updatedAt,
created_by: this.workspace.userId,
})
);
const { createdDocument, createdMutation } = await this.workspace.database const { createdDocument, createdMutation, createdNodeReferences } =
.transaction() await this.workspace.database.transaction().execute(async (trx) => {
.execute(async (trx) => {
const createdDocument = await trx const createdDocument = await trx
.insertInto('documents') .insertInto('documents')
.returningAll() .returningAll()
@@ -162,7 +181,16 @@ export class DocumentService {
}) })
.executeTakeFirst(); .executeTakeFirst();
return { createdDocument, createdMutation }; let createdNodeReferences: CreateNodeReference[] = [];
if (nodeReferencesToCreate.length > 0) {
createdNodeReferences = await trx
.insertInto('node_references')
.returningAll()
.values(nodeReferencesToCreate)
.execute();
}
return { createdDocument, createdMutation, createdNodeReferences };
}); });
if (createdDocument) { if (createdDocument) {
@@ -174,6 +202,15 @@ export class DocumentService {
}); });
} }
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
if (createdMutation) { if (createdMutation) {
this.workspace.mutations.triggerSync(); this.workspace.mutations.triggerSync();
} }
@@ -204,10 +241,11 @@ export class DocumentService {
ydoc.applyUpdate(update.data); ydoc.applyUpdate(update.data);
} }
const beforeContent = ydoc.getObject<DocumentContent>();
ydoc.applyUpdate(update); ydoc.applyUpdate(update);
const content = ydoc.getObject<DocumentContent>(); const afterContent = ydoc.getObject<DocumentContent>();
if (!model.documentSchema?.safeParse(content).success) { if (!model.documentSchema?.safeParse(afterContent).success) {
throw new Error('Invalid document state'); throw new Error('Invalid document state');
} }
@@ -215,80 +253,102 @@ export class DocumentService {
const serverRevision = BigInt(document.server_revision) + 1n; const serverRevision = BigInt(document.server_revision) + 1n;
const updateId = generateId(IdType.Update); const updateId = generateId(IdType.Update);
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
const text = extractDocumentText(document.id, content); const text = extractDocumentText(document.id, afterContent);
const { updatedDocument, createdUpdate, createdMutation } = const beforeMentions =
await this.workspace.database.transaction().execute(async (trx) => { extractBlocksMentions(document.id, beforeContent.blocks) ?? [];
const updatedDocument = await trx const afterMentions =
.updateTable('documents') extractBlocksMentions(document.id, afterContent.blocks) ?? [];
.returningAll() const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
.set({
content: JSON.stringify(content),
local_revision: localRevision,
server_revision: serverRevision,
updated_at: updatedAt,
updated_by: this.workspace.userId,
})
.where('id', '=', document.id)
.where('local_revision', '=', document.local_revision)
.executeTakeFirst();
if (!updatedDocument) { const {
throw new Error('Failed to update document'); updatedDocument,
} createdUpdate,
createdMutation,
createdNodeReferences,
deletedNodeReferences,
} = await this.workspace.database.transaction().execute(async (trx) => {
const updatedDocument = await trx
.updateTable('documents')
.returningAll()
.set({
content: JSON.stringify(afterContent),
local_revision: localRevision,
server_revision: serverRevision,
updated_at: updatedAt,
updated_by: this.workspace.userId,
})
.where('id', '=', document.id)
.where('local_revision', '=', document.local_revision)
.executeTakeFirst();
const createdUpdate = await trx if (!updatedDocument) {
.insertInto('document_updates') throw new Error('Failed to update document');
.returningAll() }
.values({
id: updateId,
document_id: document.id,
data: update,
created_at: updatedAt,
})
.executeTakeFirst();
if (!createdUpdate) { const createdUpdate = await trx
throw new Error('Failed to create update'); .insertInto('document_updates')
} .returningAll()
.values({
id: updateId,
document_id: document.id,
data: update,
created_at: updatedAt,
})
.executeTakeFirst();
const mutationData: UpdateDocumentMutationData = { if (!createdUpdate) {
documentId: document.id, throw new Error('Failed to create update');
updateId: updateId, }
data: encodeState(update),
createdAt: updatedAt,
};
const createdMutation = await trx const mutationData: UpdateDocumentMutationData = {
.insertInto('mutations') documentId: document.id,
.returningAll() updateId: updateId,
.values({ data: encodeState(update),
id: generateId(IdType.Mutation), createdAt: updatedAt,
type: 'update_document', };
data: JSON.stringify(mutationData),
created_at: updatedAt,
retries: 0,
})
.executeTakeFirst();
if (!createdMutation) { const createdMutation = await trx
throw new Error('Failed to create mutation'); .insertInto('mutations')
} .returningAll()
.values({
id: generateId(IdType.Mutation),
type: 'update_document',
data: JSON.stringify(mutationData),
created_at: updatedAt,
retries: 0,
})
.executeTakeFirst();
await trx if (!createdMutation) {
.updateTable('document_texts') throw new Error('Failed to create mutation');
.set({ }
text: text,
})
.where('id', '=', document.id)
.executeTakeFirst();
return { await trx
updatedDocument, .updateTable('document_texts')
createdMutation, .set({
createdUpdate, text: text,
}; })
}); .where('id', '=', document.id)
.executeTakeFirst();
const { createdNodeReferences, deletedNodeReferences } =
await applyMentionUpdates(
trx,
document.id,
this.workspace.userId,
updatedAt,
mentionChanges
);
return {
updatedDocument,
createdMutation,
createdUpdate,
createdNodeReferences,
deletedNodeReferences,
};
});
if (updatedDocument) { if (updatedDocument) {
eventBus.publish({ eventBus.publish({
@@ -308,6 +368,24 @@ export class DocumentService {
}); });
} }
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
for (const deletedNodeReference of deletedNodeReferences) {
eventBus.publish({
type: 'node_reference_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(deletedNodeReference),
});
}
if (createdMutation) { if (createdMutation) {
this.workspace.mutations.triggerSync(); this.workspace.mutations.triggerSync();
} }
@@ -415,44 +493,68 @@ export class DocumentService {
const localRevision = BigInt(document.local_revision) + BigInt(1); const localRevision = BigInt(document.local_revision) + BigInt(1);
const text = extractDocumentText(document.id, content); const text = extractDocumentText(document.id, content);
const { updatedDocument, deletedUpdate } = await this.workspace.database const beforeContent = JSON.parse(document.content) as DocumentContent;
.transaction() const beforeMentions =
.execute(async (trx) => { extractBlocksMentions(document.id, beforeContent.blocks) ?? [];
const updatedDocument = await trx const afterMentions =
.updateTable('documents') extractBlocksMentions(document.id, content.blocks) ?? [];
.returningAll() const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
.set({
content: JSON.stringify(content),
local_revision: localRevision,
})
.where('id', '=', data.documentId)
.where('local_revision', '=', node.local_revision)
.executeTakeFirst();
if (!updatedDocument) { const {
throw new Error('Failed to update document'); updatedDocument,
} deletedUpdate,
createdNodeReferences,
deletedNodeReferences,
} = await this.workspace.database.transaction().execute(async (trx) => {
const updatedDocument = await trx
.updateTable('documents')
.returningAll()
.set({
content: JSON.stringify(content),
local_revision: localRevision,
})
.where('id', '=', data.documentId)
.where('local_revision', '=', node.local_revision)
.executeTakeFirst();
const deletedUpdate = await trx if (!updatedDocument) {
.deleteFrom('document_updates') throw new Error('Failed to update document');
.returningAll() }
.where('id', '=', updateToDelete.id)
.executeTakeFirst();
if (!deletedUpdate) { const deletedUpdate = await trx
throw new Error('Failed to delete update'); .deleteFrom('document_updates')
} .returningAll()
.where('id', '=', updateToDelete.id)
.executeTakeFirst();
await trx if (!deletedUpdate) {
.updateTable('document_texts') throw new Error('Failed to delete update');
.set({ }
text: text,
})
.where('id', '=', document.id)
.executeTakeFirst();
return { updatedDocument, deletedUpdate }; await trx
}); .updateTable('document_texts')
.set({
text: text,
})
.where('id', '=', document.id)
.executeTakeFirst();
const { createdNodeReferences, deletedNodeReferences } =
await applyMentionUpdates(
trx,
document.id,
this.workspace.userId,
document.updated_at ?? document.created_at,
mentionChanges
);
return {
updatedDocument,
deletedUpdate,
createdNodeReferences,
deletedNodeReferences,
};
});
if (updatedDocument) { if (updatedDocument) {
eventBus.publish({ eventBus.publish({
@@ -473,6 +575,24 @@ export class DocumentService {
}); });
} }
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
for (const deletedNodeReference of deletedNodeReferences) {
eventBus.publish({
type: 'node_reference_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(deletedNodeReference),
});
}
return true; return true;
} }
@@ -537,66 +657,95 @@ export class DocumentService {
const updatesToDelete = [data.id, ...mergedUpdateIds]; const updatesToDelete = [data.id, ...mergedUpdateIds];
const text = extractDocumentText(data.documentId, content); const text = extractDocumentText(data.documentId, content);
const beforeContent = JSON.parse(
document?.content ?? '{}'
) as DocumentContent;
const beforeMentions =
extractBlocksMentions(data.documentId, beforeContent.blocks) ?? [];
const afterMentions =
extractBlocksMentions(data.documentId, content.blocks) ?? [];
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
if (document) { if (document) {
const { updatedDocument, upsertedState, deletedUpdates } = const {
await this.workspace.database.transaction().execute(async (trx) => { updatedDocument,
const updatedDocument = await trx upsertedState,
.updateTable('documents') deletedUpdates,
.returningAll() createdNodeReferences,
.set({ deletedNodeReferences,
content: JSON.stringify(content), } = await this.workspace.database.transaction().execute(async (trx) => {
server_revision: serverRevision, const updatedDocument = await trx
local_revision: localRevision, .updateTable('documents')
updated_at: data.createdAt, .returningAll()
updated_by: data.createdBy, .set({
}) content: JSON.stringify(content),
.where('id', '=', data.documentId) server_revision: serverRevision,
.where('local_revision', '=', document.local_revision) local_revision: localRevision,
.executeTakeFirst(); updated_at: data.createdAt,
updated_by: data.createdBy,
})
.where('id', '=', data.documentId)
.where('local_revision', '=', document.local_revision)
.executeTakeFirst();
if (!updatedDocument) { if (!updatedDocument) {
throw new Error('Failed to update document'); throw new Error('Failed to update document');
} }
const upsertedState = await trx const upsertedState = await trx
.insertInto('document_states') .insertInto('document_states')
.returningAll() .returningAll()
.values({ .values({
id: data.documentId, id: data.documentId,
state: serverState, state: serverState,
revision: serverRevision, revision: serverRevision,
}) })
.onConflict((cb) => .onConflict((cb) =>
cb cb
.column('id') .column('id')
.doUpdateSet({ .doUpdateSet({
state: serverState, state: serverState,
revision: serverRevision, revision: serverRevision,
}) })
.where('revision', '=', BigInt(documentState?.revision ?? 0)) .where('revision', '=', BigInt(documentState?.revision ?? 0))
) )
.executeTakeFirst(); .executeTakeFirst();
if (!upsertedState) { if (!upsertedState) {
throw new Error('Failed to update document state'); throw new Error('Failed to update document state');
} }
const deletedUpdates = await trx const deletedUpdates = await trx
.deleteFrom('document_updates') .deleteFrom('document_updates')
.returningAll() .returningAll()
.where('id', 'in', updatesToDelete) .where('id', 'in', updatesToDelete)
.execute(); .execute();
await trx await trx
.updateTable('document_texts') .updateTable('document_texts')
.set({ .set({
text: text, text: text,
}) })
.where('id', '=', data.documentId) .where('id', '=', data.documentId)
.executeTakeFirst(); .executeTakeFirst();
return { updatedDocument, upsertedState, deletedUpdates }; const { createdNodeReferences, deletedNodeReferences } =
}); await applyMentionUpdates(
trx,
data.documentId,
this.workspace.userId,
data.createdAt,
mentionChanges
);
return {
updatedDocument,
upsertedState,
deletedUpdates,
createdNodeReferences,
deletedNodeReferences,
};
});
if (!updatedDocument) { if (!updatedDocument) {
return false; return false;
@@ -629,10 +778,27 @@ export class DocumentService {
}); });
} }
} }
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
for (const deletedNodeReference of deletedNodeReferences) {
eventBus.publish({
type: 'node_reference_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(deletedNodeReference),
});
}
} else { } else {
const { createdDocument } = await this.workspace.database const { createdDocument, createdNodeReferences } =
.transaction() await this.workspace.database.transaction().execute(async (trx) => {
.execute(async (trx) => {
const createdDocument = await trx const createdDocument = await trx
.insertInto('documents') .insertInto('documents')
.returningAll() .returningAll()
@@ -674,7 +840,15 @@ export class DocumentService {
}) })
.executeTakeFirst(); .executeTakeFirst();
return { createdDocument }; const { createdNodeReferences } = await applyMentionUpdates(
trx,
data.documentId,
this.workspace.userId,
data.createdAt,
mentionChanges
);
return { createdDocument, createdNodeReferences };
}); });
if (!createdDocument) { if (!createdDocument) {
@@ -687,6 +861,15 @@ export class DocumentService {
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
document: mapDocument(createdDocument), document: mapDocument(createdDocument),
}); });
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
} }
return true; return true;

View File

@@ -0,0 +1,269 @@
import AsyncLock from 'async-lock';
import { getIdType, IdType, MentionConstants } from '@colanode/core';
import { sql } from 'kysely';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import {
SelectNode,
SelectNodeCounter,
SelectNodeInteraction,
SelectNodeReference,
} from '@/main/databases/workspace';
import { eventBus } from '@/shared/lib/event-bus';
import { NodeCounterType } from '@/shared/types/nodes';
export class NodeCountersService {
private readonly workspace: WorkspaceService;
private readonly lock = new AsyncLock();
constructor(workspace: WorkspaceService) {
this.workspace = workspace;
}
public async checkCountersForCreatedNode(
node: SelectNode,
references: SelectNodeReference[]
) {
// Only messages have counters for now
if (
node.type !== 'message' ||
!node.parent_id ||
node.created_by === this.workspace.userId
) {
return;
}
const isMentioned = this.isUserMentioned(references);
const counters = await this.lock.acquire(
this.getLockKey(node.id),
async () => {
if (!node.parent_id) {
return;
}
const nodeInteraction = await this.workspace.database
.selectFrom('node_interactions')
.selectAll()
.where('node_id', '=', node.id)
.where('collaborator_id', '=', this.workspace.userId)
.executeTakeFirst();
if (nodeInteraction?.last_seen_at) {
return;
}
const collaboration = this.workspace.collaborations.getCollaboration(
node.root_id
);
if (!collaboration || collaboration.created_at > node.created_at) {
return;
}
const parentIdType = getIdType(node.parent_id);
const types: NodeCounterType[] = [];
if (isMentioned) {
types.push('unread_mentions');
} else if (parentIdType === IdType.Channel) {
types.push('unread_messages');
} else if (parentIdType === IdType.Chat) {
types.push('unread_important_messages');
}
if (types.length > 0) {
return await this.increaseCounters(node.parent_id, types);
}
}
);
if (counters) {
for (const counter of counters) {
eventBus.publish({
type: 'node_counter_updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
counter: {
nodeId: counter.node_id,
type: counter.type,
count: counter.count,
},
});
}
}
}
public async checkCountersForDeletedNode(node: SelectNode) {
if (node.type !== 'message' || !node.parent_id) {
return;
}
const counters = await this.lock.acquire(
this.getLockKey(node.id),
async () => {
if (!node.parent_id) {
return;
}
return await this.deleteCounters(node.parent_id);
}
);
if (counters) {
for (const counter of counters) {
eventBus.publish({
type: 'node_counter_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
counter: {
nodeId: counter.node_id,
type: counter.type,
count: counter.count,
},
});
}
}
}
public async checkCountersForUpdatedNodeInteraction(
nodeInteraction: SelectNodeInteraction,
previousNodeInteraction?: SelectNodeInteraction
) {
if (nodeInteraction.collaborator_id !== this.workspace.userId) {
return;
}
// If the node interaction has not been seen, we don't need to check the counters
if (!nodeInteraction.last_seen_at) {
return;
}
// If the previous node interaction has already been seen, we don't need to check the counters
if (previousNodeInteraction?.last_seen_at) {
return;
}
const counters = await this.lock.acquire(
this.getLockKey(nodeInteraction.node_id),
async () => {
const node = await this.workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', nodeInteraction.node_id)
.executeTakeFirst();
if (
!node ||
!node.parent_id ||
node.created_by === this.workspace.userId
) {
return;
}
const collaboration = this.workspace.collaborations.getCollaboration(
node.root_id
);
if (!collaboration || collaboration.created_at > node.created_at) {
return;
}
const nodeReferences = await this.workspace.database
.selectFrom('node_references')
.selectAll()
.where('node_id', '=', nodeInteraction.node_id)
.execute();
const isMentioned = this.isUserMentioned(nodeReferences);
const parentIdType = getIdType(node.parent_id);
const types: NodeCounterType[] = [];
if (isMentioned) {
types.push('unread_mentions');
} else if (parentIdType === IdType.Channel) {
types.push('unread_messages');
} else if (parentIdType === IdType.Chat) {
types.push('unread_important_messages');
}
if (types.length > 0) {
return await this.decreaseCounters(node.parent_id, types);
}
}
);
if (counters) {
for (const counter of counters) {
eventBus.publish({
type: 'node_counter_updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
counter: {
nodeId: counter.node_id,
type: counter.type,
count: counter.count,
},
});
}
}
}
private async increaseCounters(
nodeId: string,
types: NodeCounterType[]
): Promise<SelectNodeCounter[] | undefined> {
return await this.workspace.database
.insertInto('node_counters')
.returningAll()
.values(
types.map((type) => ({
node_id: nodeId,
type,
created_at: new Date().toISOString(),
count: 1,
}))
)
.onConflict((oc) =>
oc.columns(['node_id', 'type']).doUpdateSet({
count: sql`node_counters.count + 1`,
updated_at: new Date().toISOString(),
})
)
.execute();
}
private async decreaseCounters(
nodeId: string,
types: NodeCounterType[]
): Promise<SelectNodeCounter[] | undefined> {
return await this.workspace.database
.updateTable('node_counters')
.returningAll()
.set({
count: sql`node_counters.count - 1`,
updated_at: new Date().toISOString(),
})
.where('node_id', '=', nodeId)
.where('type', 'in', types)
.execute();
}
private async deleteCounters(nodeId: string) {
return await this.workspace.database
.deleteFrom('node_counters')
.returningAll()
.where('node_id', '=', nodeId)
.execute();
}
private isUserMentioned(references: SelectNodeReference[]) {
return references.some(
(reference) =>
reference.reference_id === this.workspace.userId ||
reference.reference_id === MentionConstants.Everyone
);
}
private getLockKey(nodeId: string) {
return `node_counters_${nodeId}`;
}
}

View File

@@ -32,7 +32,7 @@ export class NodeInteractionService {
} }
} }
const createdNodeInteraction = await this.workspace.database const upsertedNodeInteraction = await this.workspace.database
.insertInto('node_interactions') .insertInto('node_interactions')
.returningAll() .returningAll()
.values({ .values({
@@ -56,15 +56,22 @@ export class NodeInteractionService {
) )
.executeTakeFirst(); .executeTakeFirst();
if (!createdNodeInteraction) { if (!upsertedNodeInteraction) {
return; return;
} }
if (upsertedNodeInteraction.collaborator_id === this.workspace.userId) {
await this.workspace.nodeCounters.checkCountersForUpdatedNodeInteraction(
upsertedNodeInteraction,
existingNodeInteraction
);
}
eventBus.publish({ eventBus.publish({
type: 'node_interaction_updated', type: 'node_interaction_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
nodeInteraction: mapNodeInteraction(createdNodeInteraction), nodeInteraction: mapNodeInteraction(upsertedNodeInteraction),
}); });
this.debug( this.debug(

View File

@@ -16,10 +16,18 @@ import {
import { decodeState, encodeState, YDoc } from '@colanode/crdt'; import { decodeState, encodeState, YDoc } from '@colanode/crdt';
import { fetchNodeTree } from '@/main/lib/utils'; import { fetchNodeTree } from '@/main/lib/utils';
import { mapNode } from '@/main/lib/mappers'; import { mapNode, mapNodeReference } from '@/main/lib/mappers';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { SelectNode } from '@/main/databases/workspace'; import {
CreateNodeReference,
SelectNode,
SelectNodeReference,
} from '@/main/databases/workspace';
import {
applyMentionUpdates,
checkMentionChanges,
} from '@/shared/lib/mentions';
const UPDATE_RETRIES_LIMIT = 20; const UPDATE_RETRIES_LIMIT = 20;
@@ -77,11 +85,21 @@ export class NodeService {
const updateId = generateId(IdType.Update); const updateId = generateId(IdType.Update);
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const rootId = tree[0]?.id ?? input.id; const rootId = tree[0]?.id ?? input.id;
const nodeText = model.extractNodeText(input.id, input.attributes); const nodeText = model.extractText(input.id, input.attributes);
const mentions = model.extractMentions(input.id, input.attributes);
const nodeReferencesToCreate: CreateNodeReference[] = mentions.map(
(mention) => ({
node_id: input.id,
reference_id: mention.target,
inner_id: mention.id,
type: 'mention',
created_at: createdAt,
created_by: this.workspace.userId,
})
);
const { createdNode, createdMutation } = await this.workspace.database const { createdNode, createdMutation, createdNodeReferences } =
.transaction() await this.workspace.database.transaction().execute(async (trx) => {
.execute(async (trx) => {
const createdNode = await trx const createdNode = await trx
.insertInto('nodes') .insertInto('nodes')
.returningAll() .returningAll()
@@ -149,9 +167,19 @@ export class NodeService {
.execute(); .execute();
} }
let createdNodeReferences: SelectNodeReference[] = [];
if (nodeReferencesToCreate.length > 0) {
createdNodeReferences = await trx
.insertInto('node_references')
.values(nodeReferencesToCreate)
.returningAll()
.execute();
}
return { return {
createdNode, createdNode,
createdMutation, createdMutation,
createdNodeReferences,
}; };
}); });
@@ -159,6 +187,10 @@ export class NodeService {
throw new Error('Failed to create node'); throw new Error('Failed to create node');
} }
if (!createdMutation) {
throw new Error('Failed to create mutation');
}
this.debug(`Created node ${createdNode.id} with type ${createdNode.type}`); this.debug(`Created node ${createdNode.id} with type ${createdNode.type}`);
eventBus.publish({ eventBus.publish({
@@ -168,8 +200,13 @@ export class NodeService {
node: mapNode(createdNode), node: mapNode(createdNode),
}); });
if (!createdMutation) { for (const createdNodeReference of createdNodeReferences) {
throw new Error('Failed to create mutation'); eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
} }
this.workspace.mutations.triggerSync(); this.workspace.mutations.triggerSync();
@@ -248,82 +285,100 @@ export class NodeService {
const attributes = ydoc.getObject<NodeAttributes>(); const attributes = ydoc.getObject<NodeAttributes>();
const localRevision = BigInt(node.localRevision) + BigInt(1); const localRevision = BigInt(node.localRevision) + BigInt(1);
const nodeText = model.extractNodeText(nodeId, node.attributes); const nodeText = model.extractText(nodeId, attributes);
const { updatedNode, createdMutation } = await this.workspace.database const beforeMentions = model.extractMentions(nodeId, node.attributes);
.transaction() const afterMentions = model.extractMentions(nodeId, attributes);
.execute(async (trx) => { const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
const updatedNode = await trx
.updateTable('nodes')
.returningAll()
.set({
attributes: JSON.stringify(attributes),
updated_at: updatedAt,
updated_by: this.workspace.userId,
local_revision: localRevision,
})
.where('id', '=', nodeId)
.where('local_revision', '=', node.localRevision)
.executeTakeFirst();
if (!updatedNode) { const {
throw new Error('Failed to update node'); updatedNode,
} createdMutation,
createdNodeReferences,
deletedNodeReferences,
} = await this.workspace.database.transaction().execute(async (trx) => {
const updatedNode = await trx
.updateTable('nodes')
.returningAll()
.set({
attributes: JSON.stringify(attributes),
updated_at: updatedAt,
updated_by: this.workspace.userId,
local_revision: localRevision,
})
.where('id', '=', nodeId)
.where('local_revision', '=', node.localRevision)
.executeTakeFirst();
const createdUpdate = await trx if (!updatedNode) {
.insertInto('node_updates') throw new Error('Failed to update node');
.returningAll() }
const createdUpdate = await trx
.insertInto('node_updates')
.returningAll()
.values({
id: updateId,
node_id: nodeId,
data: update,
created_at: updatedAt,
})
.executeTakeFirst();
if (!createdUpdate) {
throw new Error('Failed to create update');
}
const mutationData: UpdateNodeMutationData = {
nodeId: nodeId,
updateId: updateId,
data: encodeState(update),
createdAt: updatedAt,
};
const createdMutation = await trx
.insertInto('mutations')
.returningAll()
.values({
id: generateId(IdType.Mutation),
type: 'update_node',
data: JSON.stringify(mutationData),
created_at: updatedAt,
retries: 0,
})
.executeTakeFirst();
if (!createdMutation) {
throw new Error('Failed to create mutation');
}
if (nodeText) {
await trx
.insertInto('node_texts')
.values({ .values({
id: updateId, id: nodeId,
node_id: nodeId, name: nodeText.name,
data: update, attributes: nodeText.attributes,
created_at: updatedAt,
}) })
.executeTakeFirst(); .execute();
}
if (!createdUpdate) { const { createdNodeReferences, deletedNodeReferences } =
throw new Error('Failed to create update'); await applyMentionUpdates(
} trx,
nodeId,
this.workspace.userId,
updatedAt,
mentionChanges
);
const mutationData: UpdateNodeMutationData = { return {
nodeId: nodeId, updatedNode,
updateId: updateId, createdMutation,
data: encodeState(update), createdNodeReferences,
createdAt: updatedAt, deletedNodeReferences,
}; };
});
const createdMutation = await trx
.insertInto('mutations')
.returningAll()
.values({
id: generateId(IdType.Mutation),
type: 'update_node',
data: JSON.stringify(mutationData),
created_at: updatedAt,
retries: 0,
})
.executeTakeFirst();
if (!createdMutation) {
throw new Error('Failed to create mutation');
}
if (nodeText) {
await trx
.insertInto('node_texts')
.values({
id: nodeId,
name: nodeText.name,
attributes: nodeText.attributes,
})
.execute();
}
return {
updatedNode,
createdMutation,
};
});
if (updatedNode) { if (updatedNode) {
this.debug( this.debug(
@@ -344,6 +399,24 @@ export class NodeService {
this.workspace.mutations.triggerSync(); this.workspace.mutations.triggerSync();
} }
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
for (const deletedNodeReference of deletedNodeReferences) {
eventBus.publish({
type: 'node_reference_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(deletedNodeReference),
});
}
if (updatedNode) { if (updatedNode) {
return 'success'; return 'success';
} }
@@ -472,9 +545,18 @@ export class NodeService {
const attributes = ydoc.getObject<NodeAttributes>(); const attributes = ydoc.getObject<NodeAttributes>();
const model = getNodeModel(attributes.type); const model = getNodeModel(attributes.type);
const nodeText = model.extractNodeText(update.id, attributes); const nodeText = model.extractText(update.nodeId, attributes);
const mentions = model.extractMentions(update.nodeId, attributes);
const nodeReferencesToCreate = mentions.map((mention) => ({
node_id: update.nodeId,
reference_id: mention.target,
inner_id: mention.id,
type: 'mention',
created_at: update.createdAt,
created_by: update.createdBy,
}));
const { createdNode } = await this.workspace.database const { createdNode, createdNodeReferences } = await this.workspace.database
.transaction() .transaction()
.execute(async (trx) => { .execute(async (trx) => {
const createdNode = await trx const createdNode = await trx
@@ -516,7 +598,16 @@ export class NodeService {
.execute(); .execute();
} }
return { createdNode }; let createdNodeReferences: SelectNodeReference[] = [];
if (nodeReferencesToCreate.length > 0) {
createdNodeReferences = await trx
.insertInto('node_references')
.values(nodeReferencesToCreate)
.returningAll()
.execute();
}
return { createdNode, createdNodeReferences };
}); });
if (!createdNode) { if (!createdNode) {
@@ -533,6 +624,20 @@ export class NodeService {
node: mapNode(createdNode), node: mapNode(createdNode),
}); });
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
await this.workspace.nodeCounters.checkCountersForCreatedNode(
createdNode,
createdNodeReferences
);
return true; return true;
} }
@@ -571,14 +676,21 @@ export class NodeService {
const localRevision = BigInt(existingNode.local_revision) + BigInt(1); const localRevision = BigInt(existingNode.local_revision) + BigInt(1);
const model = getNodeModel(attributes.type); const model = getNodeModel(attributes.type);
const nodeText = model.extractNodeText(existingNode.id, attributes); const nodeText = model.extractText(existingNode.id, attributes);
const beforeAttributes = JSON.parse(existingNode.attributes);
const beforeMentions = model.extractMentions(
existingNode.id,
beforeAttributes
);
const afterMentions = model.extractMentions(existingNode.id, attributes);
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
const mergedUpdateIds = update.mergedUpdates?.map((u) => u.id) ?? []; const mergedUpdateIds = update.mergedUpdates?.map((u) => u.id) ?? [];
const updatesToDelete = [update.id, ...mergedUpdateIds]; const updatesToDelete = [update.id, ...mergedUpdateIds];
const { updatedNode } = await this.workspace.database const { updatedNode, createdNodeReferences, deletedNodeReferences } =
.transaction() await this.workspace.database.transaction().execute(async (trx) => {
.execute(async (trx) => {
const updatedNode = await trx const updatedNode = await trx
.updateTable('nodes') .updateTable('nodes')
.returningAll() .returningAll()
@@ -637,7 +749,16 @@ export class NodeService {
.execute(); .execute();
} }
return { updatedNode }; const { createdNodeReferences, deletedNodeReferences } =
await applyMentionUpdates(
trx,
existingNode.id,
update.createdBy,
update.createdAt,
mentionChanges
);
return { updatedNode, createdNodeReferences, deletedNodeReferences };
}); });
if (!updatedNode) { if (!updatedNode) {
@@ -654,6 +775,24 @@ export class NodeService {
node: mapNode(updatedNode), node: mapNode(updatedNode),
}); });
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
for (const deletedNodeReference of deletedNodeReferences) {
eventBus.publish({
type: 'node_reference_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(deletedNodeReference),
});
}
return true; return true;
} }
@@ -691,6 +830,11 @@ export class NodeService {
.where('node_id', '=', tombstone.id) .where('node_id', '=', tombstone.id)
.execute(); .execute();
await trx
.deleteFrom('node_references')
.where('node_id', '=', tombstone.id)
.execute();
await trx await trx
.deleteFrom('tombstones') .deleteFrom('tombstones')
.where('id', '=', tombstone.id) .where('id', '=', tombstone.id)
@@ -714,14 +858,18 @@ export class NodeService {
return { deletedNode }; return { deletedNode };
}); });
if (deletedNode) { if (!deletedNode) {
eventBus.publish({ return;
type: 'node_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
node: mapNode(deletedNode),
});
} }
await this.workspace.nodeCounters.checkCountersForDeletedNode(deletedNode);
eventBus.publish({
type: 'node_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
node: mapNode(deletedNode),
});
} }
public async revertNodeCreate(mutation: CreateNodeMutationData) { public async revertNodeCreate(mutation: CreateNodeMutationData) {
@@ -758,6 +906,11 @@ export class NodeService {
.where('id', '=', mutation.nodeId) .where('id', '=', mutation.nodeId)
.execute(); .execute();
await tx
.deleteFrom('node_references')
.where('node_id', '=', mutation.nodeId)
.execute();
await tx await tx
.deleteFrom('documents') .deleteFrom('documents')
.where('id', '=', mutation.nodeId) .where('id', '=', mutation.nodeId)
@@ -822,6 +975,11 @@ export class NodeService {
.where('id', '=', mutation.nodeId) .where('id', '=', mutation.nodeId)
.execute(); .execute();
await this.workspace.database
.deleteFrom('node_references')
.where('node_id', '=', mutation.nodeId)
.execute();
await this.workspace.database await this.workspace.database
.deleteFrom('documents') .deleteFrom('documents')
.where('id', '=', mutation.nodeId) .where('id', '=', mutation.nodeId)
@@ -871,12 +1029,16 @@ export class NodeService {
const attributes = ydoc.getObject<NodeAttributes>(); const attributes = ydoc.getObject<NodeAttributes>();
const model = getNodeModel(attributes.type); const model = getNodeModel(attributes.type);
const nodeText = model.extractNodeText(node.id, attributes); const nodeText = model.extractText(node.id, attributes);
const localRevision = BigInt(node.local_revision) + BigInt(1); const localRevision = BigInt(node.local_revision) + BigInt(1);
const updatedNode = await this.workspace.database const beforeAttributes = JSON.parse(node.attributes);
.transaction() const beforeMentions = model.extractMentions(node.id, beforeAttributes);
.execute(async (trx) => { const afterMentions = model.extractMentions(node.id, attributes);
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
const { updatedNode, createdNodeReferences, deletedNodeReferences } =
await this.workspace.database.transaction().execute(async (trx) => {
const updatedNode = await trx const updatedNode = await trx
.updateTable('nodes') .updateTable('nodes')
.returningAll() .returningAll()
@@ -889,7 +1051,7 @@ export class NodeService {
.executeTakeFirst(); .executeTakeFirst();
if (!updatedNode) { if (!updatedNode) {
return undefined; throw new Error('Failed to update node');
} }
await trx await trx
@@ -907,6 +1069,17 @@ export class NodeService {
}) })
.execute(); .execute();
} }
const { createdNodeReferences, deletedNodeReferences } =
await applyMentionUpdates(
trx,
node.id,
this.workspace.userId,
mutation.createdAt,
mentionChanges
);
return { updatedNode, createdNodeReferences, deletedNodeReferences };
}); });
if (updatedNode) { if (updatedNode) {
@@ -917,6 +1090,24 @@ export class NodeService {
node: mapNode(updatedNode), node: mapNode(updatedNode),
}); });
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node_reference_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(createdNodeReference),
});
}
for (const deletedNodeReference of deletedNodeReferences) {
eventBus.publish({
type: 'node_reference_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
nodeReference: mapNodeReference(deletedNodeReference),
});
}
return true; return true;
} }

View File

@@ -1,36 +1,21 @@
import { getIdType, IdType } from '@colanode/core';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { SelectCollaboration } from '@/main/databases/workspace'; import { WorkspaceRadarData } from '@/shared/types/radars';
import { import {
ChannelReadState,
ChatReadState,
WorkspaceRadarData,
} from '@/shared/types/radars';
import {
CollaborationCreatedEvent,
CollaborationDeletedEvent,
NodeCreatedEvent,
NodeDeletedEvent,
NodeInteractionUpdatedEvent,
Event, Event,
NodeCounterUpdatedEvent,
NodeCounterDeletedEvent,
} from '@/shared/types/events'; } from '@/shared/types/events';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { NodeCounterType } from '@/shared/types/nodes';
interface UndreadMessage {
messageId: string;
parentId: string;
parentIdType: IdType;
}
export class RadarService { export class RadarService {
private readonly workspace: WorkspaceService; private readonly workspace: WorkspaceService;
private readonly unreadMessages: Map<string, UndreadMessage> = new Map(); private readonly counters: Map<string, Map<NodeCounterType, number>>;
private readonly collaborations: Map<string, SelectCollaboration> = new Map();
private readonly eventSubscriptionId: string; private readonly eventSubscriptionId: string;
constructor(workspace: WorkspaceService) { constructor(workspace: WorkspaceService) {
this.workspace = workspace; this.workspace = workspace;
this.counters = new Map();
this.eventSubscriptionId = eventBus.subscribe(this.handleEvent.bind(this)); this.eventSubscriptionId = eventBus.subscribe(this.handleEvent.bind(this));
} }
@@ -39,93 +24,62 @@ export class RadarService {
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
userId: this.workspace.userId, userId: this.workspace.userId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
importantCount: 0, state: {
hasUnseenChanges: false, hasUnread: false,
unreadCount: 0,
},
nodeStates: {}, nodeStates: {},
}; };
for (const unreadMessage of this.unreadMessages.values()) { for (const [nodeId, counters] of this.counters.entries()) {
if (unreadMessage.parentIdType === IdType.Channel) { let hasUnread = false;
let nodeState = data.nodeStates[ let unreadCount = 0;
unreadMessage.parentId
] as ChannelReadState; for (const [type, count] of counters.entries()) {
if (!nodeState) { if (count === 0) {
nodeState = { continue;
type: 'channel',
channelId: unreadMessage.parentId,
unseenMessagesCount: 0,
mentionsCount: 0,
};
data.nodeStates[unreadMessage.parentId] = nodeState;
data.hasUnseenChanges = true;
} }
nodeState.unseenMessagesCount++; if (type === 'unread_messages') {
} else if (unreadMessage.parentIdType === IdType.Chat) { hasUnread = true;
let nodeState = data.nodeStates[ } else if (type === 'unread_important_messages') {
unreadMessage.parentId hasUnread = true;
] as ChatReadState; unreadCount += count;
if (!nodeState) { } else if (type === 'unread_mentions') {
nodeState = { hasUnread = true;
type: 'chat', unreadCount += count;
chatId: unreadMessage.parentId,
unseenMessagesCount: 0,
mentionsCount: 0,
};
data.nodeStates[unreadMessage.parentId] = nodeState;
} }
nodeState.unseenMessagesCount++;
data.importantCount++;
} }
data.nodeStates[nodeId] = {
hasUnread,
unreadCount,
};
data.state.hasUnread = data.state.hasUnread || hasUnread;
data.state.unreadCount += unreadCount;
} }
return data; return data;
} }
public async init(): Promise<void> { public async init(): Promise<void> {
const collaborations = await this.workspace.database const nodeCounters = await this.workspace.database
.selectFrom('collaborations') .selectFrom('node_counters')
.selectAll() .selectAll()
.execute(); .execute();
for (const collaboration of collaborations) { for (const nodeCounter of nodeCounters) {
this.collaborations.set(collaboration.node_id, collaboration); if (!this.counters.has(nodeCounter.node_id)) {
} this.counters.set(nodeCounter.node_id, new Map());
}
if (this.collaborations.size === 0) { const counter = this.counters.get(nodeCounter.node_id);
return; if (!counter) {
}
const unreadMessagesRows = await this.workspace.database
.selectFrom('nodes as node')
.leftJoin('node_interactions as node_interactions', (join) =>
join
.onRef('node.id', '=', 'node_interactions.node_id')
.on('node_interactions.collaborator_id', '=', this.workspace.userId)
)
.innerJoin('node_interactions as parent_interactions', (join) =>
join
.onRef('node.parent_id', '=', 'parent_interactions.node_id')
.on('parent_interactions.collaborator_id', '=', this.workspace.userId)
)
.select(['node.id as node_id', 'node.parent_id as parent_id'])
.where('node.created_by', '!=', this.workspace.userId)
.where('node_interactions.last_seen_at', 'is', null)
.where('parent_interactions.last_seen_at', 'is not', null)
.whereRef('node.created_at', '>=', 'parent_interactions.first_seen_at')
.execute();
for (const unreadMessageRow of unreadMessagesRows) {
if (!unreadMessageRow.parent_id) {
continue; continue;
} }
this.unreadMessages.set(unreadMessageRow.node_id, { counter.set(nodeCounter.type, nodeCounter.count);
messageId: unreadMessageRow.node_id,
parentId: unreadMessageRow.parent_id,
parentIdType: getIdType(unreadMessageRow.parent_id),
});
} }
} }
@@ -134,123 +88,61 @@ export class RadarService {
} }
private async handleEvent(event: Event) { private async handleEvent(event: Event) {
if (event.type === 'node_interaction_updated') { if (event.type === 'node_counter_updated') {
await this.handleNodeInteractionUpdated(event); this.handleNodeCounterUpdated(event);
} else if (event.type === 'node_created') { } else if (event.type === 'node_counter_deleted') {
await this.handleNodeCreated(event); this.handleNodeCounterDeleted(event);
} else if (event.type === 'node_deleted') {
await this.handleNodeDeleted(event);
} else if (event.type === 'collaboration_created') {
await this.handleCollaborationCreated(event);
} else if (event.type === 'collaboration_deleted') {
await this.handleCollaborationDeleted(event);
} }
} }
private async handleNodeInteractionUpdated( private handleNodeCounterUpdated(event: NodeCounterUpdatedEvent) {
event: NodeInteractionUpdatedEvent
): Promise<void> {
const interaction = event.nodeInteraction;
if ( if (
event.accountId !== this.workspace.accountId || event.accountId !== this.workspace.accountId ||
event.workspaceId !== this.workspace.id || event.workspaceId !== this.workspace.id
interaction.collaboratorId !== this.workspace.userId
) { ) {
return; return;
} }
if (interaction.lastSeenAt) { if (!this.counters.has(event.counter.nodeId)) {
const unreadMessage = this.unreadMessages.get(interaction.nodeId);
if (unreadMessage) {
this.unreadMessages.delete(interaction.nodeId);
eventBus.publish({
type: 'radar_data_updated',
});
}
return;
}
}
private async handleNodeCreated(event: NodeCreatedEvent): Promise<void> {
if (event.node.type !== 'message') {
return; return;
} }
const message = event.node; const nodeCounters = this.counters.get(event.counter.nodeId);
if (message.createdBy === this.workspace.userId) { if (!nodeCounters) {
return; return;
} }
if (this.unreadMessages.has(message.id)) { const count = nodeCounters.get(event.counter.type);
if (count === event.counter.count) {
return; return;
} }
const collaboration = this.collaborations.get(message.rootId); nodeCounters.set(event.counter.type, event.counter.count);
if (!collaboration) {
return;
}
if (collaboration.created_at > message.createdAt) {
return;
}
const messageInteraction = await this.workspace.database
.selectFrom('node_interactions')
.selectAll()
.where('node_id', '=', message.id)
.where('collaborator_id', '=', this.workspace.userId)
.executeTakeFirst();
if (messageInteraction && messageInteraction.last_seen_at) {
return;
}
this.unreadMessages.set(message.id, {
messageId: message.id,
parentId: message.parentId,
parentIdType: getIdType(message.parentId),
});
eventBus.publish({ eventBus.publish({
type: 'radar_data_updated', type: 'radar_data_updated',
}); });
} }
private async handleNodeDeleted(event: NodeDeletedEvent): Promise<void> { private handleNodeCounterDeleted(event: NodeCounterDeletedEvent) {
const message = event.node; if (
if (message.createdBy === this.workspace.userId) { event.accountId !== this.workspace.accountId ||
event.workspaceId !== this.workspace.id
) {
return; return;
} }
if (!this.unreadMessages.has(message.id)) { const nodeCounter = this.counters.get(event.counter.nodeId);
if (!nodeCounter) {
return; return;
} }
this.unreadMessages.delete(message.id); if (!nodeCounter.has(event.counter.type)) {
return;
}
nodeCounter.delete(event.counter.type);
eventBus.publish({ eventBus.publish({
type: 'radar_data_updated', type: 'radar_data_updated',
}); });
} }
private async handleCollaborationCreated(
event: CollaborationCreatedEvent
): Promise<void> {
const collaboration = await this.workspace.database
.selectFrom('collaborations')
.selectAll()
.where('node_id', '=', event.nodeId)
.executeTakeFirst();
if (!collaboration) {
return;
}
this.collaborations.set(event.nodeId, collaboration);
}
private async handleCollaborationDeleted(
event: CollaborationDeletedEvent
): Promise<void> {
this.collaborations.delete(event.nodeId);
}
} }

View File

@@ -122,10 +122,8 @@ export class SyncService {
await this.collaborationSynchronizer.init(); await this.collaborationSynchronizer.init();
} }
const collaborations = await this.workspace.database const collaborations =
.selectFrom('collaborations') this.workspace.collaborations.getActiveCollaborations();
.selectAll()
.execute();
for (const collaboration of collaborations) { for (const collaboration of collaborations) {
await this.initRootSynchronizers(collaboration.node_id); await this.initRootSynchronizers(collaboration.node_id);

View File

@@ -22,6 +22,7 @@ import { CollaborationService } from '@/main/services/workspaces/collaboration-s
import { SyncService } from '@/main/services/workspaces/sync-service'; import { SyncService } from '@/main/services/workspaces/sync-service';
import { RadarService } from '@/main/services/workspaces/radar-service'; import { RadarService } from '@/main/services/workspaces/radar-service';
import { DocumentService } from '@/main/services/workspaces/document-service'; import { DocumentService } from '@/main/services/workspaces/document-service';
import { NodeCountersService } from '@/main/services/workspaces/node-counters-service';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
export class WorkspaceService { export class WorkspaceService {
@@ -40,6 +41,7 @@ export class WorkspaceService {
public readonly collaborations: CollaborationService; public readonly collaborations: CollaborationService;
public readonly synchronizer: SyncService; public readonly synchronizer: SyncService;
public readonly radar: RadarService; public readonly radar: RadarService;
public readonly nodeCounters: NodeCountersService;
constructor(workspace: Workspace, account: AccountService) { constructor(workspace: Workspace, account: AccountService) {
this.debug(`Initializing workspace service ${workspace.id}`); this.debug(`Initializing workspace service ${workspace.id}`);
@@ -76,6 +78,7 @@ export class WorkspaceService {
this.collaborations = new CollaborationService(this); this.collaborations = new CollaborationService(this);
this.synchronizer = new SyncService(this); this.synchronizer = new SyncService(this);
this.radar = new RadarService(this); this.radar = new RadarService(this);
this.nodeCounters = new NodeCountersService(this);
} }
public get id(): string { public get id(): string {
@@ -111,6 +114,7 @@ export class WorkspaceService {
public async init() { public async init() {
await this.migrate(); await this.migrate();
await this.collaborations.init();
await this.synchronizer.init(); await this.synchronizer.init();
await this.radar.init(); await this.radar.init();
} }

View File

@@ -53,7 +53,7 @@ const AvatarFallback = ({ id, name, size, className }: AvatarProps) => {
if (name) { if (name) {
const color = getColorForId(id); const color = getColorForId(id);
return ( return (
<div <span
className={cn( className={cn(
'inline-flex items-center justify-center overflow-hidden rounded text-white shadow', 'inline-flex items-center justify-center overflow-hidden rounded text-white shadow',
getAvatarSizeClasses(size), getAvatarSizeClasses(size),
@@ -62,7 +62,7 @@ const AvatarFallback = ({ id, name, size, className }: AvatarProps) => {
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
> >
<span className="font-medium">{name[0]?.toLocaleUpperCase()}</span> <span className="font-medium">{name[0]?.toLocaleUpperCase()}</span>
</div> </span>
); );
} }

View File

@@ -2,7 +2,7 @@ import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { useRadar } from '@/renderer/contexts/radar'; import { useRadar } from '@/renderer/contexts/radar';
import { NotificationBadge } from '@/renderer/components/ui/notification-badge'; import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
import { LocalChannelNode } from '@/shared/types/nodes'; import { LocalChannelNode } from '@/shared/types/nodes';
interface ChannelContainerTabProps { interface ChannelContainerTabProps {
@@ -38,13 +38,11 @@ export const ChannelContainerTab = ({
? channel.attributes.name ? channel.attributes.name
: 'Unnamed'; : 'Unnamed';
const channelState = radar.getChannelState( const unreadState = radar.getNodeState(
workspace.accountId, workspace.accountId,
workspace.id, workspace.id,
channel.id channel.id
); );
const unreadCount = channelState.unseenMessagesCount;
const mentionsCount = channelState.mentionsCount;
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -56,7 +54,10 @@ export const ChannelContainerTab = ({
/> />
<span>{name}</span> <span>{name}</span>
{!isActive && ( {!isActive && (
<NotificationBadge count={mentionsCount} unseen={unreadCount > 0} /> <UnreadBadge
count={unreadState.unreadCount}
unread={unreadState.hasUnread}
/>
)} )}
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { Avatar } from '@/renderer/components/avatars/avatar'; import { Avatar } from '@/renderer/components/avatars/avatar';
import { NotificationBadge } from '@/renderer/components/ui/notification-badge'; import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
import { useRadar } from '@/renderer/contexts/radar'; import { useRadar } from '@/renderer/contexts/radar';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useLayout } from '@/renderer/contexts/layout'; import { useLayout } from '@/renderer/contexts/layout';
@@ -18,13 +18,11 @@ export const ChannelSidebarItem = ({ channel }: ChannelSidebarItemProps) => {
const radar = useRadar(); const radar = useRadar();
const isActive = layout.activeTab === channel.id; const isActive = layout.activeTab === channel.id;
const channelState = radar.getChannelState( const unreadState = radar.getNodeState(
workspace.accountId, workspace.accountId,
workspace.id, workspace.id,
channel.id channel.id
); );
const unreadCount = channelState.unseenMessagesCount;
const mentionsCount = channelState.mentionsCount;
return ( return (
<InView <InView
@@ -48,13 +46,16 @@ export const ChannelSidebarItem = ({ channel }: ChannelSidebarItemProps) => {
<span <span
className={cn( className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left', 'line-clamp-1 w-full flex-grow pl-2 text-left',
!isActive && unreadCount > 0 && 'font-semibold' !isActive && unreadState.hasUnread && 'font-semibold'
)} )}
> >
{channel.attributes.name ?? 'Unnamed'} {channel.attributes.name ?? 'Unnamed'}
</span> </span>
{!isActive && ( {!isActive && (
<NotificationBadge count={mentionsCount} unseen={unreadCount > 0} /> <UnreadBadge
count={unreadState.unreadCount}
unread={unreadState.hasUnread}
/>
)} )}
</InView> </InView>
); );

View File

@@ -2,7 +2,7 @@ import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { useRadar } from '@/renderer/contexts/radar'; import { useRadar } from '@/renderer/contexts/radar';
import { NotificationBadge } from '@/renderer/components/ui/notification-badge'; import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
interface ChatContainerTabProps { interface ChatContainerTabProps {
chatId: string; chatId: string;
@@ -45,20 +45,21 @@ export const ChatContainerTab = ({
return <p className="text-sm text-muted-foreground">Not found</p>; return <p className="text-sm text-muted-foreground">Not found</p>;
} }
const chatState = radar.getChatState( const unreadState = radar.getNodeState(
workspace.accountId, workspace.accountId,
workspace.id, workspace.id,
chat.id chat.id
); );
const unreadCount = chatState.unseenMessagesCount;
const mentionsCount = chatState.mentionsCount;
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Avatar size="small" id={user.id} name={user.name} avatar={user.avatar} /> <Avatar size="small" id={user.id} name={user.name} avatar={user.avatar} />
<span>{user.name}</span> <span>{user.name}</span>
{!isActive && ( {!isActive && (
<NotificationBadge count={mentionsCount} unseen={unreadCount > 0} /> <UnreadBadge
count={unreadState.unreadCount}
unread={unreadState.hasUnread}
/>
)} )}
</div> </div>
); );

View File

@@ -2,7 +2,7 @@ import { InView } from 'react-intersection-observer';
import { LocalChatNode } from '@/shared/types/nodes'; import { LocalChatNode } from '@/shared/types/nodes';
import { Avatar } from '@/renderer/components/avatars/avatar'; import { Avatar } from '@/renderer/components/avatars/avatar';
import { NotificationBadge } from '@/renderer/components/ui/notification-badge'; import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
import { useRadar } from '@/renderer/contexts/radar'; import { useRadar } from '@/renderer/contexts/radar';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
@@ -34,14 +34,12 @@ export const ChatSidebarItem = ({ chat }: ChatSidebarItemProps) => {
return null; return null;
} }
const nodeReadState = radar.getChatState( const unreadState = radar.getNodeState(
workspace.accountId, workspace.accountId,
workspace.id, workspace.id,
chat.id chat.id
); );
const isActive = layout.activeTab === chat.id; const isActive = layout.activeTab === chat.id;
const unreadCount =
nodeReadState.unseenMessagesCount + nodeReadState.mentionsCount;
return ( return (
<InView <InView
@@ -65,13 +63,16 @@ export const ChatSidebarItem = ({ chat }: ChatSidebarItemProps) => {
<span <span
className={cn( className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left', 'line-clamp-1 w-full flex-grow pl-2 text-left',
!isActive && unreadCount > 0 && 'font-semibold' !isActive && unreadState.hasUnread && 'font-semibold'
)} )}
> >
{data.name ?? 'Unnamed'} {data.name ?? 'Unnamed'}
</span> </span>
{!isActive && ( {!isActive && (
<NotificationBadge count={unreadCount} unseen={unreadCount > 0} /> <UnreadBadge
count={unreadState.unreadCount}
unread={unreadState.hasUnread}
/>
)} )}
</InView> </InView>
); );

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { useApp } from '@/renderer/contexts/app'; import { useApp } from '@/renderer/contexts/app';
import { Avatar } from '@/renderer/components/avatars/avatar'; import { Avatar } from '@/renderer/components/avatars/avatar';
import { NotificationBadge } from '@/renderer/components/ui/notification-badge'; import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -15,7 +15,7 @@ import {
import { AccountContext, useAccount } from '@/renderer/contexts/account'; import { AccountContext, useAccount } from '@/renderer/contexts/account';
import { useRadar } from '@/renderer/contexts/radar'; import { useRadar } from '@/renderer/contexts/radar';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { AccountReadState } from '@/shared/types/radars'; import { UnreadState } from '@/shared/types/radars';
export function SidebarMenuFooter() { export function SidebarMenuFooter() {
const app = useApp(); const app = useApp();
@@ -29,16 +29,18 @@ export function SidebarMenuFooter() {
const accounts = data ?? []; const accounts = data ?? [];
const otherAccounts = accounts.filter((a) => a.id !== account.id); const otherAccounts = accounts.filter((a) => a.id !== account.id);
const accountStates: Record<string, AccountReadState> = {}; const accountUnreadStates: Record<string, UnreadState> = {};
for (const accountItem of otherAccounts) { for (const accountItem of otherAccounts) {
accountStates[accountItem.id] = radar.getAccountState(accountItem.id); accountUnreadStates[accountItem.id] = radar.getAccountState(accountItem.id);
} }
const importantCount = Object.values(accountStates).reduce(
(acc, curr) => acc + curr.importantCount, const hasUnread = Object.values(accountUnreadStates).some(
0 (state) => state.hasUnread
); );
const hasUnseenChanges = Object.values(accountStates).some(
(state) => state.hasUnseenChanges const unreadCount = Object.values(accountUnreadStates).reduce(
(acc, curr) => acc + curr.unreadCount,
0
); );
return ( return (
@@ -51,9 +53,9 @@ export function SidebarMenuFooter() {
avatar={account.avatar} avatar={account.avatar}
className="size-10 rounded-lg shadow-md" className="size-10 rounded-lg shadow-md"
/> />
<NotificationBadge <UnreadBadge
count={importantCount} count={unreadCount}
unseen={hasUnseenChanges} unread={hasUnread}
className="absolute -top-1 right-0" className="absolute -top-1 right-0"
/> />
</button> </button>
@@ -102,9 +104,9 @@ export function SidebarMenuFooter() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="mb-1">Accounts</DropdownMenuLabel> <DropdownMenuLabel className="mb-1">Accounts</DropdownMenuLabel>
{accounts.map((accountItem) => { {accounts.map((accountItem) => {
const state = accountStates[accountItem.id] ?? { const state = accountUnreadStates[accountItem.id] ?? {
importantCount: 0, unreadCount: 0,
hasUnseenChanges: false, hasUnread: false,
}; };
return ( return (
@@ -142,9 +144,9 @@ export function SidebarMenuFooter() {
{accountItem.id === account.id ? ( {accountItem.id === account.id ? (
<Check className="size-4" /> <Check className="size-4" />
) : ( ) : (
<NotificationBadge <UnreadBadge
count={state.importantCount} count={state.unreadCount}
unseen={state.hasUnseenChanges} unread={state.hasUnread}
/> />
)} )}
</div> </div>

View File

@@ -2,7 +2,7 @@ import { Bell, Check, Plus, Settings } from 'lucide-react';
import React from 'react'; import React from 'react';
import { Avatar } from '@/renderer/components/avatars/avatar'; import { Avatar } from '@/renderer/components/avatars/avatar';
import { NotificationBadge } from '@/renderer/components/ui/notification-badge'; import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -32,11 +32,11 @@ export const SidebarMenuHeader = () => {
const otherWorkspaceStates = otherWorkspaces.map((w) => const otherWorkspaceStates = otherWorkspaces.map((w) =>
radar.getWorkspaceState(w.accountId, w.id) radar.getWorkspaceState(w.accountId, w.id)
); );
const importantCount = otherWorkspaceStates.reduce( const unreadCount = otherWorkspaceStates.reduce(
(acc, curr) => acc + curr.importantCount, (acc, curr) => acc + curr.state.unreadCount,
0 0
); );
const hasUnseenChanges = otherWorkspaceStates.some((w) => w.hasUnseenChanges); const hasUnread = otherWorkspaceStates.some((w) => w.state.hasUnread);
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
@@ -48,9 +48,9 @@ export const SidebarMenuHeader = () => {
name={workspace.name} name={workspace.name}
className="size-10 rounded-lg shadow-md" className="size-10 rounded-lg shadow-md"
/> />
<NotificationBadge <UnreadBadge
count={importantCount} count={unreadCount}
unseen={hasUnseenChanges} unread={hasUnread}
className="absolute -top-1 right-0" className="absolute -top-1 right-0"
/> />
</button> </button>
@@ -93,7 +93,7 @@ export const SidebarMenuHeader = () => {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel> <DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel>
{workspaces.map((workspaceItem) => { {workspaces.map((workspaceItem) => {
const workspaceState = radar.getWorkspaceState( const workspaceUnreadState = radar.getWorkspaceState(
workspaceItem.accountId, workspaceItem.accountId,
workspaceItem.id workspaceItem.id
); );
@@ -118,9 +118,9 @@ export const SidebarMenuHeader = () => {
{workspaceItem.id === workspace.id ? ( {workspaceItem.id === workspace.id ? (
<Check className="size-4" /> <Check className="size-4" />
) : ( ) : (
<NotificationBadge <UnreadBadge
count={workspaceState.importantCount} count={workspaceUnreadState.state.unreadCount}
unseen={workspaceState.hasUnseenChanges} unread={workspaceUnreadState.state.hasUnread}
/> />
)} )}
</div> </div>

View File

@@ -181,6 +181,7 @@ export const MessageCreate = React.forwardRef<MessageCreateRefProps>(
workspaceId={workspace.id} workspaceId={workspace.id}
ref={messageEditorRef} ref={messageEditorRef}
conversationId={conversation.id} conversationId={conversation.id}
rootId={conversation.rootId}
onChange={setContent} onChange={setContent}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />

View File

@@ -24,6 +24,7 @@ import {
TextNode, TextNode,
TrailingNode, TrailingNode,
UnderlineMark, UnderlineMark,
MentionExtension,
} from '@/renderer/editor/extensions'; } from '@/renderer/editor/extensions';
import { ToolbarMenu } from '@/renderer/editor/menus'; import { ToolbarMenu } from '@/renderer/editor/menus';
import { FileMetadata } from '@/shared/types/files'; import { FileMetadata } from '@/shared/types/files';
@@ -32,6 +33,7 @@ interface MessageEditorProps {
accountId: string; accountId: string;
workspaceId: string; workspaceId: string;
conversationId: string; conversationId: string;
rootId: string;
onChange?: (content: JSONContent) => void; onChange?: (content: JSONContent) => void;
onSubmit: () => void; onSubmit: () => void;
} }
@@ -74,6 +76,14 @@ export const MessageEditor = React.forwardRef<
workspaceId: props.workspaceId, workspaceId: props.workspaceId,
}), }),
FileNode, FileNode,
MentionExtension.configure({
context: {
accountId: props.accountId,
workspaceId: props.workspaceId,
documentId: props.conversationId,
rootId: props.rootId,
},
}),
], ],
editorProps: { editorProps: {
attributes: { attributes: {

View File

@@ -1,17 +1,13 @@
import { cn } from '@/shared/lib/utils'; import { cn } from '@/shared/lib/utils';
interface NotificationBadgeProps { interface UnreadBadgeProps {
count: number; count: number;
unseen: boolean; unread: boolean;
className?: string; className?: string;
} }
export const NotificationBadge = ({ export const UnreadBadge = ({ count, unread, className }: UnreadBadgeProps) => {
count, if (count === 0 && !unread) {
unseen,
className,
}: NotificationBadgeProps) => {
if (count === 0 && !unseen) {
return null; return null;
} }

View File

@@ -1,28 +1,18 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { import { WorkspaceRadarData, UnreadState } from '@/shared/types/radars';
AccountReadState,
ChannelReadState,
ChatReadState,
WorkspaceReadState,
} from '@/shared/types/radars';
interface RadarContext { interface RadarContext {
getAccountState: (accountId: string) => AccountReadState; getAccountState: (accountId: string) => UnreadState;
getWorkspaceState: ( getWorkspaceState: (
accountId: string, accountId: string,
workspaceId: string workspaceId: string
) => WorkspaceReadState; ) => WorkspaceRadarData;
getChatState: ( getNodeState: (
accountId: string, accountId: string,
workspaceId: string, workspaceId: string,
chatId: string nodeId: string
) => ChatReadState; ) => UnreadState;
getChannelState: (
accountId: string,
workspaceId: string,
channelId: string
) => ChannelReadState;
markNodeAsSeen: ( markNodeAsSeen: (
accountId: string, accountId: string,
workspaceId: string, workspaceId: string,

View File

@@ -22,5 +22,6 @@ export const defaultClasses = {
gif: 'max-h-72 my-1', gif: 'max-h-72 my-1',
emoji: 'max-h-5 max-w-5 h-5 w-5 px-0.5 mb-1 inline-block', emoji: 'max-h-5 max-w-5 h-5 w-5 px-0.5 mb-1 inline-block',
dropcursor: 'text-primary-foreground bg-blue-500', dropcursor: 'text-primary-foreground bg-blue-500',
mention: 'rounded bg-blue-100 px-1', mention:
'inline-flex flex-row items-center gap-1 rounded-md bg-blue-50 px-0.5 py-0',
}; };

View File

@@ -36,6 +36,7 @@ import { TaskListNode } from '@/renderer/editor/extensions/task-list';
import { TrailingNode } from '@/renderer/editor/extensions/trailing-node'; import { TrailingNode } from '@/renderer/editor/extensions/trailing-node';
import { DatabaseNode } from '@/renderer/editor/extensions/database'; import { DatabaseNode } from '@/renderer/editor/extensions/database';
import { AutoJoiner } from '@/renderer/editor/extensions/auto-joiner'; import { AutoJoiner } from '@/renderer/editor/extensions/auto-joiner';
import { MentionExtension } from '@/renderer/editor/extensions/mention';
export { export {
BlockquoteNode, BlockquoteNode,
@@ -75,4 +76,5 @@ export {
UnderlineMark, UnderlineMark,
DatabaseNode, DatabaseNode,
AutoJoiner, AutoJoiner,
MentionExtension,
}; };

View File

@@ -0,0 +1,300 @@
import type { Range } from '@tiptap/core';
import { Editor, Node } from '@tiptap/core';
import { ReactNodeViewRenderer, ReactRenderer } from '@tiptap/react';
import {
Suggestion,
type SuggestionKeyDownProps,
type SuggestionProps,
} from '@tiptap/suggestion';
import React from 'react';
import {
useFloating,
offset,
flip,
shift,
autoUpdate,
FloatingPortal,
} from '@floating-ui/react';
import { generateId, IdType } from '@colanode/core';
import { updateScrollView } from '@/shared/lib/utils';
import { EditorContext } from '@/shared/types/editor';
import { User } from '@/shared/types/users';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { MentionNodeView } from '@/renderer/editor/views';
interface MentionOptions {
context: EditorContext | null;
}
const navigationKeys = ['ArrowUp', 'ArrowDown', 'Enter'];
const CommandList = ({
items,
command,
range,
props,
}: {
items: User[];
command: (item: User, range: Range) => void;
range: Range;
props: SuggestionProps<User>;
}) => {
const [selectedIndex, setSelectedIndex] = React.useState(0);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
middleware: [offset(6), flip(), shift()],
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
React.useLayoutEffect(() => {
if (props.clientRect) {
refs.setPositionReference({
getBoundingClientRect: () => props.clientRect?.() || new DOMRect(),
});
}
}, [props.clientRect, refs]);
const selectItem = React.useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item, range);
}
},
[command, items, range]
);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowUp') {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === 'ArrowDown') {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === 'Enter') {
selectItem(selectedIndex);
return true;
}
return false;
}
return false;
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
React.useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<FloatingPortal>
<div ref={refs.setFloating} style={floatingStyles}>
<div
id="slash-command"
ref={commandListContainer}
className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-stone-200 bg-white px-1 py-2 shadow-md transition-all"
>
{items.map((item: User, index: number) => (
<button
type="button"
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-stone-900 hover:bg-stone-100 ${
index === selectedIndex ? 'bg-stone-100 text-stone-900' : ''
}`}
key={item.id}
onClick={() => selectItem(index)}
>
<Avatar
id={item.id}
name={item.name}
avatar={item.avatar}
className="size-8"
/>
<div>
<p className="font-medium">{item.name}</p>
<p className="text-xs text-stone-500">{item.email}</p>
</div>
</button>
))}
</div>
</div>
</FloatingPortal>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let editor: Editor | null = null;
return {
onStart: (props: SuggestionProps<User>) => {
editor = props.editor;
props.editor.storage.mention.isOpen = true;
component = new ReactRenderer(CommandList, {
props: {
...props,
props,
},
editor: props.editor,
});
},
onUpdate: (props: SuggestionProps<User>) => {
props.editor.storage.mention.isOpen = true;
component?.updateProps({
...props,
props,
});
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (editor) {
editor.storage.mention.isOpen = true;
}
if (props.event.key === 'Escape') {
return true;
}
if (navigationKeys.includes(props.event.key)) {
return true;
}
// @ts-expect-error Component ref type is complex
return component?.ref?.onKeyDown(props);
},
onExit: () => {
component?.destroy();
if (editor) {
editor.storage.mention.isOpen = false;
}
},
};
};
export const MentionExtension = Node.create<MentionOptions>({
name: 'mention',
group: 'inline',
inline: true,
selectable: false,
atom: true,
addAttributes() {
return {
id: {
default: null,
},
target: {
default: null,
},
};
},
addOptions() {
return {
context: {} as EditorContext,
};
},
addStorage() {
return {
isOpen: false,
};
},
addNodeView() {
return ReactNodeViewRenderer(MentionNodeView, {
as: 'mention',
className: 'inline-flex',
});
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@',
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: User;
}) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(' ');
if (overrideSpace) {
range.to += 1;
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: {
id: generateId(IdType.Mention),
target: props.id,
},
},
{
type: 'text',
text: ' ',
},
])
.run();
window.getSelection()?.collapseToEnd();
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const type = state.schema.nodes[this.name];
if (!type) return false;
return !!$from.parent.type.contentMatch.matchType(type);
},
items: async ({ query }: { query: string }) => {
return new Promise<User[]>((resolve) => {
if (!this.options.context) {
resolve([] as User[]);
return;
}
const { accountId, workspaceId } = this.options.context;
window.colanode
.executeQuery({
type: 'user_search',
accountId,
workspaceId,
searchQuery: query,
})
.then((users) => {
resolve(users);
});
});
},
render: renderItems,
}),
];
},
});

View File

@@ -0,0 +1,36 @@
import { JSONContent } from '@tiptap/core';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { defaultClasses } from '@/renderer/editor/classes';
interface MentionRendererProps {
node: JSONContent;
keyPrefix: string | null;
}
export const MentionRenderer = ({ node }: MentionRendererProps) => {
const workspace = useWorkspace();
const target = node.attrs?.target;
const { data } = useQuery({
type: 'user_get',
userId: target,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const name = data?.name ?? 'Unknown';
return (
<span className={defaultClasses.mention}>
<Avatar
size="small"
id={target ?? '?'}
name={name}
avatar={data?.avatar}
/>
<span role="presentation">{name}</span>
</span>
);
};

View File

@@ -18,6 +18,7 @@ import { ParagraphRenderer } from '@/renderer/editor/renderers/paragraph';
import { TaskItemRenderer } from '@/renderer/editor/renderers/task-item'; import { TaskItemRenderer } from '@/renderer/editor/renderers/task-item';
import { TaskListRenderer } from '@/renderer/editor/renderers/task-list'; import { TaskListRenderer } from '@/renderer/editor/renderers/task-list';
import { TextRenderer } from '@/renderer/editor/renderers/text'; import { TextRenderer } from '@/renderer/editor/renderers/text';
import { MentionRenderer } from '@/renderer/editor/renderers/mention';
interface NodeRendererProps { interface NodeRendererProps {
node: JSONContent; node: JSONContent;
@@ -72,6 +73,9 @@ export const NodeRenderer = ({
<CodeBlockRenderer node={node} keyPrefix={keyPrefix} /> <CodeBlockRenderer node={node} keyPrefix={keyPrefix} />
)) ))
.with('file', () => <FileRenderer node={node} keyPrefix={keyPrefix} />) .with('file', () => <FileRenderer node={node} keyPrefix={keyPrefix} />)
.with('mention', () => (
<MentionRenderer node={node} keyPrefix={keyPrefix} />
))
.otherwise(() => null)} .otherwise(() => null)}
</MarkRenderer> </MarkRenderer>
); );

View File

@@ -4,6 +4,7 @@ import { FilePlaceholderNodeView } from '@/renderer/editor/views/file-placeholde
import { FolderNodeView } from '@/renderer/editor/views/folder'; import { FolderNodeView } from '@/renderer/editor/views/folder';
import { PageNodeView } from '@/renderer/editor/views/page'; import { PageNodeView } from '@/renderer/editor/views/page';
import { DatabaseNodeView } from '@/renderer/editor/views/database'; import { DatabaseNodeView } from '@/renderer/editor/views/database';
import { MentionNodeView } from '@/renderer/editor/views/mention';
export { export {
CodeBlockNodeView, CodeBlockNodeView,
@@ -11,5 +12,6 @@ export {
FileNodeView, FileNodeView,
FilePlaceholderNodeView, FilePlaceholderNodeView,
FolderNodeView, FolderNodeView,
MentionNodeView,
PageNodeView, PageNodeView,
}; };

View File

@@ -0,0 +1,28 @@
import { type NodeViewProps } from '@tiptap/core';
import { NodeViewWrapper } from '@tiptap/react';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
import { defaultClasses } from '@/renderer/editor/classes';
export const MentionNodeView = ({ node }: NodeViewProps) => {
const workspace = useWorkspace();
const target = node.attrs.target;
const { data } = useQuery({
type: 'user_get',
userId: target,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const name = data?.name ?? 'Unknown';
return (
<NodeViewWrapper data-id={node.attrs.id} className={defaultClasses.mention}>
<Avatar size="small" id={target} name={name} avatar={data?.avatar} />
<span role="presentation">{name}</span>
</NodeViewWrapper>
);
};

View File

@@ -18,70 +18,54 @@ export const RadarProvider = ({ children }: RadarProviderProps) => {
const accountState = radarData[accountId]; const accountState = radarData[accountId];
if (!accountState) { if (!accountState) {
return { return {
importantCount: 0, hasUnread: false,
hasUnseenChanges: false, unreadCount: 0,
}; };
} }
const importantCount = Object.values(accountState).reduce( const hasUnread = Object.values(accountState).some(
(acc, state) => acc + state.importantCount, (state) => state.state.hasUnread
);
const unreadCount = Object.values(accountState).reduce(
(acc, state) => acc + state.state.unreadCount,
0 0
); );
const hasUnseenChanges = Object.values(accountState).some(
(state) => state.hasUnseenChanges
);
return { return {
importantCount, hasUnread,
hasUnseenChanges, unreadCount,
}; };
}, },
getWorkspaceState: (accountId, workspaceId) => { getWorkspaceState: (accountId, workspaceId) => {
const workspaceState = radarData[accountId]?.[workspaceId]; const workspaceState = radarData[accountId]?.[workspaceId];
if (workspaceState) { if (workspaceState) {
return { return workspaceState;
hasUnseenChanges: workspaceState.hasUnseenChanges,
importantCount: workspaceState.importantCount,
};
} }
return { return {
userId: '',
workspaceId: workspaceId,
accountId: accountId,
state: {
hasUnread: false,
unreadCount: 0,
},
nodeStates: {}, nodeStates: {},
importantCount: 0,
hasUnseenChanges: false,
}; };
}, },
getChatState: (accountId, workspaceId, chatId) => { getNodeState: (accountId, workspaceId, nodeId) => {
const workspaceState = radarData[accountId]?.[workspaceId]; const workspaceState = radarData[accountId]?.[workspaceId];
if (workspaceState) { if (workspaceState) {
const chatState = workspaceState.nodeStates[chatId]; const nodeState = workspaceState.nodeStates[nodeId];
if (chatState && chatState.type === 'chat') { if (nodeState) {
return chatState; return nodeState;
} }
} }
return { return {
type: 'chat', hasUnread: false,
chatId: chatId, unreadCount: 0,
unseenMessagesCount: 0,
mentionsCount: 0,
};
},
getChannelState: (accountId, workspaceId, channelId) => {
const workspaceState = radarData[accountId]?.[workspaceId];
if (workspaceState) {
const channelState = workspaceState.nodeStates[channelId];
if (channelState && channelState.type === 'channel') {
return channelState;
}
}
return {
type: 'channel',
channelId: channelId,
unseenMessagesCount: 0,
mentionsCount: 0,
}; };
}, },
markNodeAsSeen: (accountId, workspaceId, nodeId) => { markNodeAsSeen: (accountId, workspaceId, nodeId) => {

View File

@@ -108,6 +108,7 @@ const mapContentsToBlockLeafs = (
nodeBlocks.push({ nodeBlocks.push({
type: content.type, type: content.type,
text: content.text, text: content.text,
attrs: content.attrs,
marks: content.marks?.map((mark) => { marks: content.marks?.map((mark) => {
return { return {
type: mark.type, type: mark.type,
@@ -189,6 +190,7 @@ const mapBlockLeafsToContents = (
contents.push({ contents.push({
type: leaf.type, type: leaf.type,
...(leaf.text && { text: leaf.text }), ...(leaf.text && { text: leaf.text }),
...(leaf.attrs && { attrs: leaf.attrs }),
...(leaf.marks?.length && { ...(leaf.marks?.length && {
marks: leaf.marks.map((mark) => ({ marks: leaf.marks.map((mark) => ({
type: mark.type, type: mark.type,

View File

@@ -0,0 +1,83 @@
import { Mention } from '@colanode/core';
import { Transaction } from 'kysely';
import {
WorkspaceDatabaseSchema,
SelectNodeReference,
} from '@/main/databases/workspace/schema';
type MentionChangeResult = {
addedMentions: Mention[];
removedMentions: Mention[];
};
const mentionEquals = (a: Mention, b: Mention) =>
a.id === b.id && a.target === b.target;
export const checkMentionChanges = (
beforeMentions: Mention[],
afterMentions: Mention[]
): MentionChangeResult => {
const addedMentions = afterMentions.filter(
(after) => !beforeMentions.some((before) => mentionEquals(before, after))
);
const removedMentions = beforeMentions.filter(
(before) => !afterMentions.some((after) => mentionEquals(before, after))
);
return {
addedMentions,
removedMentions,
};
};
export const applyMentionUpdates = async (
transaction: Transaction<WorkspaceDatabaseSchema>,
nodeId: string,
userId: string,
date: string,
updateResult: MentionChangeResult
) => {
const createdNodeReferences: SelectNodeReference[] = [];
const deletedNodeReferences: SelectNodeReference[] = [];
for (const mention of updateResult.addedMentions) {
const createdNodeReference = await transaction
.insertInto('node_references')
.returningAll()
.values({
node_id: nodeId,
reference_id: mention.target,
inner_id: mention.id,
type: 'mention',
created_at: date,
created_by: userId,
})
.onConflict((oc) => oc.doNothing())
.executeTakeFirst();
if (!createdNodeReference) {
throw new Error('Failed to create node reference');
}
createdNodeReferences.push(createdNodeReference);
}
for (const mention of updateResult.removedMentions) {
const deletedNodeReference = await transaction
.deleteFrom('node_references')
.where('node_id', '=', nodeId)
.where('reference_id', '=', mention.target)
.where('inner_id', '=', mention.id)
.returningAll()
.executeTakeFirst();
if (!deletedNodeReference) {
throw new Error('Failed to delete node reference');
}
deletedNodeReferences.push(deletedNodeReference);
}
return { createdNodeReferences, deletedNodeReferences };
};

View File

@@ -6,7 +6,13 @@ import { Server } from '@/shared/types/servers';
import { Workspace, WorkspaceMetadata } from '@/shared/types/workspaces'; import { Workspace, WorkspaceMetadata } from '@/shared/types/workspaces';
import { User } from '@/shared/types/users'; import { User } from '@/shared/types/users';
import { FileState } from '@/shared/types/files'; import { FileState } from '@/shared/types/files';
import { LocalNode, NodeInteraction, NodeReaction } from '@/shared/types/nodes'; import {
LocalNode,
NodeCounter,
NodeInteraction,
NodeReaction,
NodeReference,
} from '@/shared/types/nodes';
import { import {
Document, Document,
DocumentState, DocumentState,
@@ -241,6 +247,34 @@ export type DocumentUpdateDeletedEvent = {
updateId: string; updateId: string;
}; };
export type NodeReferenceCreatedEvent = {
type: 'node_reference_created';
accountId: string;
workspaceId: string;
nodeReference: NodeReference;
};
export type NodeReferenceDeletedEvent = {
type: 'node_reference_deleted';
accountId: string;
workspaceId: string;
nodeReference: NodeReference;
};
export type NodeCounterUpdatedEvent = {
type: 'node_counter_updated';
accountId: string;
workspaceId: string;
counter: NodeCounter;
};
export type NodeCounterDeletedEvent = {
type: 'node_counter_deleted';
accountId: string;
workspaceId: string;
counter: NodeCounter;
};
export type Event = export type Event =
| UserCreatedEvent | UserCreatedEvent
| UserUpdatedEvent | UserUpdatedEvent
@@ -278,4 +312,8 @@ export type Event =
| DocumentDeletedEvent | DocumentDeletedEvent
| DocumentStateUpdatedEvent | DocumentStateUpdatedEvent
| DocumentUpdateCreatedEvent | DocumentUpdateCreatedEvent
| DocumentUpdateDeletedEvent; | DocumentUpdateDeletedEvent
| NodeReferenceCreatedEvent
| NodeReferenceDeletedEvent
| NodeCounterUpdatedEvent
| NodeCounterDeletedEvent;

View File

@@ -43,6 +43,24 @@ export type NodeReactionCount = {
reacted: boolean; reacted: boolean;
}; };
export type NodeReference = {
nodeId: string;
referenceId: string;
innerId: string;
type: string;
};
export type NodeCounterType =
| 'unread_mentions'
| 'unread_messages'
| 'unread_important_messages';
export type NodeCounter = {
nodeId: string;
type: NodeCounterType;
count: number;
};
export type LocalNodeBase = { export type LocalNodeBase = {
localRevision: bigint; localRevision: bigint;
serverRevision: bigint; serverRevision: bigint;

View File

@@ -1,64 +1,12 @@
export type ChannelReadState = { export type UnreadState = {
type: 'channel'; hasUnread: boolean;
channelId: string; unreadCount: number;
unseenMessagesCount: number;
mentionsCount: number;
}; };
export type ChatReadState = { export type WorkspaceRadarData = {
type: 'chat';
chatId: string;
unseenMessagesCount: number;
mentionsCount: number;
};
export type DatabaseReadState = {
type: 'database';
databaseId: string;
unseenRecordsCount: number;
};
export type RecordReadState = {
type: 'record';
recordId: string;
hasUnseenChanges: boolean;
mentionsCount: number;
};
export type PageState = {
type: 'page';
pageId: string;
hasUnseenChanges: boolean;
mentionsCount: number;
};
export type FolderState = {
type: 'folder';
folderId: string;
unseenFilesCount: number;
};
export type WorkspaceReadState = {
importantCount: number;
hasUnseenChanges: boolean;
};
export type AccountReadState = {
importantCount: number;
hasUnseenChanges: boolean;
};
export type WorkspaceRadarData = WorkspaceReadState & {
userId: string; userId: string;
workspaceId: string; workspaceId: string;
accountId: string; accountId: string;
nodeStates: Record<string, NodeReadState>; state: UnreadState;
nodeStates: Record<string, UnreadState>;
}; };
export type NodeReadState =
| ChannelReadState
| ChatReadState
| DatabaseReadState
| RecordReadState
| PageState
| FolderState;

View File

@@ -53,7 +53,7 @@ export const assistantResponseHandler: JobHandler<
return; return;
} }
const messageText = messageModel.extractNodeText( const messageText = messageModel.extractText(
message.id, message.id,
message.attributes message.attributes
)?.attributes; )?.attributes;

View File

@@ -51,7 +51,7 @@ export const checkNodeEmbeddingsHandler = async () => {
continue; continue;
} }
const nodeText = nodeModel.extractNodeText(node.id, node.attributes); const nodeText = nodeModel.extractText(node.id, node.attributes);
if (!nodeText) { if (!nodeText) {
continue; continue;
} }

View File

@@ -41,7 +41,7 @@ export const embedNodeHandler = async (input: {
return; return;
} }
const nodeText = nodeModel.extractNodeText(node.id, node.attributes); const nodeText = nodeModel.extractText(node.id, node.attributes);
if (!nodeText) { if (!nodeText) {
return; return;
} }

View File

@@ -128,7 +128,7 @@ async function fetchChatHistory(state: AssistantChainState) {
const isAI = message.created_by === 'colanode_ai'; const isAI = message.created_by === 'colanode_ai';
const extracted = (message && const extracted = (message &&
message.attributes && message.attributes &&
getNodeModel(message.type)?.extractNodeText( getNodeModel(message.type)?.extractText(
message.id, message.id,
message.attributes message.attributes
)) || { attributes: '' }; )) || { attributes: '' };

View File

@@ -41,6 +41,7 @@ export const createDocument = async (
} }
const content = ydoc.getObject<DocumentContent>(); const content = ydoc.getObject<DocumentContent>();
const { createdDocument, createdDocumentUpdate } = await database const { createdDocument, createdDocumentUpdate } = await database
.transaction() .transaction()
.execute(async (trx) => { .execute(async (trx) => {
@@ -188,46 +189,6 @@ const tryUpdateDocumentFromMutation = async (
const { updatedDocument, createdDocumentUpdate } = await database const { updatedDocument, createdDocumentUpdate } = await database
.transaction() .transaction()
.execute(async (trx) => { .execute(async (trx) => {
if (document) {
const createdDocumentUpdate = await trx
.insertInto('document_updates')
.returningAll()
.values({
id: mutation.updateId,
document_id: mutation.documentId,
workspace_id: user.workspace_id,
root_id: node.root_id,
data: decodeState(mutation.data),
created_at: new Date(mutation.createdAt),
created_by: user.id,
merged_updates: null,
})
.executeTakeFirst();
if (!createdDocumentUpdate) {
throw new Error('Failed to create document update');
}
const updatedDocument = await trx
.updateTable('documents')
.returningAll()
.set({
content: JSON.stringify(content),
updated_at: new Date(mutation.createdAt),
updated_by: user.id,
revision: createdDocumentUpdate.revision,
})
.where('id', '=', mutation.documentId)
.where('revision', '=', document.revision)
.executeTakeFirst();
if (!updatedDocument) {
throw new Error('Failed to update document');
}
return { updatedDocument, createdDocumentUpdate };
}
const createdDocumentUpdate = await trx const createdDocumentUpdate = await trx
.insertInto('document_updates') .insertInto('document_updates')
.returningAll() .returningAll()
@@ -247,25 +208,41 @@ const tryUpdateDocumentFromMutation = async (
throw new Error('Failed to create document update'); throw new Error('Failed to create document update');
} }
const updatedDocument = await trx const updatedDocument = document
.insertInto('documents') ? await trx
.returningAll() .updateTable('documents')
.values({ .returningAll()
id: mutation.documentId, .set({
workspace_id: user.workspace_id, content: JSON.stringify(content),
content: JSON.stringify(content), updated_at: new Date(mutation.createdAt),
created_at: new Date(mutation.createdAt), updated_by: user.id,
created_by: user.id, revision: createdDocumentUpdate.revision,
revision: createdDocumentUpdate.revision, })
}) .where('id', '=', mutation.documentId)
.onConflict((cb) => cb.doNothing()) .where('revision', '=', document.revision)
.executeTakeFirst(); .executeTakeFirst()
: await trx
.insertInto('documents')
.returningAll()
.values({
id: mutation.documentId,
workspace_id: user.workspace_id,
content: JSON.stringify(content),
created_at: new Date(mutation.createdAt),
created_by: user.id,
revision: createdDocumentUpdate.revision,
})
.onConflict((cb) => cb.doNothing())
.executeTakeFirst();
if (!updatedDocument) { if (!updatedDocument) {
throw new Error('Failed to create document'); throw new Error('Failed to create document');
} }
return { updatedDocument, createdDocumentUpdate }; return {
updatedDocument,
createdDocumentUpdate,
};
}); });
if (!updatedDocument || !createdDocumentUpdate) { if (!updatedDocument || !createdDocumentUpdate) {

View File

@@ -84,7 +84,7 @@ const fetchParentContext = async (
return undefined; return undefined;
} }
const parentText = parentModel.extractNodeText( const parentText = parentModel.extractText(
parentNode.id, parentNode.id,
parentNode.attributes parentNode.attributes
); );
@@ -100,7 +100,7 @@ const fetchParentContext = async (
const path = pathNodes const path = pathNodes
.map((n) => { .map((n) => {
const model = getNodeModel(n.attributes.type); const model = getNodeModel(n.attributes.type);
return model?.extractNodeText(n.id, n.attributes)?.name ?? ''; return model?.extractText(n.id, n.attributes)?.name ?? '';
}) })
.join(' / '); .join(' / ');
@@ -173,7 +173,7 @@ export const fetchNodeMetadata = async (
return undefined; return undefined;
} }
const nodeText = nodeModel.extractNodeText(node.id, node.attributes); const nodeText = nodeModel.extractText(node.id, node.attributes);
if (!nodeText) { if (!nodeText) {
return undefined; return undefined;
} }
@@ -251,7 +251,7 @@ export const fetchDocumentMetadata = async (
const nodeModel = getNodeModel(node.type); const nodeModel = getNodeModel(node.type);
if (nodeModel) { if (nodeModel) {
const nodeText = nodeModel.extractNodeText(node.id, node.attributes); const nodeText = nodeModel.extractText(node.id, node.attributes);
if (nodeText) { if (nodeText) {
baseMetadata.name = nodeText.name; baseMetadata.name = nodeText.name;
} }
@@ -354,7 +354,7 @@ export const fetchNodesMetadata = async (
continue; continue;
} }
const nodeText = nodeModel.extractNodeText(node.id, node.attributes); const nodeText = nodeModel.extractText(node.id, node.attributes);
if (!nodeText) { if (!nodeText) {
continue; continue;
} }
@@ -388,7 +388,7 @@ export const fetchNodesMetadata = async (
if (parentNode) { if (parentNode) {
const parentModel = getNodeModel(parentNode.type); const parentModel = getNodeModel(parentNode.type);
if (parentModel) { if (parentModel) {
const parentText = parentModel.extractNodeText( const parentText = parentModel.extractText(
parentNode.id, parentNode.id,
parentNode.attributes parentNode.attributes
); );
@@ -525,7 +525,7 @@ export const fetchDocumentsMetadata = async (
let name: string | undefined; let name: string | undefined;
const nodeModel = getNodeModel(node.type); const nodeModel = getNodeModel(node.type);
if (nodeModel) { if (nodeModel) {
const nodeText = nodeModel.extractNodeText(node.id, node.attributes); const nodeText = nodeModel.extractText(node.id, node.attributes);
if (nodeText) { if (nodeText) {
name = nodeText.name ?? ''; name = nodeText.name ?? '';
} }
@@ -553,7 +553,7 @@ export const fetchDocumentsMetadata = async (
if (parentNode) { if (parentNode) {
const parentModel = getNodeModel(parentNode.type); const parentModel = getNodeModel(parentNode.type);
if (parentModel) { if (parentModel) {
const parentText = parentModel.extractNodeText( const parentText = parentModel.extractText(
parentNode.id, parentNode.id,
parentNode.attributes parentNode.attributes
); );

View File

@@ -18,6 +18,7 @@ import { cloneDeep } from 'lodash-es';
import { database } from '@/data/database'; import { database } from '@/data/database';
import { import {
CreateCollaboration, CreateCollaboration,
SelectCollaboration,
SelectNode, SelectNode,
SelectNodeUpdate, SelectNodeUpdate,
SelectUser, SelectUser,
@@ -174,17 +175,17 @@ export const createNode = async (
throw new Error('Failed to create node'); throw new Error('Failed to create node');
} }
let createdCollaborations: SelectCollaboration[] = [];
if (collaborationsToCreate.length > 0) { if (collaborationsToCreate.length > 0) {
const createdCollaborations = await trx createdCollaborations = await trx
.insertInto('collaborations') .insertInto('collaborations')
.returningAll() .returningAll()
.values(collaborationsToCreate) .values(collaborationsToCreate)
.execute(); .execute();
return { createdNode, createdCollaborations };
} }
return { createdNode, createdCollaborations: [] }; return { createdNode, createdCollaborations };
}); });
eventBus.publish({ eventBus.publish({
@@ -443,17 +444,17 @@ export const createNodeFromMutation = async (
throw new Error('Failed to create node'); throw new Error('Failed to create node');
} }
let createdCollaborations: SelectCollaboration[] = [];
if (collaborationsToCreate.length > 0) { if (collaborationsToCreate.length > 0) {
const createdCollaborations = await trx createdCollaborations = await trx
.insertInto('collaborations') .insertInto('collaborations')
.returningAll() .returningAll()
.values(collaborationsToCreate) .values(collaborationsToCreate)
.execute(); .execute();
return { createdNode, createdCollaborations };
} }
return { createdNode, createdCollaborations: [] }; return { createdNode, createdCollaborations };
}); });
eventBus.publish({ eventBus.publish({

15
package-lock.json generated
View File

@@ -83,6 +83,7 @@
"@tiptap/extension-underline": "^2.11.7", "@tiptap/extension-underline": "^2.11.7",
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/suggestion": "^2.11.7", "@tiptap/suggestion": "^2.11.7",
"async-lock": "^1.4.1",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"bufferutil": "^4.0.9", "bufferutil": "^4.0.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -124,6 +125,7 @@
"@electron-forge/plugin-vite": "^7.8.0", "@electron-forge/plugin-vite": "^7.8.0",
"@electron-forge/publisher-github": "^7.8.0", "@electron-forge/publisher-github": "^7.8.0",
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@types/async-lock": "^1.4.2",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2", "@types/electron-squirrel-startup": "^1.0.2",
"@types/is-hotkey": "^0.1.10", "@types/is-hotkey": "^0.1.10",
@@ -6891,6 +6893,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/async-lock": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz",
"integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -8150,6 +8159,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/async-lock": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
"integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==",
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",

View File

@@ -33,3 +33,5 @@ export * from './lib/permissions';
export * from './types/api'; export * from './types/api';
export * from './lib/debugger'; export * from './lib/debugger';
export * from './types/crdt'; export * from './types/crdt';
export * from './lib/mentions';
export * from './types/mentions';

View File

@@ -37,6 +37,7 @@ export enum IdType {
Host = 'ht', Host = 'ht',
Block = 'bl', Block = 'bl',
OtpCode = 'ot', OtpCode = 'ot',
Mention = 'me',
} }
export const generateId = (type: IdType): string => { export const generateId = (type: IdType): string => {

View File

@@ -0,0 +1,47 @@
import { Mention } from '../types/mentions';
import { Block } from '../registry/block';
export const extractBlocksMentions = (
nodeId: string,
blocks: Record<string, Block> | undefined | null
): Mention[] => {
if (!blocks) {
return [];
}
return collectBlockMentions(nodeId, blocks);
};
const collectBlockMentions = (
blockId: string,
blocks: Record<string, Block>
): Mention[] => {
const mentions: Mention[] = [];
// Extract text from the current block's leaf nodes
const block = blocks[blockId];
if (block) {
if (block.content) {
for (const leaf of block.content) {
if (leaf.type === 'mention' && leaf.attrs?.target && leaf.attrs?.id) {
mentions.push({
id: leaf.attrs.id,
target: leaf.attrs.target,
});
}
}
}
}
// Find children and sort them by their index to maintain a stable order
const children = Object.values(blocks)
.filter((child) => child.parentId === blockId)
.sort((a, b) => a.index.localeCompare(b.index));
// Recursively collect mentions from children
for (const child of children) {
mentions.push(...collectBlockMentions(child.id, blocks));
}
return mentions;
};

View File

@@ -5,6 +5,7 @@ import { ZodText } from './zod';
export const blockLeafSchema = z.object({ export const blockLeafSchema = z.object({
type: z.string(), type: z.string(),
text: ZodText.create().nullable().optional(), text: ZodText.create().nullable().optional(),
attrs: z.record(z.any()).nullable().optional(),
marks: z marks: z
.array( .array(
z.object({ z.object({

View File

@@ -61,7 +61,7 @@ export const channelModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (_: string, attributes: NodeAttributes) => { extractText: (_: string, attributes: NodeAttributes) => {
if (attributes.type !== 'channel') { if (attributes.type !== 'channel') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -71,4 +71,7 @@ export const channelModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -46,7 +46,10 @@ export const chatModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: () => { extractText: () => {
return null; return null;
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -1,6 +1,7 @@
import { z, ZodSchema } from 'zod'; import { z, ZodSchema } from 'zod';
import { WorkspaceRole } from '../../types/workspaces'; import { WorkspaceRole } from '../../types/workspaces';
import { Mention } from '../../types/mentions';
import { Node, NodeAttributes } from '.'; import { Node, NodeAttributes } from '.';
@@ -64,5 +65,6 @@ export interface NodeModel {
canUpdateDocument: (context: CanUpdateDocumentContext) => boolean; canUpdateDocument: (context: CanUpdateDocumentContext) => boolean;
canDelete: (context: CanDeleteNodeContext) => boolean; canDelete: (context: CanDeleteNodeContext) => boolean;
canReact: (context: CanReactNodeContext) => boolean; canReact: (context: CanReactNodeContext) => boolean;
extractNodeText: (id: string, attributes: NodeAttributes) => NodeText | null; extractText: (id: string, attributes: NodeAttributes) => NodeText | null;
extractMentions: (id: string, attributes: NodeAttributes) => Mention[];
} }

View File

@@ -134,7 +134,7 @@ export const databaseViewModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (_, attributes) => { extractText: (_, attributes) => {
if (attributes.type !== 'database_view') { if (attributes.type !== 'database_view') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -144,4 +144,7 @@ export const databaseViewModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -61,7 +61,7 @@ export const databaseModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (_, attributes) => { extractText: (_, attributes) => {
if (attributes.type !== 'database') { if (attributes.type !== 'database') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -71,4 +71,7 @@ export const databaseModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -81,7 +81,7 @@ export const fileModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (_, attributes) => { extractText: (_, attributes) => {
if (attributes.type !== 'file') { if (attributes.type !== 'file') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -91,4 +91,7 @@ export const fileModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -59,7 +59,7 @@ export const folderModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (_, attributes) => { extractText: (_, attributes) => {
if (attributes.type !== 'folder') { if (attributes.type !== 'folder') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -69,4 +69,7 @@ export const folderModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -6,6 +6,7 @@ import { blockSchema } from '../block';
import { extractBlockTexts } from '../../lib/texts'; import { extractBlockTexts } from '../../lib/texts';
import { extractNodeRole } from '../../lib/nodes'; import { extractNodeRole } from '../../lib/nodes';
import { hasNodeRole } from '../../lib/permissions'; import { hasNodeRole } from '../../lib/permissions';
import { extractBlocksMentions } from '../../lib/mentions';
export const messageAttributesSchema = z.object({ export const messageAttributesSchema = z.object({
type: z.literal('message'), type: z.literal('message'),
@@ -75,7 +76,7 @@ export const messageModel: NodeModel = {
return hasNodeRole(role, 'viewer'); return hasNodeRole(role, 'viewer');
}, },
extractNodeText: (id, attributes) => { extractText: (id, attributes) => {
if (attributes.type !== 'message') { if (attributes.type !== 'message') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -87,4 +88,11 @@ export const messageModel: NodeModel = {
attributes: attributesText, attributes: attributesText,
}; };
}, },
extractMentions: (id, attributes) => {
if (attributes.type !== 'message') {
throw new Error('Invalid node type');
}
return extractBlocksMentions(id, attributes.content);
},
}; };

View File

@@ -70,7 +70,7 @@ export const pageModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (id, attributes) => { extractText: (id, attributes) => {
if (attributes.type !== 'page') { if (attributes.type !== 'page') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -80,4 +80,7 @@ export const pageModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -85,7 +85,7 @@ export const recordModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (id, attributes) => { extractText: (id, attributes) => {
if (attributes.type !== 'record') { if (attributes.type !== 'record') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -104,4 +104,7 @@ export const recordModel: NodeModel = {
attributes: texts.join('\n'), attributes: texts.join('\n'),
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -73,7 +73,7 @@ export const spaceModel: NodeModel = {
canReact: () => { canReact: () => {
return false; return false;
}, },
extractNodeText: (_, attributes) => { extractText: (_, attributes) => {
if (attributes.type !== 'space') { if (attributes.type !== 'space') {
throw new Error('Invalid node type'); throw new Error('Invalid node type');
} }
@@ -83,4 +83,7 @@ export const spaceModel: NodeModel = {
attributes: null, attributes: null,
}; };
}, },
extractMentions: () => {
return [];
},
}; };

View File

@@ -0,0 +1,8 @@
export type Mention = {
id: string;
target: string;
};
export const MentionConstants = {
Everyone: 'everyone',
};