mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement mentions
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
300
apps/desktop/src/renderer/editor/extensions/mention.tsx
Normal file
300
apps/desktop/src/renderer/editor/extensions/mention.tsx
Normal 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,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
36
apps/desktop/src/renderer/editor/renderers/mention.tsx
Normal file
36
apps/desktop/src/renderer/editor/renderers/mention.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
28
apps/desktop/src/renderer/editor/views/mention.tsx
Normal file
28
apps/desktop/src/renderer/editor/views/mention.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
83
apps/desktop/src/shared/lib/mentions.ts
Normal file
83
apps/desktop/src/shared/lib/mentions.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '' };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
15
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
47
packages/core/src/lib/mentions.ts
Normal file
47
packages/core/src/lib/mentions.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ export const chatModel: NodeModel = {
|
|||||||
canReact: () => {
|
canReact: () => {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
extractNodeText: () => {
|
extractText: () => {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
extractMentions: () => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
8
packages/core/src/types/mentions.ts
Normal file
8
packages/core/src/types/mentions.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type Mention = {
|
||||||
|
id: string;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MentionConstants = {
|
||||||
|
Everyone: 'everyone',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user