mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 03:37:51 +01:00
Implement mentions
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@electron-forge/plugin-vite": "^7.8.0",
|
||||
"@electron-forge/publisher-github": "^7.8.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/is-hotkey": "^0.1.10",
|
||||
@@ -85,6 +86,7 @@
|
||||
"@tiptap/extension-underline": "^2.11.7",
|
||||
"@tiptap/react": "^2.11.7",
|
||||
"@tiptap/suggestion": "^2.11.7",
|
||||
"async-lock": "^1.4.1",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"bufferutil": "^4.0.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -8,14 +8,14 @@ export const createNodeInteractionsTable: Migration = {
|
||||
.addColumn('collaborator_id', 'text', (col) => col.notNull())
|
||||
.addColumn('root_id', 'text', (col) => col.notNull())
|
||||
.addColumn('revision', 'integer', (col) => col.notNull())
|
||||
.addPrimaryKeyConstraint('node_interactions_pkey', [
|
||||
'node_id',
|
||||
'collaborator_id',
|
||||
])
|
||||
.addColumn('first_seen_at', 'text')
|
||||
.addColumn('last_seen_at', 'text')
|
||||
.addColumn('first_opened_at', 'text')
|
||||
.addColumn('last_opened_at', 'text')
|
||||
.addPrimaryKeyConstraint('node_interactions_pkey', [
|
||||
'node_id',
|
||||
'collaborator_id',
|
||||
])
|
||||
.execute();
|
||||
},
|
||||
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 { createCursorsTable } from './00016-create-cursors-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> = {
|
||||
'00001-create-users-table': createUsersTable,
|
||||
@@ -36,4 +38,6 @@ export const workspaceDatabaseMigrations: Record<string, Migration> = {
|
||||
'00015-create-tombstones-table': createTombstonesTable,
|
||||
'00016-create-cursors-table': createCursorsTable,
|
||||
'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 { DownloadStatus, UploadStatus } from '@/shared/types/files';
|
||||
import { NodeCounterType } from '@/shared/types/nodes';
|
||||
|
||||
interface UserTable {
|
||||
id: ColumnType<string, string, never>;
|
||||
@@ -93,6 +94,31 @@ export type SelectNodeReaction = Selectable<NodeReactionTable>;
|
||||
export type CreateNodeReaction = Insertable<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 {
|
||||
id: ColumnType<string, string, never>;
|
||||
name: ColumnType<string | null, string | null, string | null>;
|
||||
@@ -244,6 +270,8 @@ export interface WorkspaceDatabaseSchema {
|
||||
node_interactions: NodeInteractionTable;
|
||||
node_updates: NodeUpdateTable;
|
||||
node_reactions: NodeReactionTable;
|
||||
node_references: NodeReferenceTable;
|
||||
node_counters: NodeCounterTable;
|
||||
node_texts: NodeTextTable;
|
||||
documents: DocumentTable;
|
||||
document_states: DocumentStateTable;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SelectDocument,
|
||||
SelectDocumentState,
|
||||
SelectDocumentUpdate,
|
||||
SelectNodeReference,
|
||||
} from '@/main/databases/workspace';
|
||||
import {
|
||||
Account,
|
||||
@@ -36,7 +37,12 @@ import {
|
||||
WorkspaceMetadata,
|
||||
WorkspaceMetadataKey,
|
||||
} 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 { Icon } from '@/shared/types/icons';
|
||||
import { AppMetadata, AppMetadataKey } from '@/shared/types/apps';
|
||||
@@ -251,3 +257,12 @@ export const mapWorkspaceMetadata = (
|
||||
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');
|
||||
}
|
||||
|
||||
await workspace.nodeCounters.checkCountersForUpdatedNodeInteraction(
|
||||
createdInteraction,
|
||||
existingInteraction
|
||||
);
|
||||
|
||||
workspace.mutations.triggerSync();
|
||||
|
||||
eventBus.publish({
|
||||
|
||||
@@ -110,6 +110,11 @@ export class NodeMarkSeenMutationHandler
|
||||
throw new Error('Failed to create node interaction');
|
||||
}
|
||||
|
||||
await workspace.nodeCounters.checkCountersForUpdatedNodeInteraction(
|
||||
createdInteraction,
|
||||
existingInteraction
|
||||
);
|
||||
|
||||
workspace.mutations.triggerSync();
|
||||
|
||||
eventBus.publish({
|
||||
|
||||
@@ -35,22 +35,22 @@ export class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
let importantCount = 0;
|
||||
let hasUnseenChanges = false;
|
||||
let hasUnread = false;
|
||||
let unreadCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
const workspaces = account.getWorkspaces();
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const radarData = workspace.radar.getData();
|
||||
importantCount += radarData.importantCount;
|
||||
hasUnseenChanges = hasUnseenChanges || radarData.hasUnseenChanges;
|
||||
hasUnread = hasUnread || radarData.state.hasUnread;
|
||||
unreadCount = unreadCount + radarData.state.unreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (importantCount > 0) {
|
||||
app.dock.setBadge(importantCount.toString());
|
||||
} else if (hasUnseenChanges) {
|
||||
if (unreadCount > 0) {
|
||||
app.dock.setBadge(unreadCount.toString());
|
||||
} else if (hasUnread) {
|
||||
app.dock.setBadge('·');
|
||||
} else {
|
||||
app.dock.setBadge('');
|
||||
|
||||
@@ -2,22 +2,46 @@ import { SyncCollaborationData, createDebugger } from '@colanode/core';
|
||||
|
||||
import { eventBus } from '@/shared/lib/event-bus';
|
||||
import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
|
||||
import { SelectCollaboration } from '@/main/databases/workspace';
|
||||
|
||||
export class CollaborationService {
|
||||
private readonly debug = createDebugger('desktop:service:collaboration');
|
||||
private readonly workspace: WorkspaceService;
|
||||
private readonly collaborations = new Map<string, SelectCollaboration>();
|
||||
|
||||
constructor(workspace: WorkspaceService) {
|
||||
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) {
|
||||
this.debug(
|
||||
`Applying server collaboration: ${collaboration.nodeId} for workspace ${this.workspace.id}`
|
||||
);
|
||||
|
||||
await this.workspace.database
|
||||
const upsertedCollaboration = await this.workspace.database
|
||||
.insertInto('collaborations')
|
||||
.returningAll()
|
||||
.values({
|
||||
node_id: collaboration.nodeId,
|
||||
role: collaboration.role,
|
||||
@@ -37,9 +61,16 @@ export class CollaborationService {
|
||||
})
|
||||
.where('revision', '<', BigInt(collaboration.revision))
|
||||
)
|
||||
.execute();
|
||||
.executeTakeFirst();
|
||||
|
||||
this.collaborations.set(
|
||||
collaboration.nodeId,
|
||||
upsertedCollaboration as SelectCollaboration
|
||||
);
|
||||
|
||||
if (collaboration.deletedAt) {
|
||||
this.collaborations.delete(collaboration.nodeId);
|
||||
|
||||
await this.workspace.database
|
||||
.deleteFrom('nodes')
|
||||
.where('root_id', '=', collaboration.nodeId)
|
||||
@@ -51,7 +82,7 @@ export class CollaborationService {
|
||||
.execute();
|
||||
|
||||
await this.workspace.database
|
||||
.deleteFrom('node_interactions')
|
||||
.deleteFrom('node_reactions')
|
||||
.where('root_id', '=', collaboration.nodeId)
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CanUpdateDocumentContext,
|
||||
createDebugger,
|
||||
DocumentContent,
|
||||
extractBlocksMentions,
|
||||
extractDocumentText,
|
||||
generateId,
|
||||
getNodeModel,
|
||||
@@ -15,12 +16,20 @@ import { encodeState, YDoc } from '@colanode/crdt';
|
||||
import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
|
||||
import { eventBus } from '@/shared/lib/event-bus';
|
||||
import { fetchNodeTree } from '@/main/lib/utils';
|
||||
import { SelectDocument } from '@/main/databases/workspace';
|
||||
import {
|
||||
CreateNodeReference,
|
||||
SelectDocument,
|
||||
} from '@/main/databases/workspace';
|
||||
import {
|
||||
mapDocument,
|
||||
mapDocumentState,
|
||||
mapDocumentUpdate,
|
||||
mapNodeReference,
|
||||
} from '@/main/lib/mappers';
|
||||
import {
|
||||
applyMentionUpdates,
|
||||
checkMentionChanges,
|
||||
} from '@/shared/lib/mentions';
|
||||
|
||||
const UPDATE_RETRIES_LIMIT = 10;
|
||||
|
||||
@@ -115,10 +124,20 @@ export class DocumentService {
|
||||
const updateId = generateId(IdType.Update);
|
||||
const updatedAt = new Date().toISOString();
|
||||
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
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const { createdDocument, createdMutation, createdNodeReferences } =
|
||||
await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const createdDocument = await trx
|
||||
.insertInto('documents')
|
||||
.returningAll()
|
||||
@@ -162,7 +181,16 @@ export class DocumentService {
|
||||
})
|
||||
.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) {
|
||||
@@ -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) {
|
||||
this.workspace.mutations.triggerSync();
|
||||
}
|
||||
@@ -204,10 +241,11 @@ export class DocumentService {
|
||||
ydoc.applyUpdate(update.data);
|
||||
}
|
||||
|
||||
const beforeContent = ydoc.getObject<DocumentContent>();
|
||||
ydoc.applyUpdate(update);
|
||||
|
||||
const content = ydoc.getObject<DocumentContent>();
|
||||
if (!model.documentSchema?.safeParse(content).success) {
|
||||
const afterContent = ydoc.getObject<DocumentContent>();
|
||||
if (!model.documentSchema?.safeParse(afterContent).success) {
|
||||
throw new Error('Invalid document state');
|
||||
}
|
||||
|
||||
@@ -215,80 +253,102 @@ export class DocumentService {
|
||||
const serverRevision = BigInt(document.server_revision) + 1n;
|
||||
const updateId = generateId(IdType.Update);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const text = extractDocumentText(document.id, content);
|
||||
const text = extractDocumentText(document.id, afterContent);
|
||||
|
||||
const { updatedDocument, createdUpdate, createdMutation } =
|
||||
await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const updatedDocument = await trx
|
||||
.updateTable('documents')
|
||||
.returningAll()
|
||||
.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();
|
||||
const beforeMentions =
|
||||
extractBlocksMentions(document.id, beforeContent.blocks) ?? [];
|
||||
const afterMentions =
|
||||
extractBlocksMentions(document.id, afterContent.blocks) ?? [];
|
||||
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
|
||||
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
const {
|
||||
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
|
||||
.insertInto('document_updates')
|
||||
.returningAll()
|
||||
.values({
|
||||
id: updateId,
|
||||
document_id: document.id,
|
||||
data: update,
|
||||
created_at: updatedAt,
|
||||
})
|
||||
.executeTakeFirst();
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
|
||||
if (!createdUpdate) {
|
||||
throw new Error('Failed to create update');
|
||||
}
|
||||
const createdUpdate = await trx
|
||||
.insertInto('document_updates')
|
||||
.returningAll()
|
||||
.values({
|
||||
id: updateId,
|
||||
document_id: document.id,
|
||||
data: update,
|
||||
created_at: updatedAt,
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
const mutationData: UpdateDocumentMutationData = {
|
||||
documentId: document.id,
|
||||
updateId: updateId,
|
||||
data: encodeState(update),
|
||||
createdAt: updatedAt,
|
||||
};
|
||||
if (!createdUpdate) {
|
||||
throw new Error('Failed to create update');
|
||||
}
|
||||
|
||||
const createdMutation = await trx
|
||||
.insertInto('mutations')
|
||||
.returningAll()
|
||||
.values({
|
||||
id: generateId(IdType.Mutation),
|
||||
type: 'update_document',
|
||||
data: JSON.stringify(mutationData),
|
||||
created_at: updatedAt,
|
||||
retries: 0,
|
||||
})
|
||||
.executeTakeFirst();
|
||||
const mutationData: UpdateDocumentMutationData = {
|
||||
documentId: document.id,
|
||||
updateId: updateId,
|
||||
data: encodeState(update),
|
||||
createdAt: updatedAt,
|
||||
};
|
||||
|
||||
if (!createdMutation) {
|
||||
throw new Error('Failed to create mutation');
|
||||
}
|
||||
const createdMutation = await trx
|
||||
.insertInto('mutations')
|
||||
.returningAll()
|
||||
.values({
|
||||
id: generateId(IdType.Mutation),
|
||||
type: 'update_document',
|
||||
data: JSON.stringify(mutationData),
|
||||
created_at: updatedAt,
|
||||
retries: 0,
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
await trx
|
||||
.updateTable('document_texts')
|
||||
.set({
|
||||
text: text,
|
||||
})
|
||||
.where('id', '=', document.id)
|
||||
.executeTakeFirst();
|
||||
if (!createdMutation) {
|
||||
throw new Error('Failed to create mutation');
|
||||
}
|
||||
|
||||
return {
|
||||
updatedDocument,
|
||||
createdMutation,
|
||||
createdUpdate,
|
||||
};
|
||||
});
|
||||
await trx
|
||||
.updateTable('document_texts')
|
||||
.set({
|
||||
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) {
|
||||
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) {
|
||||
this.workspace.mutations.triggerSync();
|
||||
}
|
||||
@@ -415,44 +493,68 @@ export class DocumentService {
|
||||
const localRevision = BigInt(document.local_revision) + BigInt(1);
|
||||
const text = extractDocumentText(document.id, content);
|
||||
|
||||
const { updatedDocument, deletedUpdate } = 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 beforeContent = JSON.parse(document.content) as DocumentContent;
|
||||
const beforeMentions =
|
||||
extractBlocksMentions(document.id, beforeContent.blocks) ?? [];
|
||||
const afterMentions =
|
||||
extractBlocksMentions(document.id, content.blocks) ?? [];
|
||||
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
|
||||
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
const {
|
||||
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
|
||||
.deleteFrom('document_updates')
|
||||
.returningAll()
|
||||
.where('id', '=', updateToDelete.id)
|
||||
.executeTakeFirst();
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
|
||||
if (!deletedUpdate) {
|
||||
throw new Error('Failed to delete update');
|
||||
}
|
||||
const deletedUpdate = await trx
|
||||
.deleteFrom('document_updates')
|
||||
.returningAll()
|
||||
.where('id', '=', updateToDelete.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
await trx
|
||||
.updateTable('document_texts')
|
||||
.set({
|
||||
text: text,
|
||||
})
|
||||
.where('id', '=', document.id)
|
||||
.executeTakeFirst();
|
||||
if (!deletedUpdate) {
|
||||
throw new Error('Failed to delete update');
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -537,66 +657,95 @@ export class DocumentService {
|
||||
const updatesToDelete = [data.id, ...mergedUpdateIds];
|
||||
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) {
|
||||
const { updatedDocument, upsertedState, deletedUpdates } =
|
||||
await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const updatedDocument = await trx
|
||||
.updateTable('documents')
|
||||
.returningAll()
|
||||
.set({
|
||||
content: JSON.stringify(content),
|
||||
server_revision: serverRevision,
|
||||
local_revision: localRevision,
|
||||
updated_at: data.createdAt,
|
||||
updated_by: data.createdBy,
|
||||
})
|
||||
.where('id', '=', data.documentId)
|
||||
.where('local_revision', '=', document.local_revision)
|
||||
.executeTakeFirst();
|
||||
const {
|
||||
updatedDocument,
|
||||
upsertedState,
|
||||
deletedUpdates,
|
||||
createdNodeReferences,
|
||||
deletedNodeReferences,
|
||||
} = await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const updatedDocument = await trx
|
||||
.updateTable('documents')
|
||||
.returningAll()
|
||||
.set({
|
||||
content: JSON.stringify(content),
|
||||
server_revision: serverRevision,
|
||||
local_revision: localRevision,
|
||||
updated_at: data.createdAt,
|
||||
updated_by: data.createdBy,
|
||||
})
|
||||
.where('id', '=', data.documentId)
|
||||
.where('local_revision', '=', document.local_revision)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Failed to update document');
|
||||
}
|
||||
|
||||
const upsertedState = await trx
|
||||
.insertInto('document_states')
|
||||
.returningAll()
|
||||
.values({
|
||||
id: data.documentId,
|
||||
state: serverState,
|
||||
revision: serverRevision,
|
||||
})
|
||||
.onConflict((cb) =>
|
||||
cb
|
||||
.column('id')
|
||||
.doUpdateSet({
|
||||
state: serverState,
|
||||
revision: serverRevision,
|
||||
})
|
||||
.where('revision', '=', BigInt(documentState?.revision ?? 0))
|
||||
)
|
||||
.executeTakeFirst();
|
||||
const upsertedState = await trx
|
||||
.insertInto('document_states')
|
||||
.returningAll()
|
||||
.values({
|
||||
id: data.documentId,
|
||||
state: serverState,
|
||||
revision: serverRevision,
|
||||
})
|
||||
.onConflict((cb) =>
|
||||
cb
|
||||
.column('id')
|
||||
.doUpdateSet({
|
||||
state: serverState,
|
||||
revision: serverRevision,
|
||||
})
|
||||
.where('revision', '=', BigInt(documentState?.revision ?? 0))
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!upsertedState) {
|
||||
throw new Error('Failed to update document state');
|
||||
}
|
||||
if (!upsertedState) {
|
||||
throw new Error('Failed to update document state');
|
||||
}
|
||||
|
||||
const deletedUpdates = await trx
|
||||
.deleteFrom('document_updates')
|
||||
.returningAll()
|
||||
.where('id', 'in', updatesToDelete)
|
||||
.execute();
|
||||
const deletedUpdates = await trx
|
||||
.deleteFrom('document_updates')
|
||||
.returningAll()
|
||||
.where('id', 'in', updatesToDelete)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.updateTable('document_texts')
|
||||
.set({
|
||||
text: text,
|
||||
})
|
||||
.where('id', '=', data.documentId)
|
||||
.executeTakeFirst();
|
||||
await trx
|
||||
.updateTable('document_texts')
|
||||
.set({
|
||||
text: text,
|
||||
})
|
||||
.where('id', '=', data.documentId)
|
||||
.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) {
|
||||
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 {
|
||||
const { createdDocument } = await this.workspace.database
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const { createdDocument, createdNodeReferences } =
|
||||
await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const createdDocument = await trx
|
||||
.insertInto('documents')
|
||||
.returningAll()
|
||||
@@ -674,7 +840,15 @@ export class DocumentService {
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
return { createdDocument };
|
||||
const { createdNodeReferences } = await applyMentionUpdates(
|
||||
trx,
|
||||
data.documentId,
|
||||
this.workspace.userId,
|
||||
data.createdAt,
|
||||
mentionChanges
|
||||
);
|
||||
|
||||
return { createdDocument, createdNodeReferences };
|
||||
});
|
||||
|
||||
if (!createdDocument) {
|
||||
@@ -687,6 +861,15 @@ export class DocumentService {
|
||||
workspaceId: this.workspace.id,
|
||||
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;
|
||||
|
||||
@@ -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')
|
||||
.returningAll()
|
||||
.values({
|
||||
@@ -56,15 +56,22 @@ export class NodeInteractionService {
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!createdNodeInteraction) {
|
||||
if (!upsertedNodeInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (upsertedNodeInteraction.collaborator_id === this.workspace.userId) {
|
||||
await this.workspace.nodeCounters.checkCountersForUpdatedNodeInteraction(
|
||||
upsertedNodeInteraction,
|
||||
existingNodeInteraction
|
||||
);
|
||||
}
|
||||
|
||||
eventBus.publish({
|
||||
type: 'node_interaction_updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
nodeInteraction: mapNodeInteraction(createdNodeInteraction),
|
||||
nodeInteraction: mapNodeInteraction(upsertedNodeInteraction),
|
||||
});
|
||||
|
||||
this.debug(
|
||||
|
||||
@@ -16,10 +16,18 @@ import {
|
||||
import { decodeState, encodeState, YDoc } from '@colanode/crdt';
|
||||
|
||||
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 { 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;
|
||||
|
||||
@@ -77,11 +85,21 @@ export class NodeService {
|
||||
const updateId = generateId(IdType.Update);
|
||||
const createdAt = new Date().toISOString();
|
||||
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
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const { createdNode, createdMutation, createdNodeReferences } =
|
||||
await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const createdNode = await trx
|
||||
.insertInto('nodes')
|
||||
.returningAll()
|
||||
@@ -149,9 +167,19 @@ export class NodeService {
|
||||
.execute();
|
||||
}
|
||||
|
||||
let createdNodeReferences: SelectNodeReference[] = [];
|
||||
if (nodeReferencesToCreate.length > 0) {
|
||||
createdNodeReferences = await trx
|
||||
.insertInto('node_references')
|
||||
.values(nodeReferencesToCreate)
|
||||
.returningAll()
|
||||
.execute();
|
||||
}
|
||||
|
||||
return {
|
||||
createdNode,
|
||||
createdMutation,
|
||||
createdNodeReferences,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -159,6 +187,10 @@ export class NodeService {
|
||||
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}`);
|
||||
|
||||
eventBus.publish({
|
||||
@@ -168,8 +200,13 @@ export class NodeService {
|
||||
node: mapNode(createdNode),
|
||||
});
|
||||
|
||||
if (!createdMutation) {
|
||||
throw new Error('Failed to create mutation');
|
||||
for (const createdNodeReference of createdNodeReferences) {
|
||||
eventBus.publish({
|
||||
type: 'node_reference_created',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
nodeReference: mapNodeReference(createdNodeReference),
|
||||
});
|
||||
}
|
||||
|
||||
this.workspace.mutations.triggerSync();
|
||||
@@ -248,82 +285,100 @@ export class NodeService {
|
||||
|
||||
const attributes = ydoc.getObject<NodeAttributes>();
|
||||
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
|
||||
.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 beforeMentions = model.extractMentions(nodeId, node.attributes);
|
||||
const afterMentions = model.extractMentions(nodeId, attributes);
|
||||
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
|
||||
|
||||
if (!updatedNode) {
|
||||
throw new Error('Failed to update node');
|
||||
}
|
||||
const {
|
||||
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
|
||||
.insertInto('node_updates')
|
||||
.returningAll()
|
||||
if (!updatedNode) {
|
||||
throw new Error('Failed to update node');
|
||||
}
|
||||
|
||||
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({
|
||||
id: updateId,
|
||||
node_id: nodeId,
|
||||
data: update,
|
||||
created_at: updatedAt,
|
||||
id: nodeId,
|
||||
name: nodeText.name,
|
||||
attributes: nodeText.attributes,
|
||||
})
|
||||
.executeTakeFirst();
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (!createdUpdate) {
|
||||
throw new Error('Failed to create update');
|
||||
}
|
||||
const { createdNodeReferences, deletedNodeReferences } =
|
||||
await applyMentionUpdates(
|
||||
trx,
|
||||
nodeId,
|
||||
this.workspace.userId,
|
||||
updatedAt,
|
||||
mentionChanges
|
||||
);
|
||||
|
||||
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({
|
||||
id: nodeId,
|
||||
name: nodeText.name,
|
||||
attributes: nodeText.attributes,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
return {
|
||||
updatedNode,
|
||||
createdMutation,
|
||||
};
|
||||
});
|
||||
return {
|
||||
updatedNode,
|
||||
createdMutation,
|
||||
createdNodeReferences,
|
||||
deletedNodeReferences,
|
||||
};
|
||||
});
|
||||
|
||||
if (updatedNode) {
|
||||
this.debug(
|
||||
@@ -344,6 +399,24 @@ export class NodeService {
|
||||
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) {
|
||||
return 'success';
|
||||
}
|
||||
@@ -472,9 +545,18 @@ export class NodeService {
|
||||
const attributes = ydoc.getObject<NodeAttributes>();
|
||||
|
||||
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()
|
||||
.execute(async (trx) => {
|
||||
const createdNode = await trx
|
||||
@@ -516,7 +598,16 @@ export class NodeService {
|
||||
.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) {
|
||||
@@ -533,6 +624,20 @@ export class NodeService {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -571,14 +676,21 @@ export class NodeService {
|
||||
const localRevision = BigInt(existingNode.local_revision) + BigInt(1);
|
||||
|
||||
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 updatesToDelete = [update.id, ...mergedUpdateIds];
|
||||
|
||||
const { updatedNode } = await this.workspace.database
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const { updatedNode, createdNodeReferences, deletedNodeReferences } =
|
||||
await this.workspace.database.transaction().execute(async (trx) => {
|
||||
const updatedNode = await trx
|
||||
.updateTable('nodes')
|
||||
.returningAll()
|
||||
@@ -637,7 +749,16 @@ export class NodeService {
|
||||
.execute();
|
||||
}
|
||||
|
||||
return { updatedNode };
|
||||
const { createdNodeReferences, deletedNodeReferences } =
|
||||
await applyMentionUpdates(
|
||||
trx,
|
||||
existingNode.id,
|
||||
update.createdBy,
|
||||
update.createdAt,
|
||||
mentionChanges
|
||||
);
|
||||
|
||||
return { updatedNode, createdNodeReferences, deletedNodeReferences };
|
||||
});
|
||||
|
||||
if (!updatedNode) {
|
||||
@@ -654,6 +775,24 @@ export class NodeService {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -691,6 +830,11 @@ export class NodeService {
|
||||
.where('node_id', '=', tombstone.id)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom('node_references')
|
||||
.where('node_id', '=', tombstone.id)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom('tombstones')
|
||||
.where('id', '=', tombstone.id)
|
||||
@@ -714,14 +858,18 @@ export class NodeService {
|
||||
return { deletedNode };
|
||||
});
|
||||
|
||||
if (deletedNode) {
|
||||
eventBus.publish({
|
||||
type: 'node_deleted',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
node: mapNode(deletedNode),
|
||||
});
|
||||
if (!deletedNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -758,6 +906,11 @@ export class NodeService {
|
||||
.where('id', '=', mutation.nodeId)
|
||||
.execute();
|
||||
|
||||
await tx
|
||||
.deleteFrom('node_references')
|
||||
.where('node_id', '=', mutation.nodeId)
|
||||
.execute();
|
||||
|
||||
await tx
|
||||
.deleteFrom('documents')
|
||||
.where('id', '=', mutation.nodeId)
|
||||
@@ -822,6 +975,11 @@ export class NodeService {
|
||||
.where('id', '=', mutation.nodeId)
|
||||
.execute();
|
||||
|
||||
await this.workspace.database
|
||||
.deleteFrom('node_references')
|
||||
.where('node_id', '=', mutation.nodeId)
|
||||
.execute();
|
||||
|
||||
await this.workspace.database
|
||||
.deleteFrom('documents')
|
||||
.where('id', '=', mutation.nodeId)
|
||||
@@ -871,12 +1029,16 @@ export class NodeService {
|
||||
|
||||
const attributes = ydoc.getObject<NodeAttributes>();
|
||||
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 updatedNode = await this.workspace.database
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const beforeAttributes = JSON.parse(node.attributes);
|
||||
const beforeMentions = model.extractMentions(node.id, beforeAttributes);
|
||||
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
|
||||
.updateTable('nodes')
|
||||
.returningAll()
|
||||
@@ -889,7 +1051,7 @@ export class NodeService {
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!updatedNode) {
|
||||
return undefined;
|
||||
throw new Error('Failed to update node');
|
||||
}
|
||||
|
||||
await trx
|
||||
@@ -907,6 +1069,17 @@ export class NodeService {
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
const { createdNodeReferences, deletedNodeReferences } =
|
||||
await applyMentionUpdates(
|
||||
trx,
|
||||
node.id,
|
||||
this.workspace.userId,
|
||||
mutation.createdAt,
|
||||
mentionChanges
|
||||
);
|
||||
|
||||
return { updatedNode, createdNodeReferences, deletedNodeReferences };
|
||||
});
|
||||
|
||||
if (updatedNode) {
|
||||
@@ -917,6 +1090,24 @@ export class NodeService {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
import { getIdType, IdType } from '@colanode/core';
|
||||
|
||||
import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
|
||||
import { SelectCollaboration } from '@/main/databases/workspace';
|
||||
import { WorkspaceRadarData } from '@/shared/types/radars';
|
||||
import {
|
||||
ChannelReadState,
|
||||
ChatReadState,
|
||||
WorkspaceRadarData,
|
||||
} from '@/shared/types/radars';
|
||||
import {
|
||||
CollaborationCreatedEvent,
|
||||
CollaborationDeletedEvent,
|
||||
NodeCreatedEvent,
|
||||
NodeDeletedEvent,
|
||||
NodeInteractionUpdatedEvent,
|
||||
Event,
|
||||
NodeCounterUpdatedEvent,
|
||||
NodeCounterDeletedEvent,
|
||||
} from '@/shared/types/events';
|
||||
import { eventBus } from '@/shared/lib/event-bus';
|
||||
|
||||
interface UndreadMessage {
|
||||
messageId: string;
|
||||
parentId: string;
|
||||
parentIdType: IdType;
|
||||
}
|
||||
import { NodeCounterType } from '@/shared/types/nodes';
|
||||
|
||||
export class RadarService {
|
||||
private readonly workspace: WorkspaceService;
|
||||
private readonly unreadMessages: Map<string, UndreadMessage> = new Map();
|
||||
private readonly collaborations: Map<string, SelectCollaboration> = new Map();
|
||||
private readonly counters: Map<string, Map<NodeCounterType, number>>;
|
||||
private readonly eventSubscriptionId: string;
|
||||
|
||||
constructor(workspace: WorkspaceService) {
|
||||
this.workspace = workspace;
|
||||
this.counters = new Map();
|
||||
this.eventSubscriptionId = eventBus.subscribe(this.handleEvent.bind(this));
|
||||
}
|
||||
|
||||
@@ -39,93 +24,62 @@ export class RadarService {
|
||||
accountId: this.workspace.accountId,
|
||||
userId: this.workspace.userId,
|
||||
workspaceId: this.workspace.id,
|
||||
importantCount: 0,
|
||||
hasUnseenChanges: false,
|
||||
state: {
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
},
|
||||
nodeStates: {},
|
||||
};
|
||||
|
||||
for (const unreadMessage of this.unreadMessages.values()) {
|
||||
if (unreadMessage.parentIdType === IdType.Channel) {
|
||||
let nodeState = data.nodeStates[
|
||||
unreadMessage.parentId
|
||||
] as ChannelReadState;
|
||||
if (!nodeState) {
|
||||
nodeState = {
|
||||
type: 'channel',
|
||||
channelId: unreadMessage.parentId,
|
||||
unseenMessagesCount: 0,
|
||||
mentionsCount: 0,
|
||||
};
|
||||
data.nodeStates[unreadMessage.parentId] = nodeState;
|
||||
data.hasUnseenChanges = true;
|
||||
for (const [nodeId, counters] of this.counters.entries()) {
|
||||
let hasUnread = false;
|
||||
let unreadCount = 0;
|
||||
|
||||
for (const [type, count] of counters.entries()) {
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodeState.unseenMessagesCount++;
|
||||
} else if (unreadMessage.parentIdType === IdType.Chat) {
|
||||
let nodeState = data.nodeStates[
|
||||
unreadMessage.parentId
|
||||
] as ChatReadState;
|
||||
if (!nodeState) {
|
||||
nodeState = {
|
||||
type: 'chat',
|
||||
chatId: unreadMessage.parentId,
|
||||
unseenMessagesCount: 0,
|
||||
mentionsCount: 0,
|
||||
};
|
||||
data.nodeStates[unreadMessage.parentId] = nodeState;
|
||||
if (type === 'unread_messages') {
|
||||
hasUnread = true;
|
||||
} else if (type === 'unread_important_messages') {
|
||||
hasUnread = true;
|
||||
unreadCount += count;
|
||||
} else if (type === 'unread_mentions') {
|
||||
hasUnread = true;
|
||||
unreadCount += count;
|
||||
}
|
||||
|
||||
nodeState.unseenMessagesCount++;
|
||||
data.importantCount++;
|
||||
}
|
||||
|
||||
data.nodeStates[nodeId] = {
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
};
|
||||
|
||||
data.state.hasUnread = data.state.hasUnread || hasUnread;
|
||||
data.state.unreadCount += unreadCount;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const collaborations = await this.workspace.database
|
||||
.selectFrom('collaborations')
|
||||
const nodeCounters = await this.workspace.database
|
||||
.selectFrom('node_counters')
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
for (const collaboration of collaborations) {
|
||||
this.collaborations.set(collaboration.node_id, collaboration);
|
||||
}
|
||||
for (const nodeCounter of nodeCounters) {
|
||||
if (!this.counters.has(nodeCounter.node_id)) {
|
||||
this.counters.set(nodeCounter.node_id, new Map());
|
||||
}
|
||||
|
||||
if (this.collaborations.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
const counter = this.counters.get(nodeCounter.node_id);
|
||||
if (!counter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.unreadMessages.set(unreadMessageRow.node_id, {
|
||||
messageId: unreadMessageRow.node_id,
|
||||
parentId: unreadMessageRow.parent_id,
|
||||
parentIdType: getIdType(unreadMessageRow.parent_id),
|
||||
});
|
||||
counter.set(nodeCounter.type, nodeCounter.count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,123 +88,61 @@ export class RadarService {
|
||||
}
|
||||
|
||||
private async handleEvent(event: Event) {
|
||||
if (event.type === 'node_interaction_updated') {
|
||||
await this.handleNodeInteractionUpdated(event);
|
||||
} else if (event.type === 'node_created') {
|
||||
await this.handleNodeCreated(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);
|
||||
if (event.type === 'node_counter_updated') {
|
||||
this.handleNodeCounterUpdated(event);
|
||||
} else if (event.type === 'node_counter_deleted') {
|
||||
this.handleNodeCounterDeleted(event);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNodeInteractionUpdated(
|
||||
event: NodeInteractionUpdatedEvent
|
||||
): Promise<void> {
|
||||
const interaction = event.nodeInteraction;
|
||||
private handleNodeCounterUpdated(event: NodeCounterUpdatedEvent) {
|
||||
if (
|
||||
event.accountId !== this.workspace.accountId ||
|
||||
event.workspaceId !== this.workspace.id ||
|
||||
interaction.collaboratorId !== this.workspace.userId
|
||||
event.workspaceId !== this.workspace.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.lastSeenAt) {
|
||||
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') {
|
||||
if (!this.counters.has(event.counter.nodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.node;
|
||||
if (message.createdBy === this.workspace.userId) {
|
||||
const nodeCounters = this.counters.get(event.counter.nodeId);
|
||||
if (!nodeCounters) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unreadMessages.has(message.id)) {
|
||||
const count = nodeCounters.get(event.counter.type);
|
||||
if (count === event.counter.count) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collaboration = this.collaborations.get(message.rootId);
|
||||
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),
|
||||
});
|
||||
|
||||
nodeCounters.set(event.counter.type, event.counter.count);
|
||||
eventBus.publish({
|
||||
type: 'radar_data_updated',
|
||||
});
|
||||
}
|
||||
|
||||
private async handleNodeDeleted(event: NodeDeletedEvent): Promise<void> {
|
||||
const message = event.node;
|
||||
if (message.createdBy === this.workspace.userId) {
|
||||
private handleNodeCounterDeleted(event: NodeCounterDeletedEvent) {
|
||||
if (
|
||||
event.accountId !== this.workspace.accountId ||
|
||||
event.workspaceId !== this.workspace.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.unreadMessages.has(message.id)) {
|
||||
const nodeCounter = this.counters.get(event.counter.nodeId);
|
||||
if (!nodeCounter) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unreadMessages.delete(message.id);
|
||||
if (!nodeCounter.has(event.counter.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodeCounter.delete(event.counter.type);
|
||||
eventBus.publish({
|
||||
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();
|
||||
}
|
||||
|
||||
const collaborations = await this.workspace.database
|
||||
.selectFrom('collaborations')
|
||||
.selectAll()
|
||||
.execute();
|
||||
const collaborations =
|
||||
this.workspace.collaborations.getActiveCollaborations();
|
||||
|
||||
for (const collaboration of collaborations) {
|
||||
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 { RadarService } from '@/main/services/workspaces/radar-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';
|
||||
|
||||
export class WorkspaceService {
|
||||
@@ -40,6 +41,7 @@ export class WorkspaceService {
|
||||
public readonly collaborations: CollaborationService;
|
||||
public readonly synchronizer: SyncService;
|
||||
public readonly radar: RadarService;
|
||||
public readonly nodeCounters: NodeCountersService;
|
||||
|
||||
constructor(workspace: Workspace, account: AccountService) {
|
||||
this.debug(`Initializing workspace service ${workspace.id}`);
|
||||
@@ -76,6 +78,7 @@ export class WorkspaceService {
|
||||
this.collaborations = new CollaborationService(this);
|
||||
this.synchronizer = new SyncService(this);
|
||||
this.radar = new RadarService(this);
|
||||
this.nodeCounters = new NodeCountersService(this);
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
@@ -111,6 +114,7 @@ export class WorkspaceService {
|
||||
|
||||
public async init() {
|
||||
await this.migrate();
|
||||
await this.collaborations.init();
|
||||
await this.synchronizer.init();
|
||||
await this.radar.init();
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const AvatarFallback = ({ id, name, size, className }: AvatarProps) => {
|
||||
if (name) {
|
||||
const color = getColorForId(id);
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center overflow-hidden rounded text-white shadow',
|
||||
getAvatarSizeClasses(size),
|
||||
@@ -62,7 +62,7 @@ const AvatarFallback = ({ id, name, size, className }: AvatarProps) => {
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<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 { useQuery } from '@/renderer/hooks/use-query';
|
||||
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';
|
||||
|
||||
interface ChannelContainerTabProps {
|
||||
@@ -38,13 +38,11 @@ export const ChannelContainerTab = ({
|
||||
? channel.attributes.name
|
||||
: 'Unnamed';
|
||||
|
||||
const channelState = radar.getChannelState(
|
||||
const unreadState = radar.getNodeState(
|
||||
workspace.accountId,
|
||||
workspace.id,
|
||||
channel.id
|
||||
);
|
||||
const unreadCount = channelState.unseenMessagesCount;
|
||||
const mentionsCount = channelState.mentionsCount;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -56,7 +54,10 @@ export const ChannelContainerTab = ({
|
||||
/>
|
||||
<span>{name}</span>
|
||||
{!isActive && (
|
||||
<NotificationBadge count={mentionsCount} unseen={unreadCount > 0} />
|
||||
<UnreadBadge
|
||||
count={unreadState.unreadCount}
|
||||
unread={unreadState.hasUnread}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
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 { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { useLayout } from '@/renderer/contexts/layout';
|
||||
@@ -18,13 +18,11 @@ export const ChannelSidebarItem = ({ channel }: ChannelSidebarItemProps) => {
|
||||
const radar = useRadar();
|
||||
|
||||
const isActive = layout.activeTab === channel.id;
|
||||
const channelState = radar.getChannelState(
|
||||
const unreadState = radar.getNodeState(
|
||||
workspace.accountId,
|
||||
workspace.id,
|
||||
channel.id
|
||||
);
|
||||
const unreadCount = channelState.unseenMessagesCount;
|
||||
const mentionsCount = channelState.mentionsCount;
|
||||
|
||||
return (
|
||||
<InView
|
||||
@@ -48,13 +46,16 @@ export const ChannelSidebarItem = ({ channel }: ChannelSidebarItemProps) => {
|
||||
<span
|
||||
className={cn(
|
||||
'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'}
|
||||
</span>
|
||||
{!isActive && (
|
||||
<NotificationBadge count={mentionsCount} unseen={unreadCount > 0} />
|
||||
<UnreadBadge
|
||||
count={unreadState.unreadCount}
|
||||
unread={unreadState.hasUnread}
|
||||
/>
|
||||
)}
|
||||
</InView>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Avatar } from '@/renderer/components/avatars/avatar';
|
||||
import { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { useQuery } from '@/renderer/hooks/use-query';
|
||||
import { useRadar } from '@/renderer/contexts/radar';
|
||||
import { NotificationBadge } from '@/renderer/components/ui/notification-badge';
|
||||
import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
|
||||
|
||||
interface ChatContainerTabProps {
|
||||
chatId: string;
|
||||
@@ -45,20 +45,21 @@ export const ChatContainerTab = ({
|
||||
return <p className="text-sm text-muted-foreground">Not found</p>;
|
||||
}
|
||||
|
||||
const chatState = radar.getChatState(
|
||||
const unreadState = radar.getNodeState(
|
||||
workspace.accountId,
|
||||
workspace.id,
|
||||
chat.id
|
||||
);
|
||||
const unreadCount = chatState.unseenMessagesCount;
|
||||
const mentionsCount = chatState.mentionsCount;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar size="small" id={user.id} name={user.name} avatar={user.avatar} />
|
||||
<span>{user.name}</span>
|
||||
{!isActive && (
|
||||
<NotificationBadge count={mentionsCount} unseen={unreadCount > 0} />
|
||||
<UnreadBadge
|
||||
count={unreadState.unreadCount}
|
||||
unread={unreadState.hasUnread}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { LocalChatNode } from '@/shared/types/nodes';
|
||||
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 { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { useQuery } from '@/renderer/hooks/use-query';
|
||||
@@ -34,14 +34,12 @@ export const ChatSidebarItem = ({ chat }: ChatSidebarItemProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeReadState = radar.getChatState(
|
||||
const unreadState = radar.getNodeState(
|
||||
workspace.accountId,
|
||||
workspace.id,
|
||||
chat.id
|
||||
);
|
||||
const isActive = layout.activeTab === chat.id;
|
||||
const unreadCount =
|
||||
nodeReadState.unseenMessagesCount + nodeReadState.mentionsCount;
|
||||
|
||||
return (
|
||||
<InView
|
||||
@@ -65,13 +63,16 @@ export const ChatSidebarItem = ({ chat }: ChatSidebarItemProps) => {
|
||||
<span
|
||||
className={cn(
|
||||
'line-clamp-1 w-full flex-grow pl-2 text-left',
|
||||
!isActive && unreadCount > 0 && 'font-semibold'
|
||||
!isActive && unreadState.hasUnread && 'font-semibold'
|
||||
)}
|
||||
>
|
||||
{data.name ?? 'Unnamed'}
|
||||
</span>
|
||||
{!isActive && (
|
||||
<NotificationBadge count={unreadCount} unseen={unreadCount > 0} />
|
||||
<UnreadBadge
|
||||
count={unreadState.unreadCount}
|
||||
unread={unreadState.hasUnread}
|
||||
/>
|
||||
)}
|
||||
</InView>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
|
||||
import { useApp } from '@/renderer/contexts/app';
|
||||
import { Avatar } from '@/renderer/components/avatars/avatar';
|
||||
import { NotificationBadge } from '@/renderer/components/ui/notification-badge';
|
||||
import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { AccountContext, useAccount } from '@/renderer/contexts/account';
|
||||
import { useRadar } from '@/renderer/contexts/radar';
|
||||
import { useQuery } from '@/renderer/hooks/use-query';
|
||||
import { AccountReadState } from '@/shared/types/radars';
|
||||
import { UnreadState } from '@/shared/types/radars';
|
||||
|
||||
export function SidebarMenuFooter() {
|
||||
const app = useApp();
|
||||
@@ -29,16 +29,18 @@ export function SidebarMenuFooter() {
|
||||
|
||||
const accounts = data ?? [];
|
||||
const otherAccounts = accounts.filter((a) => a.id !== account.id);
|
||||
const accountStates: Record<string, AccountReadState> = {};
|
||||
const accountUnreadStates: Record<string, UnreadState> = {};
|
||||
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,
|
||||
0
|
||||
|
||||
const hasUnread = Object.values(accountUnreadStates).some(
|
||||
(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 (
|
||||
@@ -51,9 +53,9 @@ export function SidebarMenuFooter() {
|
||||
avatar={account.avatar}
|
||||
className="size-10 rounded-lg shadow-md"
|
||||
/>
|
||||
<NotificationBadge
|
||||
count={importantCount}
|
||||
unseen={hasUnseenChanges}
|
||||
<UnreadBadge
|
||||
count={unreadCount}
|
||||
unread={hasUnread}
|
||||
className="absolute -top-1 right-0"
|
||||
/>
|
||||
</button>
|
||||
@@ -102,9 +104,9 @@ export function SidebarMenuFooter() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="mb-1">Accounts</DropdownMenuLabel>
|
||||
{accounts.map((accountItem) => {
|
||||
const state = accountStates[accountItem.id] ?? {
|
||||
importantCount: 0,
|
||||
hasUnseenChanges: false,
|
||||
const state = accountUnreadStates[accountItem.id] ?? {
|
||||
unreadCount: 0,
|
||||
hasUnread: false,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -142,9 +144,9 @@ export function SidebarMenuFooter() {
|
||||
{accountItem.id === account.id ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<NotificationBadge
|
||||
count={state.importantCount}
|
||||
unseen={state.hasUnseenChanges}
|
||||
<UnreadBadge
|
||||
count={state.unreadCount}
|
||||
unread={state.hasUnread}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Bell, Check, Plus, Settings } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { Avatar } from '@/renderer/components/avatars/avatar';
|
||||
import { NotificationBadge } from '@/renderer/components/ui/notification-badge';
|
||||
import { UnreadBadge } from '@/renderer/components/ui/unread-badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -32,11 +32,11 @@ export const SidebarMenuHeader = () => {
|
||||
const otherWorkspaceStates = otherWorkspaces.map((w) =>
|
||||
radar.getWorkspaceState(w.accountId, w.id)
|
||||
);
|
||||
const importantCount = otherWorkspaceStates.reduce(
|
||||
(acc, curr) => acc + curr.importantCount,
|
||||
const unreadCount = otherWorkspaceStates.reduce(
|
||||
(acc, curr) => acc + curr.state.unreadCount,
|
||||
0
|
||||
);
|
||||
const hasUnseenChanges = otherWorkspaceStates.some((w) => w.hasUnseenChanges);
|
||||
const hasUnread = otherWorkspaceStates.some((w) => w.state.hasUnread);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -48,9 +48,9 @@ export const SidebarMenuHeader = () => {
|
||||
name={workspace.name}
|
||||
className="size-10 rounded-lg shadow-md"
|
||||
/>
|
||||
<NotificationBadge
|
||||
count={importantCount}
|
||||
unseen={hasUnseenChanges}
|
||||
<UnreadBadge
|
||||
count={unreadCount}
|
||||
unread={hasUnread}
|
||||
className="absolute -top-1 right-0"
|
||||
/>
|
||||
</button>
|
||||
@@ -93,7 +93,7 @@ export const SidebarMenuHeader = () => {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel>
|
||||
{workspaces.map((workspaceItem) => {
|
||||
const workspaceState = radar.getWorkspaceState(
|
||||
const workspaceUnreadState = radar.getWorkspaceState(
|
||||
workspaceItem.accountId,
|
||||
workspaceItem.id
|
||||
);
|
||||
@@ -118,9 +118,9 @@ export const SidebarMenuHeader = () => {
|
||||
{workspaceItem.id === workspace.id ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<NotificationBadge
|
||||
count={workspaceState.importantCount}
|
||||
unseen={workspaceState.hasUnseenChanges}
|
||||
<UnreadBadge
|
||||
count={workspaceUnreadState.state.unreadCount}
|
||||
unread={workspaceUnreadState.state.hasUnread}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -181,6 +181,7 @@ export const MessageCreate = React.forwardRef<MessageCreateRefProps>(
|
||||
workspaceId={workspace.id}
|
||||
ref={messageEditorRef}
|
||||
conversationId={conversation.id}
|
||||
rootId={conversation.rootId}
|
||||
onChange={setContent}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TextNode,
|
||||
TrailingNode,
|
||||
UnderlineMark,
|
||||
MentionExtension,
|
||||
} from '@/renderer/editor/extensions';
|
||||
import { ToolbarMenu } from '@/renderer/editor/menus';
|
||||
import { FileMetadata } from '@/shared/types/files';
|
||||
@@ -32,6 +33,7 @@ interface MessageEditorProps {
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
conversationId: string;
|
||||
rootId: string;
|
||||
onChange?: (content: JSONContent) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
@@ -74,6 +76,14 @@ export const MessageEditor = React.forwardRef<
|
||||
workspaceId: props.workspaceId,
|
||||
}),
|
||||
FileNode,
|
||||
MentionExtension.configure({
|
||||
context: {
|
||||
accountId: props.accountId,
|
||||
workspaceId: props.workspaceId,
|
||||
documentId: props.conversationId,
|
||||
rootId: props.rootId,
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
interface UnreadBadgeProps {
|
||||
count: number;
|
||||
unseen: boolean;
|
||||
unread: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NotificationBadge = ({
|
||||
count,
|
||||
unseen,
|
||||
className,
|
||||
}: NotificationBadgeProps) => {
|
||||
if (count === 0 && !unseen) {
|
||||
export const UnreadBadge = ({ count, unread, className }: UnreadBadgeProps) => {
|
||||
if (count === 0 && !unread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import {
|
||||
AccountReadState,
|
||||
ChannelReadState,
|
||||
ChatReadState,
|
||||
WorkspaceReadState,
|
||||
} from '@/shared/types/radars';
|
||||
import { WorkspaceRadarData, UnreadState } from '@/shared/types/radars';
|
||||
|
||||
interface RadarContext {
|
||||
getAccountState: (accountId: string) => AccountReadState;
|
||||
getAccountState: (accountId: string) => UnreadState;
|
||||
getWorkspaceState: (
|
||||
accountId: string,
|
||||
workspaceId: string
|
||||
) => WorkspaceReadState;
|
||||
getChatState: (
|
||||
) => WorkspaceRadarData;
|
||||
getNodeState: (
|
||||
accountId: string,
|
||||
workspaceId: string,
|
||||
chatId: string
|
||||
) => ChatReadState;
|
||||
getChannelState: (
|
||||
accountId: string,
|
||||
workspaceId: string,
|
||||
channelId: string
|
||||
) => ChannelReadState;
|
||||
nodeId: string
|
||||
) => UnreadState;
|
||||
markNodeAsSeen: (
|
||||
accountId: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -22,5 +22,6 @@ export const defaultClasses = {
|
||||
gif: 'max-h-72 my-1',
|
||||
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',
|
||||
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 { DatabaseNode } from '@/renderer/editor/extensions/database';
|
||||
import { AutoJoiner } from '@/renderer/editor/extensions/auto-joiner';
|
||||
import { MentionExtension } from '@/renderer/editor/extensions/mention';
|
||||
|
||||
export {
|
||||
BlockquoteNode,
|
||||
@@ -75,4 +76,5 @@ export {
|
||||
UnderlineMark,
|
||||
DatabaseNode,
|
||||
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 { TaskListRenderer } from '@/renderer/editor/renderers/task-list';
|
||||
import { TextRenderer } from '@/renderer/editor/renderers/text';
|
||||
import { MentionRenderer } from '@/renderer/editor/renderers/mention';
|
||||
|
||||
interface NodeRendererProps {
|
||||
node: JSONContent;
|
||||
@@ -72,6 +73,9 @@ export const NodeRenderer = ({
|
||||
<CodeBlockRenderer node={node} keyPrefix={keyPrefix} />
|
||||
))
|
||||
.with('file', () => <FileRenderer node={node} keyPrefix={keyPrefix} />)
|
||||
.with('mention', () => (
|
||||
<MentionRenderer node={node} keyPrefix={keyPrefix} />
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</MarkRenderer>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FilePlaceholderNodeView } from '@/renderer/editor/views/file-placeholde
|
||||
import { FolderNodeView } from '@/renderer/editor/views/folder';
|
||||
import { PageNodeView } from '@/renderer/editor/views/page';
|
||||
import { DatabaseNodeView } from '@/renderer/editor/views/database';
|
||||
import { MentionNodeView } from '@/renderer/editor/views/mention';
|
||||
|
||||
export {
|
||||
CodeBlockNodeView,
|
||||
@@ -11,5 +12,6 @@ export {
|
||||
FileNodeView,
|
||||
FilePlaceholderNodeView,
|
||||
FolderNodeView,
|
||||
MentionNodeView,
|
||||
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];
|
||||
if (!accountState) {
|
||||
return {
|
||||
importantCount: 0,
|
||||
hasUnseenChanges: false,
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const importantCount = Object.values(accountState).reduce(
|
||||
(acc, state) => acc + state.importantCount,
|
||||
const hasUnread = Object.values(accountState).some(
|
||||
(state) => state.state.hasUnread
|
||||
);
|
||||
|
||||
const unreadCount = Object.values(accountState).reduce(
|
||||
(acc, state) => acc + state.state.unreadCount,
|
||||
0
|
||||
);
|
||||
|
||||
const hasUnseenChanges = Object.values(accountState).some(
|
||||
(state) => state.hasUnseenChanges
|
||||
);
|
||||
|
||||
return {
|
||||
importantCount,
|
||||
hasUnseenChanges,
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
};
|
||||
},
|
||||
getWorkspaceState: (accountId, workspaceId) => {
|
||||
const workspaceState = radarData[accountId]?.[workspaceId];
|
||||
if (workspaceState) {
|
||||
return {
|
||||
hasUnseenChanges: workspaceState.hasUnseenChanges,
|
||||
importantCount: workspaceState.importantCount,
|
||||
};
|
||||
return workspaceState;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: '',
|
||||
workspaceId: workspaceId,
|
||||
accountId: accountId,
|
||||
state: {
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
},
|
||||
nodeStates: {},
|
||||
importantCount: 0,
|
||||
hasUnseenChanges: false,
|
||||
};
|
||||
},
|
||||
getChatState: (accountId, workspaceId, chatId) => {
|
||||
getNodeState: (accountId, workspaceId, nodeId) => {
|
||||
const workspaceState = radarData[accountId]?.[workspaceId];
|
||||
if (workspaceState) {
|
||||
const chatState = workspaceState.nodeStates[chatId];
|
||||
if (chatState && chatState.type === 'chat') {
|
||||
return chatState;
|
||||
const nodeState = workspaceState.nodeStates[nodeId];
|
||||
if (nodeState) {
|
||||
return nodeState;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'chat',
|
||||
chatId: chatId,
|
||||
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,
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
};
|
||||
},
|
||||
markNodeAsSeen: (accountId, workspaceId, nodeId) => {
|
||||
|
||||
@@ -108,6 +108,7 @@ const mapContentsToBlockLeafs = (
|
||||
nodeBlocks.push({
|
||||
type: content.type,
|
||||
text: content.text,
|
||||
attrs: content.attrs,
|
||||
marks: content.marks?.map((mark) => {
|
||||
return {
|
||||
type: mark.type,
|
||||
@@ -189,6 +190,7 @@ const mapBlockLeafsToContents = (
|
||||
contents.push({
|
||||
type: leaf.type,
|
||||
...(leaf.text && { text: leaf.text }),
|
||||
...(leaf.attrs && { attrs: leaf.attrs }),
|
||||
...(leaf.marks?.length && {
|
||||
marks: leaf.marks.map((mark) => ({
|
||||
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 { User } from '@/shared/types/users';
|
||||
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 {
|
||||
Document,
|
||||
DocumentState,
|
||||
@@ -241,6 +247,34 @@ export type DocumentUpdateDeletedEvent = {
|
||||
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 =
|
||||
| UserCreatedEvent
|
||||
| UserUpdatedEvent
|
||||
@@ -278,4 +312,8 @@ export type Event =
|
||||
| DocumentDeletedEvent
|
||||
| DocumentStateUpdatedEvent
|
||||
| DocumentUpdateCreatedEvent
|
||||
| DocumentUpdateDeletedEvent;
|
||||
| DocumentUpdateDeletedEvent
|
||||
| NodeReferenceCreatedEvent
|
||||
| NodeReferenceDeletedEvent
|
||||
| NodeCounterUpdatedEvent
|
||||
| NodeCounterDeletedEvent;
|
||||
|
||||
@@ -43,6 +43,24 @@ export type NodeReactionCount = {
|
||||
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 = {
|
||||
localRevision: bigint;
|
||||
serverRevision: bigint;
|
||||
|
||||
@@ -1,64 +1,12 @@
|
||||
export type ChannelReadState = {
|
||||
type: 'channel';
|
||||
channelId: string;
|
||||
unseenMessagesCount: number;
|
||||
mentionsCount: number;
|
||||
export type UnreadState = {
|
||||
hasUnread: boolean;
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
export type ChatReadState = {
|
||||
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 & {
|
||||
export type WorkspaceRadarData = {
|
||||
userId: string;
|
||||
workspaceId: 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;
|
||||
}
|
||||
|
||||
const messageText = messageModel.extractNodeText(
|
||||
const messageText = messageModel.extractText(
|
||||
message.id,
|
||||
message.attributes
|
||||
)?.attributes;
|
||||
|
||||
@@ -51,7 +51,7 @@ export const checkNodeEmbeddingsHandler = async () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeText = nodeModel.extractNodeText(node.id, node.attributes);
|
||||
const nodeText = nodeModel.extractText(node.id, node.attributes);
|
||||
if (!nodeText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const embedNodeHandler = async (input: {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeText = nodeModel.extractNodeText(node.id, node.attributes);
|
||||
const nodeText = nodeModel.extractText(node.id, node.attributes);
|
||||
if (!nodeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ async function fetchChatHistory(state: AssistantChainState) {
|
||||
const isAI = message.created_by === 'colanode_ai';
|
||||
const extracted = (message &&
|
||||
message.attributes &&
|
||||
getNodeModel(message.type)?.extractNodeText(
|
||||
getNodeModel(message.type)?.extractText(
|
||||
message.id,
|
||||
message.attributes
|
||||
)) || { attributes: '' };
|
||||
|
||||
@@ -41,6 +41,7 @@ export const createDocument = async (
|
||||
}
|
||||
|
||||
const content = ydoc.getObject<DocumentContent>();
|
||||
|
||||
const { createdDocument, createdDocumentUpdate } = await database
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
@@ -188,46 +189,6 @@ const tryUpdateDocumentFromMutation = async (
|
||||
const { updatedDocument, createdDocumentUpdate } = await database
|
||||
.transaction()
|
||||
.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
|
||||
.insertInto('document_updates')
|
||||
.returningAll()
|
||||
@@ -247,25 +208,41 @@ const tryUpdateDocumentFromMutation = async (
|
||||
throw new Error('Failed to create document update');
|
||||
}
|
||||
|
||||
const updatedDocument = 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();
|
||||
const updatedDocument = document
|
||||
? 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()
|
||||
: 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) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
return { updatedDocument, createdDocumentUpdate };
|
||||
return {
|
||||
updatedDocument,
|
||||
createdDocumentUpdate,
|
||||
};
|
||||
});
|
||||
|
||||
if (!updatedDocument || !createdDocumentUpdate) {
|
||||
|
||||
@@ -84,7 +84,7 @@ const fetchParentContext = async (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parentText = parentModel.extractNodeText(
|
||||
const parentText = parentModel.extractText(
|
||||
parentNode.id,
|
||||
parentNode.attributes
|
||||
);
|
||||
@@ -100,7 +100,7 @@ const fetchParentContext = async (
|
||||
const path = pathNodes
|
||||
.map((n) => {
|
||||
const model = getNodeModel(n.attributes.type);
|
||||
return model?.extractNodeText(n.id, n.attributes)?.name ?? '';
|
||||
return model?.extractText(n.id, n.attributes)?.name ?? '';
|
||||
})
|
||||
.join(' / ');
|
||||
|
||||
@@ -173,7 +173,7 @@ export const fetchNodeMetadata = async (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nodeText = nodeModel.extractNodeText(node.id, node.attributes);
|
||||
const nodeText = nodeModel.extractText(node.id, node.attributes);
|
||||
if (!nodeText) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export const fetchDocumentMetadata = async (
|
||||
|
||||
const nodeModel = getNodeModel(node.type);
|
||||
if (nodeModel) {
|
||||
const nodeText = nodeModel.extractNodeText(node.id, node.attributes);
|
||||
const nodeText = nodeModel.extractText(node.id, node.attributes);
|
||||
if (nodeText) {
|
||||
baseMetadata.name = nodeText.name;
|
||||
}
|
||||
@@ -354,7 +354,7 @@ export const fetchNodesMetadata = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeText = nodeModel.extractNodeText(node.id, node.attributes);
|
||||
const nodeText = nodeModel.extractText(node.id, node.attributes);
|
||||
if (!nodeText) {
|
||||
continue;
|
||||
}
|
||||
@@ -388,7 +388,7 @@ export const fetchNodesMetadata = async (
|
||||
if (parentNode) {
|
||||
const parentModel = getNodeModel(parentNode.type);
|
||||
if (parentModel) {
|
||||
const parentText = parentModel.extractNodeText(
|
||||
const parentText = parentModel.extractText(
|
||||
parentNode.id,
|
||||
parentNode.attributes
|
||||
);
|
||||
@@ -525,7 +525,7 @@ export const fetchDocumentsMetadata = async (
|
||||
let name: string | undefined;
|
||||
const nodeModel = getNodeModel(node.type);
|
||||
if (nodeModel) {
|
||||
const nodeText = nodeModel.extractNodeText(node.id, node.attributes);
|
||||
const nodeText = nodeModel.extractText(node.id, node.attributes);
|
||||
if (nodeText) {
|
||||
name = nodeText.name ?? '';
|
||||
}
|
||||
@@ -553,7 +553,7 @@ export const fetchDocumentsMetadata = async (
|
||||
if (parentNode) {
|
||||
const parentModel = getNodeModel(parentNode.type);
|
||||
if (parentModel) {
|
||||
const parentText = parentModel.extractNodeText(
|
||||
const parentText = parentModel.extractText(
|
||||
parentNode.id,
|
||||
parentNode.attributes
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cloneDeep } from 'lodash-es';
|
||||
import { database } from '@/data/database';
|
||||
import {
|
||||
CreateCollaboration,
|
||||
SelectCollaboration,
|
||||
SelectNode,
|
||||
SelectNodeUpdate,
|
||||
SelectUser,
|
||||
@@ -174,17 +175,17 @@ export const createNode = async (
|
||||
throw new Error('Failed to create node');
|
||||
}
|
||||
|
||||
let createdCollaborations: SelectCollaboration[] = [];
|
||||
|
||||
if (collaborationsToCreate.length > 0) {
|
||||
const createdCollaborations = await trx
|
||||
createdCollaborations = await trx
|
||||
.insertInto('collaborations')
|
||||
.returningAll()
|
||||
.values(collaborationsToCreate)
|
||||
.execute();
|
||||
|
||||
return { createdNode, createdCollaborations };
|
||||
}
|
||||
|
||||
return { createdNode, createdCollaborations: [] };
|
||||
return { createdNode, createdCollaborations };
|
||||
});
|
||||
|
||||
eventBus.publish({
|
||||
@@ -443,17 +444,17 @@ export const createNodeFromMutation = async (
|
||||
throw new Error('Failed to create node');
|
||||
}
|
||||
|
||||
let createdCollaborations: SelectCollaboration[] = [];
|
||||
|
||||
if (collaborationsToCreate.length > 0) {
|
||||
const createdCollaborations = await trx
|
||||
createdCollaborations = await trx
|
||||
.insertInto('collaborations')
|
||||
.returningAll()
|
||||
.values(collaborationsToCreate)
|
||||
.execute();
|
||||
|
||||
return { createdNode, createdCollaborations };
|
||||
}
|
||||
|
||||
return { createdNode, createdCollaborations: [] };
|
||||
return { createdNode, createdCollaborations };
|
||||
});
|
||||
|
||||
eventBus.publish({
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -83,6 +83,7 @@
|
||||
"@tiptap/extension-underline": "^2.11.7",
|
||||
"@tiptap/react": "^2.11.7",
|
||||
"@tiptap/suggestion": "^2.11.7",
|
||||
"async-lock": "^1.4.1",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"bufferutil": "^4.0.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -124,6 +125,7 @@
|
||||
"@electron-forge/plugin-vite": "^7.8.0",
|
||||
"@electron-forge/publisher-github": "^7.8.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/is-hotkey": "^0.1.10",
|
||||
@@ -6891,6 +6893,13 @@
|
||||
"@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": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
@@ -8150,6 +8159,12 @@
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "0.4.0",
|
||||
"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 './lib/debugger';
|
||||
export * from './types/crdt';
|
||||
export * from './lib/mentions';
|
||||
export * from './types/mentions';
|
||||
|
||||
@@ -37,6 +37,7 @@ export enum IdType {
|
||||
Host = 'ht',
|
||||
Block = 'bl',
|
||||
OtpCode = 'ot',
|
||||
Mention = 'me',
|
||||
}
|
||||
|
||||
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({
|
||||
type: z.string(),
|
||||
text: ZodText.create().nullable().optional(),
|
||||
attrs: z.record(z.any()).nullable().optional(),
|
||||
marks: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -61,7 +61,7 @@ export const channelModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (_: string, attributes: NodeAttributes) => {
|
||||
extractText: (_: string, attributes: NodeAttributes) => {
|
||||
if (attributes.type !== 'channel') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -71,4 +71,7 @@ export const channelModel: NodeModel = {
|
||||
attributes: null,
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,7 +46,10 @@ export const chatModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: () => {
|
||||
extractText: () => {
|
||||
return null;
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z, ZodSchema } from 'zod';
|
||||
|
||||
import { WorkspaceRole } from '../../types/workspaces';
|
||||
import { Mention } from '../../types/mentions';
|
||||
|
||||
import { Node, NodeAttributes } from '.';
|
||||
|
||||
@@ -64,5 +65,6 @@ export interface NodeModel {
|
||||
canUpdateDocument: (context: CanUpdateDocumentContext) => boolean;
|
||||
canDelete: (context: CanDeleteNodeContext) => 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: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (_, attributes) => {
|
||||
extractText: (_, attributes) => {
|
||||
if (attributes.type !== 'database_view') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -144,4 +144,7 @@ export const databaseViewModel: NodeModel = {
|
||||
attributes: null,
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export const databaseModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (_, attributes) => {
|
||||
extractText: (_, attributes) => {
|
||||
if (attributes.type !== 'database') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -71,4 +71,7 @@ export const databaseModel: NodeModel = {
|
||||
attributes: null,
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export const fileModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (_, attributes) => {
|
||||
extractText: (_, attributes) => {
|
||||
if (attributes.type !== 'file') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -91,4 +91,7 @@ export const fileModel: NodeModel = {
|
||||
attributes: null,
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export const folderModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (_, attributes) => {
|
||||
extractText: (_, attributes) => {
|
||||
if (attributes.type !== 'folder') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -69,4 +69,7 @@ export const folderModel: NodeModel = {
|
||||
attributes: null,
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { blockSchema } from '../block';
|
||||
import { extractBlockTexts } from '../../lib/texts';
|
||||
import { extractNodeRole } from '../../lib/nodes';
|
||||
import { hasNodeRole } from '../../lib/permissions';
|
||||
import { extractBlocksMentions } from '../../lib/mentions';
|
||||
|
||||
export const messageAttributesSchema = z.object({
|
||||
type: z.literal('message'),
|
||||
@@ -75,7 +76,7 @@ export const messageModel: NodeModel = {
|
||||
|
||||
return hasNodeRole(role, 'viewer');
|
||||
},
|
||||
extractNodeText: (id, attributes) => {
|
||||
extractText: (id, attributes) => {
|
||||
if (attributes.type !== 'message') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -87,4 +88,11 @@ export const messageModel: NodeModel = {
|
||||
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: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (id, attributes) => {
|
||||
extractText: (id, attributes) => {
|
||||
if (attributes.type !== 'page') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -80,4 +80,7 @@ export const pageModel: NodeModel = {
|
||||
attributes: null,
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ export const recordModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (id, attributes) => {
|
||||
extractText: (id, attributes) => {
|
||||
if (attributes.type !== 'record') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -104,4 +104,7 @@ export const recordModel: NodeModel = {
|
||||
attributes: texts.join('\n'),
|
||||
};
|
||||
},
|
||||
extractMentions: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export const spaceModel: NodeModel = {
|
||||
canReact: () => {
|
||||
return false;
|
||||
},
|
||||
extractNodeText: (_, attributes) => {
|
||||
extractText: (_, attributes) => {
|
||||
if (attributes.type !== 'space') {
|
||||
throw new Error('Invalid node type');
|
||||
}
|
||||
@@ -83,4 +83,7 @@ export const spaceModel: NodeModel = {
|
||||
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