mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
0139b91b5d
* chore: Replace lodash with es-toolkit Migrate all direct lodash imports to es-toolkit/compat for a smaller, faster, lodash-compatible utility library. Transitive lodash usage from other packages remains unchanged. * fix: Restore isPlainObject semantics in CanCan policy The lodash migration aliased `isObject` to `lodash/isPlainObject` and the codemod incorrectly mapped the local name to es-toolkit's `isObject`, which also returns true for arrays and functions. This caused condition objects in policy definitions to be skipped, breaking authorization checks across the codebase. * fix: Restore unicode-aware length counting in validators es-toolkit/compat's size() returns string.length, while lodash's _.size() counts unicode code points. Switch to [...value].length to preserve the previous behavior so multi-byte characters like emoji count as one.
279 lines
6.5 KiB
TypeScript
279 lines
6.5 KiB
TypeScript
import invariant from "invariant";
|
|
import { uniq } from "es-toolkit/compat";
|
|
import { action, computed, observable } from "mobx";
|
|
import { Pagination } from "@shared/constants";
|
|
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";
|
|
|
|
/**
|
|
* 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 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;
|