mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 00:56:26 +08:00
feat(core): reply actions (#13071)
fix AF-2717, AF-2716 #### PR Dependency Tree * **PR #13071** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Introduced full editing, replying, and deletion capabilities for individual replies within comments, with consistent UI and state management. * Added support for editing drafts for both comments and replies, allowing users to start, commit, or dismiss edits. * Improved editor focus behavior for a more seamless editing experience. * Added permission checks for comment and reply creation, editing, deletion, and resolution, controlling UI elements accordingly. * Refactored reply rendering into dedicated components with enhanced permission-aware interactions. * **Bug Fixes** * Enhanced comment normalization to handle cases where content may be missing, preventing potential errors. * **Style** * Updated comment and reply UI styles for clearer editing and action states, including new hover and visibility behaviors for reply actions. * **Chores** * Removed unnecessary debugging statements from reply configuration. <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #13071** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -49,7 +49,7 @@ const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
|
||||
|
||||
const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||
id: comment.id,
|
||||
content: comment.content as DocCommentContent,
|
||||
content: comment.content ? (comment.content as DocCommentContent) : undefined,
|
||||
resolved: comment.resolved,
|
||||
createdAt: new Date(comment.createdAt).getTime(),
|
||||
updatedAt: new Date(comment.updatedAt).getTime(),
|
||||
@@ -60,7 +60,9 @@ const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||
name: '',
|
||||
avatarUrl: '',
|
||||
},
|
||||
mentions: findMentions(comment.content.snapshot.blocks),
|
||||
mentions: comment.content
|
||||
? findMentions(comment.content.snapshot.blocks)
|
||||
: [],
|
||||
replies: comment.replies?.map(normalizeReply) ?? [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { type CommentChangeAction, DocMode } from '@affine/graphql';
|
||||
import type { BaseSelection } from '@blocksuite/affine/store';
|
||||
import type {
|
||||
BaseSelection,
|
||||
DocSnapshot,
|
||||
Store,
|
||||
} from '@blocksuite/affine/store';
|
||||
import {
|
||||
effect,
|
||||
Entity,
|
||||
@@ -63,6 +67,13 @@ export class DocCommentEntity extends Entity<{
|
||||
// Only one pending reply at a time
|
||||
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
||||
|
||||
// Draft state for editing existing comment or reply (only one at a time)
|
||||
readonly editingDraft$ = new LiveData<{
|
||||
id: CommentId;
|
||||
type: 'comment' | 'reply';
|
||||
doc: Store;
|
||||
} | null>(null);
|
||||
|
||||
private readonly commentAdded$ = new Subject<{
|
||||
id: CommentId;
|
||||
selections: BaseSelection[];
|
||||
@@ -78,13 +89,15 @@ export class DocCommentEntity extends Entity<{
|
||||
selections?: BaseSelection[],
|
||||
preview?: string
|
||||
): Promise<string> {
|
||||
// todo: may need to properly bind the doc to the editor
|
||||
const doc = await this.snapshotHelper.createStore();
|
||||
// check if there is a pending comment, reuse it
|
||||
let pendingComment = this.pendingComment$.value;
|
||||
const doc =
|
||||
pendingComment?.doc ?? (await this.snapshotHelper.createStore());
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc');
|
||||
}
|
||||
const id = nanoid();
|
||||
const pendingComment: PendingComment = {
|
||||
pendingComment = {
|
||||
id,
|
||||
doc,
|
||||
preview,
|
||||
@@ -96,18 +109,35 @@ export class DocCommentEntity extends Entity<{
|
||||
return id;
|
||||
}
|
||||
|
||||
async addReply(commentId: string): Promise<string> {
|
||||
const doc = await this.snapshotHelper.createStore();
|
||||
/**
|
||||
* Add a reply to a comment.
|
||||
* If reply is provided, the content should @mention the user who is replying to.
|
||||
*/
|
||||
async addReply(commentId: string, reply?: DocCommentReply): Promise<string> {
|
||||
// check if there is a pending reply, reuse it
|
||||
let pendingReply = this.pendingReply$.value;
|
||||
const doc = pendingReply?.doc ?? (await this.snapshotHelper.createStore());
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc');
|
||||
}
|
||||
const mention: string | undefined = reply?.user.id;
|
||||
if (mention) {
|
||||
// insert mention at the end of the paragraph
|
||||
const paragraph = doc.getModelsByFlavour('affine:paragraph').at(-1);
|
||||
if (paragraph) {
|
||||
paragraph.text?.insert(' ', paragraph.text.length, {
|
||||
mention: {
|
||||
member: mention,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const id = nanoid();
|
||||
const pendingReply: PendingComment = {
|
||||
pendingReply = {
|
||||
id,
|
||||
doc,
|
||||
commentId,
|
||||
};
|
||||
|
||||
// Replace any existing pending reply (only one at a time)
|
||||
this.pendingReply$.setValue(pendingReply);
|
||||
return id;
|
||||
@@ -121,6 +151,47 @@ export class DocCommentEntity extends Entity<{
|
||||
this.pendingReply$.setValue(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing an existing comment or reply.
|
||||
* This will dismiss any previous editing draft so that only one can exist.
|
||||
*/
|
||||
async startEdit(
|
||||
id: CommentId,
|
||||
type: 'comment' | 'reply',
|
||||
snapshot: DocSnapshot
|
||||
): Promise<void> {
|
||||
const doc = await this.snapshotHelper.createStore(snapshot);
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc for editing');
|
||||
}
|
||||
this.editingDraft$.setValue({ id, type, doc });
|
||||
}
|
||||
|
||||
/** Commit current editing draft (if any) */
|
||||
async commitEditing(): Promise<void> {
|
||||
const draft = this.editingDraft$.value;
|
||||
if (!draft) return;
|
||||
|
||||
const snapshot = this.snapshotHelper.getSnapshot(draft.doc);
|
||||
if (!snapshot) {
|
||||
throw new Error('Failed to get snapshot');
|
||||
}
|
||||
|
||||
if (draft.type === 'comment') {
|
||||
await this.updateComment(draft.id, { snapshot });
|
||||
} else {
|
||||
await this.updateReply(draft.id, { snapshot });
|
||||
}
|
||||
|
||||
this.editingDraft$.setValue(null);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
/** Dismiss current editing draft without saving */
|
||||
dismissDraftEditing(): void {
|
||||
this.editingDraft$.setValue(null);
|
||||
}
|
||||
|
||||
get docMode$() {
|
||||
return this.framework.get(GlobalContextService).globalContext.docMode.$;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export type CommentId = string;
|
||||
|
||||
export interface BaseComment {
|
||||
id: CommentId;
|
||||
content: DocCommentContent;
|
||||
content?: DocCommentContent;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
user: PublicUserType;
|
||||
|
||||
Reference in New Issue
Block a user