app/models/Comment.ts
Rama main · réplica de lectura del repositorio original.
import invariant from "invariant";
import { uniq } from "es-toolkit/compat";
import { action, computed, observable } from "mobx";
import { Pagination } from "@shared/constants";
import type { CommentAnchor } from "@shared/editor/commands/comment";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import User from "~/models/User";
import { client } from "~/utils/ApiClient";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Comment extends Model {
static modelName = "Comment";
constructor(fields: Record<string, unknown>, store: Model["store"]) {
super(fields, store);
this.initialize(fields);
}
/**
* The Prosemirror data representing the comment content
*/
@Field
@observable.shallow
data: ProsemirrorData;
/**
* If this comment is a reply then the parent comment will be set, otherwise
* it is a top thread.
*/
@Field
@observable
parentCommentId: string | null;
/**
* The comment that this comment is a reply to.
*/
@Relation(() => Comment, { onDelete: "cascade" })
parentComment?: Comment;
/**
* The document ID to which this comment belongs.
*/
@Field
@observable
documentId: string;
/**
* The document that this comment belongs to.
*/
@Relation(() => Document, { onDelete: "cascade" })
document: Document;
/**
* The user who created this comment.
*/
@Relation(() => User)
createdBy: User;
/**
* The ID of the user who created this comment.
*/
createdById: string;
/**
* The anchor location for a draft inline comment created by a user without
* edit access. Sent with the create request so the server can apply the
* comment mark to the document, and not otherwise persisted.
*/
@observable
pendingAnchor?: CommentAnchor = undefined;
/**
* The date and time that this comment was resolved, if it has been resolved.
*/
@observable
resolvedAt: string;
/**
* The user who resolved this comment, if it has been resolved.
*/
@Relation(() => User)
resolvedBy: User | null;
/**
* The ID of the user who resolved this comment, if it has been resolved.
*/
resolvedById: string | null;
/**
* Active reactions for this comment.
*
* Note: This contains just the emoji with the associated user-ids.
*/
@observable
reactions: ReactionSummary[];
/**
* Denotes whether the user data for the active reactions are loaded.
*/
@observable
reactedUsersLoaded: boolean = false;
/**
* Denotes whether there is an in-flight request for loading reacted users.
*/
private reactedUsersLoading = false;
/**
* Whether the comment is resolved
*/
@computed
public get isResolved(): boolean {
return !!this.resolvedAt || !!this.parentComment?.isResolved;
}
/**
* Whether the comment is a reply to another comment.
*/
@computed
public get isReply() {
return !!this.parentCommentId;
}
/**
* Resolve the comment
*/
public resolve() {
return this.store.rootStore.comments.resolve(this.id);
}
/**
* Unresolve the comment
*/
public unresolve() {
return this.store.rootStore.comments.unresolve(this.id);
}
/**
* Add an emoji as a reaction to this comment.
*
* Optimistically updates the `reactions` cache and invokes the backend API.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.emoji - The emoji to add as a reaction.
* @param {string} reaction.user - The user who added this reaction.
*/
@action
public addReaction = async ({
emoji,
user,
}: {
emoji: string;
user: User;
}) => {
this.updateReaction({ type: "add", emoji, user });
try {
await client.post("/comments.add_reaction", {
id: this.id,
emoji,
});
} catch {
this.updateReaction({ type: "remove", emoji, user });
}
};
/**
* Remove an emoji as a reaction from this comment.
*
* Optimistically updates the `reactions` cache and invokes the backend API.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.emoji - The emoji to remove as a reaction.
* @param {string} reaction.user - The user who removed this reaction.
*/
@action
public removeReaction = async ({
emoji,
user,
}: {
emoji: string;
user: User;
}) => {
this.updateReaction({ type: "remove", emoji, user });
try {
await client.post("/comments.remove_reaction", {
id: this.id,
emoji,
});
} catch {
this.updateReaction({ type: "add", emoji, user });
}
};
/**
* Update the `reactions` cache.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.type - The type of the action.
* @param {string} reaction.emoji - The emoji to update as a reaction.
* @param {string} reaction.user - The user who performed this action.
*/
@action
public updateReaction = ({
type,
emoji,
user,
}: {
type: "add" | "remove";
emoji: string;
user: User;
}) => {
const reaction = this.reactions.find((r) => r.emoji === emoji);
// Step 1: Update the reactions cache.
if (type === "add") {
if (!reaction) {
this.reactions.push({ emoji, userIds: [user.id] });
} else {
reaction.userIds = uniq([...reaction.userIds, user.id]);
}
} else {
if (reaction) {
reaction.userIds = reaction.userIds.filter((id) => id !== user.id);
}
if (reaction?.userIds.length === 0) {
this.reactions = this.reactions.filter(
(r) => r.emoji !== reaction.emoji
);
}
}
// Step 2: Add the user to the store.
this.store.rootStore.users.add(user);
};
/**
* Load the users for the active reactions.
*
*
* @param {Object} options - Options for loading the data.
* @param {string} options.limit - Per request limit for pagination.
*/
@action
loadReactedUsersData = async (
{ limit }: { limit: number } = { limit: Pagination.defaultLimit }
) => {
if (this.reactedUsersLoading || this.reactedUsersLoaded) {
return;
}
this.reactedUsersLoading = true;
try {
const fetchPage = async (offset: number = 0) => {
const res = await client.post("/reactions.list", {
commentId: this.id,
offset,
limit,
});
invariant(res?.data, "Data not available");
// @ts-expect-error reaction from server response
res.data.map((reaction) =>
this.store.rootStore.users.add(reaction.user)
);
return res.pagination;
};
const { total } = await fetchPage();
const pages = Math.ceil(total / limit);
const fetchPages = [];
for (let page = 1; page < pages; page++) {
fetchPages.push(fetchPage(page * limit));
}
await Promise.all(fetchPages);
this.reactedUsersLoaded = true;
} finally {
this.reactedUsersLoading = false;
}
};
}
export default Comment;