Implement mentions

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

View File

@@ -26,6 +26,7 @@
"@electron-forge/plugin-vite": "^7.8.0",
"@electron-forge/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",

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ import { createMutationsTable } from './00014-create-mutations-table';
import { createTombstonesTable } from './00015-create-tombstones-table';
import { 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,
};

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -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({

View File

@@ -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({

View File

@@ -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('');

View File

@@ -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();

View File

@@ -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,15 +253,26 @@ 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 beforeMentions =
extractBlocksMentions(document.id, beforeContent.blocks) ?? [];
const afterMentions =
extractBlocksMentions(document.id, afterContent.blocks) ?? [];
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
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(content),
content: JSON.stringify(afterContent),
local_revision: localRevision,
server_revision: serverRevision,
updated_at: updatedAt,
@@ -283,10 +332,21 @@ export class DocumentService {
.where('id', '=', document.id)
.executeTakeFirst();
const { createdNodeReferences, deletedNodeReferences } =
await applyMentionUpdates(
trx,
document.id,
this.workspace.userId,
updatedAt,
mentionChanges
);
return {
updatedDocument,
createdMutation,
createdUpdate,
createdNodeReferences,
deletedNodeReferences,
};
});
@@ -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,9 +493,19 @@ 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 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);
const {
updatedDocument,
deletedUpdate,
createdNodeReferences,
deletedNodeReferences,
} = await this.workspace.database.transaction().execute(async (trx) => {
const updatedDocument = await trx
.updateTable('documents')
.returningAll()
@@ -451,7 +539,21 @@ export class DocumentService {
.where('id', '=', document.id)
.executeTakeFirst();
return { updatedDocument, deletedUpdate };
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) {
@@ -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,9 +657,23 @@ 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,
upsertedState,
deletedUpdates,
createdNodeReferences,
deletedNodeReferences,
} = await this.workspace.database.transaction().execute(async (trx) => {
const updatedDocument = await trx
.updateTable('documents')
.returningAll()
@@ -595,7 +729,22 @@ export class DocumentService {
.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) {
@@ -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;

View File

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

View File

@@ -32,7 +32,7 @@ export class NodeInteractionService {
}
}
const createdNodeInteraction = await this.workspace.database
const upsertedNodeInteraction = await this.workspace.database
.insertInto('node_interactions')
.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(

View File

@@ -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,11 +285,18 @@ 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 beforeMentions = model.extractMentions(nodeId, node.attributes);
const afterMentions = model.extractMentions(nodeId, attributes);
const mentionChanges = checkMentionChanges(beforeMentions, afterMentions);
const {
updatedNode,
createdMutation,
createdNodeReferences,
deletedNodeReferences,
} = await this.workspace.database.transaction().execute(async (trx) => {
const updatedNode = await trx
.updateTable('nodes')
.returningAll()
@@ -319,9 +363,20 @@ export class NodeService {
.execute();
}
const { createdNodeReferences, deletedNodeReferences } =
await applyMentionUpdates(
trx,
nodeId,
this.workspace.userId,
updatedAt,
mentionChanges
);
return {
updatedNode,
createdMutation,
createdNodeReferences,
deletedNodeReferences,
};
});
@@ -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,7 +858,12 @@ export class NodeService {
return { deletedNode };
});
if (deletedNode) {
if (!deletedNode) {
return;
}
await this.workspace.nodeCounters.checkCountersForDeletedNode(deletedNode);
eventBus.publish({
type: 'node_deleted',
accountId: this.workspace.accountId,
@@ -722,7 +871,6 @@ export class NodeService {
node: mapNode(deletedNode),
});
}
}
public async revertNodeCreate(mutation: CreateNodeMutationData) {
const node = await this.workspace.database
@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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',
};

View File

@@ -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,
};

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import { ParagraphRenderer } from '@/renderer/editor/renderers/paragraph';
import { TaskItemRenderer } from '@/renderer/editor/renderers/task-item';
import { 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>
);

View File

@@ -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,
};

View File

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

View File

@@ -18,70 +18,54 @@ export const RadarProvider = ({ children }: RadarProviderProps) => {
const accountState = radarData[accountId];
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) => {

View File

@@ -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,

View File

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

View File

@@ -6,7 +6,13 @@ import { Server } from '@/shared/types/servers';
import { Workspace, WorkspaceMetadata } from '@/shared/types/workspaces';
import { 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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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: '' };

View File

@@ -41,6 +41,7 @@ export const createDocument = async (
}
const content = ydoc.getObject<DocumentContent>();
const { createdDocument, createdDocumentUpdate } = await database
.transaction()
.execute(async (trx) => {
@@ -188,15 +189,14 @@ 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,
workspace_id: user.workspace_id,
data: decodeState(mutation.data),
created_at: new Date(mutation.createdAt),
created_by: user.id,
@@ -208,7 +208,8 @@ const tryUpdateDocumentFromMutation = async (
throw new Error('Failed to create document update');
}
const updatedDocument = await trx
const updatedDocument = document
? await trx
.updateTable('documents')
.returningAll()
.set({
@@ -219,35 +220,8 @@ const tryUpdateDocumentFromMutation = async (
})
.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()
.values({
id: mutation.updateId,
document_id: mutation.documentId,
root_id: node.root_id,
workspace_id: user.workspace_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
.executeTakeFirst()
: await trx
.insertInto('documents')
.returningAll()
.values({
@@ -265,7 +239,10 @@ const tryUpdateDocumentFromMutation = async (
throw new Error('Failed to create document');
}
return { updatedDocument, createdDocumentUpdate };
return {
updatedDocument,
createdDocumentUpdate,
};
});
if (!updatedDocument || !createdDocumentUpdate) {

View File

@@ -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
);

View File

@@ -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
View File

@@ -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",

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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 [];
},
};

View File

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

View File

@@ -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[];
}

View File

@@ -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 [];
},
};

View File

@@ -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 [];
},
};

View File

@@ -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 [];
},
};

View File

@@ -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 [];
},
};

View File

@@ -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);
},
};

View File

@@ -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 [];
},
};

View File

@@ -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 [];
},
};

View File

@@ -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 [];
},
};

View File

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