@blocknote/core
Version: 
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
342 lines (286 loc) • 9.35 kB
text/typescript
import { v4 } from "uuid";
import * as Y from "yjs";
import { CommentBody, CommentData, ThreadData } from "../../types.js";
import { ThreadStoreAuth } from "../ThreadStoreAuth.js";
import { YjsThreadStoreBase } from "./YjsThreadStoreBase.js";
import {
  commentToYMap,
  threadToYMap,
  yMapToComment,
  yMapToThread,
} from "./yjsHelpers.js";
/**
 * This is a Yjs-based implementation of the ThreadStore interface.
 *
 * It reads and writes thread / comments information directly to the underlying Yjs Document.
 *
 * @important While this is the easiest to add to your app, there are two challenges:
 * - The user needs to be able to write to the Yjs document to store the information.
 *   So a user without write access to the Yjs document cannot leave any comments.
 * - Even with write access, the operations are not secure. Unless your Yjs server
 *   guards against malicious operations, it's technically possible for one user to make changes to another user's comments, etc.
 *   (even though these options are not visible in the UI, a malicious user can make unauthorized changes to the underlying Yjs document)
 */
export class YjsThreadStore extends YjsThreadStoreBase {
  constructor(
    private readonly userId: string,
    threadsYMap: Y.Map<any>,
    auth: ThreadStoreAuth,
  ) {
    super(threadsYMap, auth);
  }
  private transact = <T, R>(
    fn: (options: T) => R,
  ): ((options: T) => Promise<R>) => {
    return async (options: T) => {
      return this.threadsYMap.doc!.transact(() => {
        return fn(options);
      });
    };
  };
  public createThread = this.transact(
    (options: {
      initialComment: {
        body: CommentBody;
        metadata?: any;
      };
      metadata?: any;
    }) => {
      if (!this.auth.canCreateThread()) {
        throw new Error("Not authorized");
      }
      const date = new Date();
      const comment: CommentData = {
        type: "comment",
        id: v4(),
        userId: this.userId,
        createdAt: date,
        updatedAt: date,
        reactions: [],
        metadata: options.initialComment.metadata,
        body: options.initialComment.body,
      };
      const thread: ThreadData = {
        type: "thread",
        id: v4(),
        createdAt: date,
        updatedAt: date,
        comments: [comment],
        resolved: false,
        metadata: options.metadata,
      };
      this.threadsYMap.set(thread.id, threadToYMap(thread));
      return thread;
    },
  );
  // YjsThreadStore does not support addThreadToDocument
  public addThreadToDocument = undefined;
  public addComment = this.transact(
    (options: {
      comment: {
        body: CommentBody;
        metadata?: any;
      };
      threadId: string;
    }) => {
      const yThread = this.threadsYMap.get(options.threadId);
      if (!yThread) {
        throw new Error("Thread not found");
      }
      if (!this.auth.canAddComment(yMapToThread(yThread))) {
        throw new Error("Not authorized");
      }
      const date = new Date();
      const comment: CommentData = {
        type: "comment",
        id: v4(),
        userId: this.userId,
        createdAt: date,
        updatedAt: date,
        deletedAt: undefined,
        reactions: [],
        metadata: options.comment.metadata,
        body: options.comment.body,
      };
      (yThread.get("comments") as Y.Array<Y.Map<any>>).push([
        commentToYMap(comment),
      ]);
      yThread.set("updatedAt", new Date().getTime());
      return comment;
    },
  );
  public updateComment = this.transact(
    (options: {
      comment: {
        body: CommentBody;
        metadata?: any;
      };
      threadId: string;
      commentId: string;
    }) => {
      const yThread = this.threadsYMap.get(options.threadId);
      if (!yThread) {
        throw new Error("Thread not found");
      }
      const yCommentIndex = yArrayFindIndex(
        yThread.get("comments"),
        (comment) => comment.get("id") === options.commentId,
      );
      if (yCommentIndex === -1) {
        throw new Error("Comment not found");
      }
      const yComment = yThread.get("comments").get(yCommentIndex);
      if (!this.auth.canUpdateComment(yMapToComment(yComment))) {
        throw new Error("Not authorized");
      }
      yComment.set("body", options.comment.body);
      yComment.set("updatedAt", new Date().getTime());
      yComment.set("metadata", options.comment.metadata);
    },
  );
  public deleteComment = this.transact(
    (options: {
      threadId: string;
      commentId: string;
      softDelete?: boolean;
    }) => {
      const yThread = this.threadsYMap.get(options.threadId);
      if (!yThread) {
        throw new Error("Thread not found");
      }
      const yCommentIndex = yArrayFindIndex(
        yThread.get("comments"),
        (comment) => comment.get("id") === options.commentId,
      );
      if (yCommentIndex === -1) {
        throw new Error("Comment not found");
      }
      const yComment = yThread.get("comments").get(yCommentIndex);
      if (!this.auth.canDeleteComment(yMapToComment(yComment))) {
        throw new Error("Not authorized");
      }
      if (yComment.get("deletedAt")) {
        throw new Error("Comment already deleted");
      }
      if (options.softDelete) {
        yComment.set("deletedAt", new Date().getTime());
        yComment.set("body", undefined);
      } else {
        yThread.get("comments").delete(yCommentIndex);
      }
      if (
        (yThread.get("comments") as Y.Array<any>)
          .toArray()
          .every((comment) => comment.get("deletedAt"))
      ) {
        // all comments deleted
        if (options.softDelete) {
          yThread.set("deletedAt", new Date().getTime());
        } else {
          this.threadsYMap.delete(options.threadId);
        }
      }
      yThread.set("updatedAt", new Date().getTime());
    },
  );
  public deleteThread = this.transact((options: { threadId: string }) => {
    if (
      !this.auth.canDeleteThread(
        yMapToThread(this.threadsYMap.get(options.threadId)),
      )
    ) {
      throw new Error("Not authorized");
    }
    this.threadsYMap.delete(options.threadId);
  });
  public resolveThread = this.transact((options: { threadId: string }) => {
    const yThread = this.threadsYMap.get(options.threadId);
    if (!yThread) {
      throw new Error("Thread not found");
    }
    if (!this.auth.canResolveThread(yMapToThread(yThread))) {
      throw new Error("Not authorized");
    }
    yThread.set("resolved", true);
    yThread.set("resolvedUpdatedAt", new Date().getTime());
    yThread.set("resolvedBy", this.userId);
  });
  public unresolveThread = this.transact((options: { threadId: string }) => {
    const yThread = this.threadsYMap.get(options.threadId);
    if (!yThread) {
      throw new Error("Thread not found");
    }
    if (!this.auth.canUnresolveThread(yMapToThread(yThread))) {
      throw new Error("Not authorized");
    }
    yThread.set("resolved", false);
    yThread.set("resolvedUpdatedAt", new Date().getTime());
  });
  public addReaction = this.transact(
    (options: { threadId: string; commentId: string; emoji: string }) => {
      const yThread = this.threadsYMap.get(options.threadId);
      if (!yThread) {
        throw new Error("Thread not found");
      }
      const yCommentIndex = yArrayFindIndex(
        yThread.get("comments"),
        (comment) => comment.get("id") === options.commentId,
      );
      if (yCommentIndex === -1) {
        throw new Error("Comment not found");
      }
      const yComment = yThread.get("comments").get(yCommentIndex);
      if (!this.auth.canAddReaction(yMapToComment(yComment), options.emoji)) {
        throw new Error("Not authorized");
      }
      const date = new Date();
      const key = `${this.userId}-${options.emoji}`;
      const reactionsByUser = yComment.get("reactionsByUser");
      if (reactionsByUser.has(key)) {
        // already exists
        return;
      } else {
        const reaction = new Y.Map();
        reaction.set("emoji", options.emoji);
        reaction.set("createdAt", date.getTime());
        reaction.set("userId", this.userId);
        reactionsByUser.set(key, reaction);
      }
    },
  );
  public deleteReaction = this.transact(
    (options: { threadId: string; commentId: string; emoji: string }) => {
      const yThread = this.threadsYMap.get(options.threadId);
      if (!yThread) {
        throw new Error("Thread not found");
      }
      const yCommentIndex = yArrayFindIndex(
        yThread.get("comments"),
        (comment) => comment.get("id") === options.commentId,
      );
      if (yCommentIndex === -1) {
        throw new Error("Comment not found");
      }
      const yComment = yThread.get("comments").get(yCommentIndex);
      if (
        !this.auth.canDeleteReaction(yMapToComment(yComment), options.emoji)
      ) {
        throw new Error("Not authorized");
      }
      const key = `${this.userId}-${options.emoji}`;
      const reactionsByUser = yComment.get("reactionsByUser");
      reactionsByUser.delete(key);
    },
  );
}
function yArrayFindIndex(
  yArray: Y.Array<any>,
  predicate: (item: any) => boolean,
) {
  for (let i = 0; i < yArray.length; i++) {
    if (predicate(yArray.get(i))) {
      return i;
    }
  }
  return -1;
}