diff --git a/blocksuite/affine/inlines/mention/src/affine-mention.ts b/blocksuite/affine/inlines/mention/src/affine-mention.ts index 4e647c653..c48115a83 100644 --- a/blocksuite/affine/inlines/mention/src/affine-mention.ts +++ b/blocksuite/affine/inlines/mention/src/affine-mention.ts @@ -23,7 +23,7 @@ export class AffineMention extends SignalWatcher( 'clig' off; /* Client/baseMedium */ font-family: Inter; - font-size: 15px; + font-size: var(--affine-font-size-base); font-style: normal; font-weight: 500; line-height: 24px; /* 160% */ diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index 42a205030..eb2f5c6d5 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -11,6 +11,7 @@ import { } from '@affine/core/blocksuite/editors'; import { getViewManager } from '@affine/core/blocksuite/manager/view'; import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai'; +import { ServerService } from '@affine/core/modules/cloud'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; import type { DatabaseRow, @@ -21,6 +22,7 @@ import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { JournalService } from '@affine/core/modules/journal'; import { useInsidePeekView } from '@affine/core/modules/peek-view'; import { WorkspaceService } from '@affine/core/modules/workspace'; +import { ServerFeature } from '@affine/graphql'; import track from '@affine/track'; import type { DocTitle } from '@blocksuite/affine/fragments/doc-title'; import type { DocMode } from '@blocksuite/affine/model'; @@ -80,7 +82,13 @@ const usePatchSpecs = (mode: DocMode) => { featureFlagService.flags.enable_pdf_embed_preview.$ ); - const enableComment = useLiveData(featureFlagService.flags.enable_comment.$); + const serverService = useService(ServerService); + const serverConfig = useLiveData(serverService.server.config$); + + const enableComment = + useLiveData(featureFlagService.flags.enable_comment.$) && + // comment may not be supported by the server + serverConfig.features.includes(ServerFeature.Comment); const patchedSpecs = useMemo(() => { const manager = getViewManager() diff --git a/packages/frontend/core/src/blocksuite/view-extensions/editor-config/linked.ts b/packages/frontend/core/src/blocksuite/view-extensions/editor-config/linked.ts index 8b6bf910c..33f12269b 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/editor-config/linked.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/editor-config/linked.ts @@ -1,11 +1,17 @@ -import { AtMenuConfigService } from '@affine/core/modules/at-menu-config/services'; +import { + AtMenuConfigService, + type LinkedMenuGroupType, +} from '@affine/core/modules/at-menu-config/services'; import type { LinkedWidgetConfig } from '@blocksuite/affine/widgets/linked-doc'; import { type FrameworkProvider } from '@toeverything/infra'; export function createLinkedWidgetConfig( - framework: FrameworkProvider + framework: FrameworkProvider, + options?: { + includedGroups?: LinkedMenuGroupType[]; + } ): Partial | undefined { const service = framework.getOptional(AtMenuConfigService); if (!service) return; - return service.getConfig(); + return service.getConfig(options?.includedGroups); } diff --git a/packages/frontend/core/src/components/comment/comment-editor/specs.ts b/packages/frontend/core/src/components/comment/comment-editor/specs.ts index e7b173c61..1db18e0e5 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/specs.ts +++ b/packages/frontend/core/src/components/comment/comment-editor/specs.ts @@ -1,6 +1,8 @@ +import { CloudViewExtension } from '@affine/core/blocksuite/view-extensions/cloud'; import { createLinkedWidgetConfig } from '@affine/core/blocksuite/view-extensions/editor-config/linked'; import { AffineEditorViewExtension } from '@affine/core/blocksuite/view-extensions/editor-view/editor-view'; import { AffineThemeViewExtension } from '@affine/core/blocksuite/view-extensions/theme'; +import { LinkedMenuGroupType } from '@affine/core/modules/at-menu-config/services'; import { CodeBlockViewExtension } from '@blocksuite/affine/blocks/code/view'; import { DividerViewExtension } from '@blocksuite/affine/blocks/divider/view'; import { LatexViewExtension as LatexBlockViewExtension } from '@blocksuite/affine/blocks/latex/view'; @@ -145,6 +147,9 @@ export function getCommentEditorViewManager(framework: FrameworkProvider) { // Affine side AffineThemeViewExtension, AffineEditorViewExtension, + + // for rendering mentions + CloudViewExtension, ]); manager.configure(ParagraphViewExtension, { @@ -155,8 +160,15 @@ export function getCommentEditorViewManager(framework: FrameworkProvider) { manager.configure( LinkedDocViewExtension, - createLinkedWidgetConfig(framework) + createLinkedWidgetConfig(framework, { + includedGroups: [LinkedMenuGroupType.Mention], + }) ); + + manager.configure(CloudViewExtension, { + framework, + enableCloud: true, + }); } return manager; } diff --git a/packages/frontend/core/src/components/comment/sidebar/index.tsx b/packages/frontend/core/src/components/comment/sidebar/index.tsx index 0074ad869..22ef1860f 100644 --- a/packages/frontend/core/src/components/comment/sidebar/index.tsx +++ b/packages/frontend/core/src/components/comment/sidebar/index.tsx @@ -551,7 +551,6 @@ const useCommentEntity = (docId: string | undefined) => { const entityRef = docCommentManager.get(docId); setEntity(entityRef.obj); entityRef.obj.start(); - entityRef.obj.revalidate(); // Set up pending comment watching to auto-open sidebar const unwatchPending = commentPanelService.watchForPendingComments( @@ -560,6 +559,7 @@ const useCommentEntity = (docId: string | undefined) => { return () => { unwatchPending(); + entityRef.obj.stop(); entityRef.release(); }; }, [docCommentManager, commentPanelService, docId]); diff --git a/packages/frontend/core/src/components/comment/sidebar/style.css.ts b/packages/frontend/core/src/components/comment/sidebar/style.css.ts index db7c5c506..58d205454 100644 --- a/packages/frontend/core/src/components/comment/sidebar/style.css.ts +++ b/packages/frontend/core/src/components/comment/sidebar/style.css.ts @@ -125,7 +125,7 @@ export const previewContainer = style({ position: 'absolute', left: '0', top: '0', - backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'), + backgroundColor: cssVarV2('block/comment/highlightUnderline'), }, }, }); diff --git a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts index cd35c37c4..c83304d27 100644 --- a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts @@ -62,6 +62,26 @@ export function useNavigateHelper() { }, [navigate] ); + const jumpToPageComment = useCallback( + ( + workspaceId: string, + pageId: string, + commentId: string, + mode: DocMode, + logic: RouteLogic = RouteLogic.PUSH + ) => { + const search = toDocSearchParams({ + mode, + refreshKey: nanoid(), + commentId, + }); + const query = search?.size ? `?${search.toString()}` : ''; + return navigate(`/workspace/${workspaceId}/${pageId}${query}`, { + replace: logic === RouteLogic.REPLACE, + }); + }, + [navigate] + ); const jumpToCollections = useCallback( (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { return navigate(`/workspace/${workspaceId}/collection`, { @@ -213,6 +233,7 @@ export function useNavigateHelper() { () => ({ jumpToPage, jumpToPageBlock, + jumpToPageComment, jumpToIndex, jumpTo404, openPage, @@ -229,6 +250,7 @@ export function useNavigateHelper() { [ jumpToPage, jumpToPageBlock, + jumpToPageComment, jumpToIndex, jumpTo404, openPage, diff --git a/packages/frontend/core/src/components/notification/list.tsx b/packages/frontend/core/src/components/notification/list.tsx index aadaf017e..33adee58a 100644 --- a/packages/frontend/core/src/components/notification/list.tsx +++ b/packages/frontend/core/src/components/notification/list.tsx @@ -156,6 +156,10 @@ const NotificationItem = ({ notification }: { notification: Notification }) => { return type === NotificationType.Mention ? ( + ) : type === NotificationType.Comment ? ( + + ) : type === NotificationType.CommentMention ? ( + ) : type === NotificationType.InvitationAccepted ? ( ) : type === NotificationType.Invitation ? ( @@ -771,3 +775,143 @@ const DocNameWithIcon = ({ ); }; + +const CommentNotificationItem = ({ + notification, +}: { + notification: Notification; +}) => { + const notificationListService = useService(NotificationListService); + const { jumpToPageComment } = useNavigateHelper(); + const t = useI18n(); + const body = notification.body; + + const memberInactived = !body.createdByUser; + + const handleClick = useCallback(() => { + track.$.sidebar.notifications.clickNotification({ + type: notification.type, + item: 'read', + }); + if (!body.workspaceId || !body.doc?.id) { + return; + } + notificationListService.readNotification(notification.id).catch(err => { + console.error(err); + }); + + jumpToPageComment( + body.workspaceId, + body.doc.id, + body.commentId, + body.doc.mode + ); + }, [body, jumpToPageComment, notificationListService, notification]); + + return ( +
+ +
+ + + ), + 2: , + }} + values={{ + username: + body.createdByUser?.name ?? t['com.affine.inactive-member'](), + docTitle: body.doc?.title || t['Untitled'](), + }} + /> + +
+ {i18nTime(notification.createdAt, { + relative: true, + })} +
+
+ +
+ ); +}; + +const CommentMentionNotificationItem = ({ + notification, +}: { + notification: Notification; +}) => { + const notificationListService = useService(NotificationListService); + const { jumpToPageComment } = useNavigateHelper(); + const t = useI18n(); + const body = notification.body; + + const memberInactived = !body.createdByUser; + + const handleClick = useCallback(() => { + track.$.sidebar.notifications.clickNotification({ + type: notification.type, + item: 'read', + }); + if (!body.workspaceId || !body.doc?.id) { + return; + } + notificationListService.readNotification(notification.id).catch(err => { + console.error(err); + }); + + jumpToPageComment( + body.workspaceId, + body.doc.id, + body.commentId, + body.doc.mode + ); + }, [body, jumpToPageComment, notificationListService, notification]); + + return ( +
+ +
+ + + ), + 2: , + }} + values={{ + username: + body.createdByUser?.name ?? t['com.affine.inactive-member'](), + docTitle: body.doc?.title || t['Untitled'](), + }} + /> + +
+ {i18nTime(notification.createdAt, { + relative: true, + })} +
+
+ +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index d2fbdd068..3206fe681 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -17,6 +17,7 @@ import { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { WorkspacePropertySidebar } from '@affine/core/components/properties/sidebar'; import { TrashPageFooter } from '@affine/core/components/pure/trash-page-footer'; import { TopTip } from '@affine/core/components/top-tip'; +import { ServerService } from '@affine/core/modules/cloud'; import { DocService } from '@affine/core/modules/doc'; import { EditorService } from '@affine/core/modules/editor'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; @@ -33,6 +34,7 @@ import { } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { isNewTabTrigger } from '@affine/core/utils'; +import { ServerFeature } from '@affine/graphql'; import track from '@affine/track'; import { DisposableGroup } from '@blocksuite/affine/global/disposable'; import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference'; @@ -113,6 +115,14 @@ const DetailPageImpl = memo(function DetailPageImpl() { featureFlagService.flags.enable_adapter_panel.$ ); + const serverService = useService(ServerService); + const serverConfig = useLiveData(serverService.server.config$); + + const enableComment = + useLiveData(featureFlagService.flags.enable_comment.$) && + // comment may not be supported by the server + serverConfig.features.includes(ServerFeature.Comment); + useEffect(() => { if (isActiveView) { setActiveBlockSuiteEditor(editorContainer); @@ -383,7 +393,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { )} - {workspace.flavour !== 'local' && ( + {workspace.flavour !== 'local' && enableComment && ( }> diff --git a/packages/frontend/core/src/modules/at-menu-config/services/index.ts b/packages/frontend/core/src/modules/at-menu-config/services/index.ts index ec971e20f..496024967 100644 --- a/packages/frontend/core/src/modules/at-menu-config/services/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/services/index.ts @@ -62,6 +62,13 @@ const RESERVED_ITEM_KEYS = { datePicker: 'date-picker', }; +export enum LinkedMenuGroupType { + LinkToDoc = 'link-to-doc', + Mention = 'mention', + Journal = 'journal', + NewDoc = 'new-doc', +} + export class AtMenuConfigService extends Service { constructor( private readonly journalService: JournalService, @@ -79,9 +86,11 @@ export class AtMenuConfigService extends Service { // todo(@peng17): maybe refactor the config using entity, so that each config // can be reactive to the query, instead of recreating the whole config? - getConfig(): Partial { + getConfig( + includedGroups?: LinkedMenuGroupType[] + ): Partial { return { - getMenus: this.getMenusFn(), + getMenus: this.getMenusFn(includedGroups), mobile: this.getMobileConfig(), autoFocusedItemKey: this.autoFocusedItemKey, }; @@ -102,14 +111,14 @@ export class AtMenuConfigService extends Service { return null; } - const linkToDocGroup = menus[0]; - const memberGroup = menus[1]; + const linkToDocGroup = menus.at(0); + const memberGroup = menus.at(1); - if (resolveSignal(memberGroup.items).length > 1) { + if (memberGroup && resolveSignal(memberGroup.items).length > 1) { return resolveSignal(memberGroup.items)[0]?.key; } - if (resolveSignal(linkToDocGroup.items).length > 0) { + if (linkToDocGroup && resolveSignal(linkToDocGroup.items).length > 0) { return resolveSignal(linkToDocGroup.items)[0]?.key; } @@ -635,9 +644,7 @@ export class AtMenuConfigService extends Service { return query.length > 0 && !loading && members.length === 0; }); - if (query.length > 0) { - this.memberSearchService.search(query); - } + this.memberSearchService.search(query); return { name: I18n.t('com.affine.editor.at-menu.mention-members'), @@ -655,13 +662,28 @@ export class AtMenuConfigService extends Service { }; } - private getMenusFn(): LinkedWidgetConfig['getMenus'] { + private getMenusFn( + includedGroups: LinkedMenuGroupType[] = [ + LinkedMenuGroupType.LinkToDoc, + LinkedMenuGroupType.Mention, + LinkedMenuGroupType.Journal, + LinkedMenuGroupType.NewDoc, + ] + ): LinkedWidgetConfig['getMenus'] { return (query, close, editorHost, inlineEditor, abortSignal) => { return [ - this.linkToDocGroup(query, close, inlineEditor, abortSignal), - this.memberGroup(query, close, inlineEditor, abortSignal), - this.journalGroup(query, close, inlineEditor), - this.newDocMenuGroup(query, close, editorHost, inlineEditor), + ...(includedGroups?.includes(LinkedMenuGroupType.LinkToDoc) + ? [this.linkToDocGroup(query, close, inlineEditor, abortSignal)] + : []), + ...(includedGroups?.includes(LinkedMenuGroupType.Mention) + ? [this.memberGroup(query, close, inlineEditor, abortSignal)] + : []), + ...(includedGroups?.includes(LinkedMenuGroupType.Journal) + ? [this.journalGroup(query, close, inlineEditor)] + : []), + ...(includedGroups?.includes(LinkedMenuGroupType.NewDoc) + ? [this.newDocMenuGroup(query, close, editorHost, inlineEditor)] + : []), ]; }; } diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts index 7e468367e..2e020870e 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts @@ -162,6 +162,7 @@ export class DocCommentStore extends Entity<{ async createComment(commentInput: { content: DocCommentContent; + mentions?: string[]; }): Promise { const graphql = this.graphqlService; if (!graphql) { @@ -177,6 +178,7 @@ export class DocCommentStore extends Entity<{ docMode: this.props.getDocMode(), docTitle: this.props.getDocTitle(), content: commentInput.content, + mentions: commentInput.mentions, }, }, }); @@ -245,6 +247,7 @@ export class DocCommentStore extends Entity<{ commentId: string, replyInput: { content: DocCommentContent; + mentions?: string[]; } ): Promise { const graphql = this.graphqlService; @@ -260,6 +263,7 @@ export class DocCommentStore extends Entity<{ content: replyInput.content, docMode: this.props.getDocMode(), docTitle: this.props.getDocTitle(), + mentions: replyInput.mentions, }, }, }); diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts index 1b85006dc..6df0a456f 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts @@ -1,5 +1,10 @@ import { type CommentChangeAction, DocMode } from '@affine/graphql'; -import type { BaseSelection } from '@blocksuite/affine/store'; +import type { + BaseSelection, + BaseTextAttributes, + BlockSnapshot, + DeltaInsert, +} from '@blocksuite/affine/store'; import { effect, Entity, @@ -26,6 +31,13 @@ import { DocCommentStore } from './doc-comment-store'; type DisposeCallback = () => void; +const MentionAttribute = 'mention'; +type ExtendedTextAttributes = BaseTextAttributes & { + [MentionAttribute]: { + member: string; + }; +}; + export class DocCommentEntity extends Entity<{ docId: string; }> { @@ -115,6 +127,31 @@ export class DocCommentEntity extends Entity<{ return this.framework.get(GlobalContextService).globalContext.docMode.$; } + findMentions(snapshot: BlockSnapshot): string[] { + const mentionedUserIds = new Set(); + if ( + snapshot.props.type === 'text' && + snapshot.props.text && + 'delta' in (snapshot.props.text as any) + ) { + const delta = (snapshot.props.text as any) + .delta as DeltaInsert[]; + for (const op of delta) { + if (op.attributes?.[MentionAttribute]) { + mentionedUserIds.add(op.attributes[MentionAttribute].member); + } + } + } + + for (const block of snapshot.children) { + this.findMentions(block).forEach(userId => { + mentionedUserIds.add(userId); + }); + } + + return Array.from(mentionedUserIds); + } + async commitComment(id: string): Promise { const pendingComment = this.pendingComment$.value; if (!pendingComment || pendingComment.id !== id) { @@ -126,7 +163,9 @@ export class DocCommentEntity extends Entity<{ if (!snapshot) { throw new Error('Failed to get snapshot'); } + const mentions = this.findMentions(snapshot.blocks); const comment = await this.store.createComment({ + mentions: mentions, content: { snapshot, preview, @@ -159,7 +198,9 @@ export class DocCommentEntity extends Entity<{ throw new Error('Pending reply has no commentId'); } + const mentions = this.findMentions(snapshot.blocks); const reply = await this.store.createReply(pendingReply.commentId, { + mentions, content: { snapshot, }, diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 8cdd4cc7c..3df3ad72d 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -269,8 +269,8 @@ export const AFFINE_FLAGS = { bsFlag: 'enable_comment', displayName: 'Enable Comment', description: 'Enable comment', - configurable: isCanaryBuild, - defaultState: true, + configurable: true, + defaultState: isCanaryBuild, }, } satisfies { [key in string]: FlagInfo }; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 5043fe378..745ce92b0 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -9438,6 +9438,26 @@ export const TypedTrans: { ["1"]: JSX.Element; ["2"]: JSX.Element; }>>; + /** + * `<1>{{username}} commented in <2>{{docTitle}}` + */ + ["com.affine.notification.comment"]: ComponentType, { + ["1"]: JSX.Element; + ["2"]: JSX.Element; + }>>; + /** + * `<1>{{username}} mentioned you in a comment in <2>{{docTitle}}` + */ + ["com.affine.notification.comment-mention"]: ComponentType, { + ["1"]: JSX.Element; + ["2"]: JSX.Element; + }>>; /** * `<1>{{username}} has accept your invitation` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 42c640aed..45e825d9d 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1932,6 +1932,8 @@ "com.affine.page-starter-bar.edgeless": "Edgeless", "com.affine.notification.unsupported": "Unsupported message", "com.affine.notification.mention": "<1>{{username}} mentioned you in <2>{{docTitle}}", + "com.affine.notification.comment": "<1>{{username}} commented in <2>{{docTitle}}", + "com.affine.notification.comment-mention": "<1>{{username}} mentioned you in a comment in <2>{{docTitle}}", "com.affine.notification.empty": "No new notifications", "com.affine.notification.loading-more": "Loading more...", "com.affine.notification.empty.description": "You'll be notified here for @mentions and workspace invites.",