Просмотр исходного кода

Merge pull request #5856 from weseek/feat/gw7773-notification-by-posting-comment

feat: Show username suggestion for mention in comment
Yuki Takei 3 лет назад
Родитель
Сommit
14964d70a6

+ 2 - 1
packages/app/resource/locales/en_US/translation.json

@@ -390,7 +390,8 @@
     }
   },
   "page_comment": {
-    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
+    "no_user_found": "No user found"
   },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",

+ 2 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -390,7 +390,8 @@
     }
   },
   "page_comment": {
-    "display_the_page_when_posting_this_comment": "投稿時のページを表示する"
+    "display_the_page_when_posting_this_comment": "投稿時のページを表示する",
+    "no_user_found": "ユーザー名が見つかりません"
   },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",

+ 2 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -369,7 +369,8 @@
 		}
   },
   "page_comment": {
-    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
+    "no_user_found": "未找到用户名"
   },
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",

+ 7 - 7
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,27 +1,26 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import {
   Button,
   TabContent, TabPane,
 } from 'reactstrap';
-
 import * as toastr from 'toastr';
 
-import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import NotAvailableForGuest from '../NotAvailableForGuest';
 import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 import CommentPreview from './CommentPreview';
-import NotAvailableForGuest from '../NotAvailableForGuest';
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
 
 
 const navTabMapping = {
@@ -312,6 +311,7 @@ class CommentEditor extends React.Component {
                 onChange={this.updateState}
                 onUpload={this.uploadHandler}
                 onCtrlEnter={this.ctrlEnterHandler}
+                isComment
               />
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.

+ 12 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -15,6 +15,7 @@ import loggerFactory from '~/utils/logger';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
+import CommentMentionHelper from './CommentMentionHelper';
 import DrawioModal from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
@@ -31,7 +32,6 @@ import pasteHelper from './PasteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import SimpleCheatsheet from './SimpleCheatsheet';
 
-
 // Textlint
 window.JSHINT = JSHINT;
 window.kuromojin = { dicPath: '/static/dict' };
@@ -190,7 +190,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     // fold drawio section
     this.foldDrawioSection();
+
+    // initialize commentMentionHelper if comment editor is opened
+    if (this.props.isComment) {
+      this.commentMentionHelper = new CommentMentionHelper(this.getCodeMirror());
+    }
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
+
   }
 
   componentWillReceiveProps(nextProps) {
@@ -567,6 +573,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.updateCheatsheetStates(null, value);
 
+    // Show username hint on comment editor
+    if (this.props.isComment) {
+      this.commentMentionHelper.showUsernameHint();
+    }
+
   }
 
   keyUpHandler(editor, event) {

+ 62 - 0
packages/app/src/components/PageEditor/CommentMentionHelper.ts

@@ -0,0 +1,62 @@
+import i18n from 'i18next';
+import { debounce } from 'throttle-debounce';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+export default class CommentMentionHelper {
+
+  editor;
+
+  pattern: RegExp;
+
+
+  constructor(editor) {
+    this.editor = editor;
+  }
+
+  getUsernamHint = () => {
+    // Get word that contains `@` character at the begining
+    const currentPos = this.editor.getCursor();
+    const wordStart = this.editor.findWordAt(currentPos).anchor.ch - 1;
+    const wordEnd = this.editor.findWordAt(currentPos).head.ch;
+
+    const searchFrom = { line: currentPos.line, ch: wordStart };
+    const searchTo = { line: currentPos.line, ch: wordEnd };
+
+    const searchMention = this.editor.getRange(searchFrom, searchTo);
+    const isMentioning = searchMention.charAt(0) === '@';
+
+    // Return nothing if not mentioning
+    if (!isMentioning) {
+      return;
+    }
+
+    // Get username after `@` character and search username
+    const mention = searchMention.substr(1);
+    this.editor.showHint({
+      completeSingle: false,
+      hint: async() => {
+        if (mention.length > 0) {
+          const users = await this.getUsersList(mention);
+          return {
+            list: users.length > 0 ? users : [{ text: '', displayText: i18n.t('page_comment.no_user_found') }],
+            from: searchFrom,
+            to: searchTo,
+          };
+        }
+      },
+    });
+  }
+
+  getUsersList = async(username) => {
+    const limit = 20;
+    const { data } = await apiv3Get('/users/list', { username, limit });
+    return data.users.map(user => ({
+      text: `@${user.username} `,
+      displayText: user.username,
+    }));
+  }
+
+showUsernameHint= debounce(800, () => this.getUsernamHint());
+
+}

+ 4 - 0
packages/app/src/server/models/user.js

@@ -715,6 +715,10 @@ module.exports = function(crowi) {
     return users;
   };
 
+  userSchema.statics.findUserByUsernameRegex = async function(username, limit) {
+    return this.find({ username: { $regex: username, $options: 'i' } }).limit(limit);
+  };
+
   class UserUpperLimitException {
 
     constructor() {

+ 11 - 5
packages/app/src/server/routes/apiv3/users.js

@@ -12,9 +12,9 @@ const path = require('path');
 
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const { serializePageSecurely } = require('../../models/serializers/page-serializer');
 
+const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const PAGE_ITEMS = 50;
@@ -899,13 +899,19 @@ module.exports = (crowi) => {
    */
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
     const userIds = req.query.userIds || null;
+    const username = req.query.username || null;
+    const limit = req.query.limit || 20;
 
     let userFetcher;
-    if (!userIds || userIds.split(',').length <= 0) {
-      userFetcher = User.findAllUsers();
+    if (userIds !== null && userIds.split(',').length > 0) {
+      userFetcher = User.findUsersByIds(userIds.split(','));
+    }
+    // Get username list by matching pattern from username mention
+    else if (username !== null) {
+      userFetcher = User.findUserByUsernameRegex(username, limit);
     }
     else {
-      userFetcher = User.findUsersByIds(userIds.split(','));
+      userFetcher = User.findAllUsers();
     }
 
     const data = {};