From 7ff4b5200562e66befd7bdd8628192d7c80c2052 Mon Sep 17 00:00:00 2001
From: Michael An <2331806369@qq.com>
Date: Mon, 21 Apr 2025 21:33:13 +0800
Subject: [PATCH] basic file support comment (#7731)
* basic file support comment
* 01 add init loading icon
* delete useless comment
* 02 delete comment tip
* update api validation
* 03 update API params
* 04 delete useless api
* 05 remove read all notification
* 06 change comment and reply permission
* 07 change docUuid to fileUuid
---------
Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
---
frontend/package-lock.json | 45 ++
frontend/package.json | 1 +
frontend/src/components/common/notice-item.js | 18 +
.../src/components/file-view/comment-panel.js | 213 ++++++++++
.../comment-widget/comment-body-header.css | 28 ++
.../comment-widget/comment-body-header.js | 59 +++
.../comment-widget/comment-delete-popover.css | 9 +
.../comment-widget/comment-delete-popover.js | 71 ++++
.../comment-widget/comment-item-readonly.js | 99 +++++
.../file-view/comment-widget/comment-item.js | 210 ++++++++++
.../file-view/comment-widget/comment-list.js | 251 ++++++++++++
.../file-view/comment-widget/reply-item.js | 175 ++++++++
.../file-view/comment-widget/reply-list.js | 234 +++++++++++
.../src/components/file-view/file-toolbar.js | 42 +-
.../src/components/file-view/file-view.js | 22 +-
frontend/src/css/comments-list.css | 267 ++++++++++++
frontend/src/css/file-view.css | 6 +-
.../src/css/react-mentions-default-style.js | 66 +++
frontend/src/utils/seafile-api.js | 69 ++--
seahub/api2/endpoints/file_comments.py | 383 ++++++++++++++++++
seahub/templates/base_for_react.html | 1 +
seahub/templates/file_view_react.html | 1 +
seahub/urls.py | 12 +-
seahub/views/file.py | 5 +-
24 files changed, 2241 insertions(+), 46 deletions(-)
create mode 100644 frontend/src/components/file-view/comment-panel.js
create mode 100644 frontend/src/components/file-view/comment-widget/comment-body-header.css
create mode 100644 frontend/src/components/file-view/comment-widget/comment-body-header.js
create mode 100644 frontend/src/components/file-view/comment-widget/comment-delete-popover.css
create mode 100644 frontend/src/components/file-view/comment-widget/comment-delete-popover.js
create mode 100644 frontend/src/components/file-view/comment-widget/comment-item-readonly.js
create mode 100644 frontend/src/components/file-view/comment-widget/comment-item.js
create mode 100644 frontend/src/components/file-view/comment-widget/comment-list.js
create mode 100644 frontend/src/components/file-view/comment-widget/reply-item.js
create mode 100644 frontend/src/components/file-view/comment-widget/reply-list.js
create mode 100644 frontend/src/css/comments-list.css
create mode 100644 frontend/src/css/react-mentions-default-style.js
create mode 100644 seahub/api2/endpoints/file_comments.py
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cdafa3d27a..980b1ecf62 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -50,6 +50,7 @@
"react-dnd-html5-backend": "^2.6.0",
"react-dom": "18.3.1",
"react-i18next": "^10.12.2",
+ "react-mentions": "4.4.10",
"react-responsive": "10.0.0",
"react-select": "5.9.0",
"react-transition-group": "4.4.5",
@@ -24819,6 +24820,37 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
+ "node_modules/react-mentions": {
+ "version": "4.4.10",
+ "resolved": "https://registry.npmjs.org/react-mentions/-/react-mentions-4.4.10.tgz",
+ "integrity": "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "7.4.5",
+ "invariant": "^2.2.4",
+ "prop-types": "^15.5.8",
+ "substyle": "^9.1.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.3",
+ "react-dom": ">=16.8.3"
+ }
+ },
+ "node_modules/react-mentions/node_modules/@babel/runtime": {
+ "version": "7.4.5",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
+ "integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "regenerator-runtime": "^0.13.2"
+ }
+ },
+ "node_modules/react-mentions/node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT"
+ },
"node_modules/react-modal": {
"version": "3.16.3",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz",
@@ -28811,6 +28843,19 @@
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
},
+ "node_modules/substyle": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/substyle/-/substyle-9.4.1.tgz",
+ "integrity": "sha512-VOngeq/W1/UkxiGzeqVvDbGDPM8XgUyJVWjrqeh+GgKqspEPiLYndK+XRcsKUHM5Muz/++1ctJ1QCF/OqRiKWA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.4",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.3"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 59711cf47d..2f340bee69 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -38,6 +38,7 @@
"prop-types": "^15.8.1",
"qrcode.react": "4.2.0",
"react": "18.3.1",
+ "react-mentions": "4.4.10",
"react-app-polyfill": "^2.0.0",
"react-chartjs-2": "5.3.0",
"react-cookies": "^0.1.0",
diff --git a/frontend/src/components/common/notice-item.js b/frontend/src/components/common/notice-item.js
index f797fe6ebb..119a29c840 100644
--- a/frontend/src/components/common/notice-item.js
+++ b/frontend/src/components/common/notice-item.js
@@ -19,6 +19,7 @@ const MSG_TYPE_REPO_SHARE_TO_GROUP = 'repo_share_to_group';
const MSG_TYPE_REPO_TRANSFER = 'repo_transfer';
const MSG_TYPE_FILE_UPLOADED = 'file_uploaded';
const MSG_TYPE_FOLDER_UPLOADED = 'folder_uploaded';
+const MSG_TYPE_FILE_COMMENT = 'file_comment';
// const MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted';
const MSG_TYPE_REPO_MONITOR = 'repo_monitor';
const MSG_TYPE_DELETED_FILES = 'deleted_files';
@@ -38,6 +39,23 @@ class NoticeItem extends React.Component {
let noticeType = noticeItem.type;
let detail = noticeItem.detail;
+ if (noticeType === MSG_TYPE_FILE_COMMENT) {
+ let avatar_url = detail.author_avatar_url;
+ let author = detail.author_name;
+ let fileName = detail.file_name;
+ let fileUrl = siteRoot + 'lib/' + detail.repo_id + '/' + 'file' + detail.file_path;
+ // 1. handle translate
+ let notice = gettext('File {file_link} has a new comment form user {author}.');
+ // 2. handle xss(cross-site scripting)
+ notice = notice.replace('{file_link}', `{tagA}${fileName}{/tagA}`);
+ notice = notice.replace('{author}', author);
+ notice = Utils.HTMLescape(notice);
+ // 3. add jump link
+ notice = notice.replace('{tagA}', ``);
+ notice = notice.replace('{/tagA}', '');
+ return { avatar_url, notice };
+ }
+
if (noticeType === MSG_TYPE_ADD_USER_TO_GROUP) {
let avatar_url = detail.group_staff_avatar_url;
let groupStaff = detail.group_staff_name;
diff --git a/frontend/src/components/file-view/comment-panel.js b/frontend/src/components/file-view/comment-panel.js
new file mode 100644
index 0000000000..2608f0430d
--- /dev/null
+++ b/frontend/src/components/file-view/comment-panel.js
@@ -0,0 +1,213 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import dayjs from 'dayjs';
+import { seafileAPI } from '../../utils/seafile-api';
+import { Utils } from '../../utils/utils';
+import toaster from '../toast';
+import CommentList from './comment-widget/comment-list';
+import ReplyList from './comment-widget/reply-list';
+
+import '../../css/comments-list.css';
+
+const { username, repoID, filePath, fileUuid } = window.app.pageOptions;
+
+const CommentPanelPropTypes = {
+ toggleCommentPanel: PropTypes.func.isRequired,
+ participants: PropTypes.array,
+ onParticipantsChange: PropTypes.func,
+};
+
+class CommentPanel extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isLoading: true,
+ commentsList: [],
+ showResolvedComment: true,
+ participants: null,
+ relatedUsers: null,
+ currentComment: null,
+ };
+ this.toBeAddedParticipant = [];
+ }
+
+ listComments = () => {
+ seafileAPI.listComments(repoID, fileUuid).then((res) => {
+ this.setState({
+ commentsList: res.data.comments,
+ isLoading: false,
+ });
+ if (this.state.currentComment) {
+ let newCurrentComment = res.data.comments.find(comment => comment.id === this.state.currentComment.id);
+ if (newCurrentComment) {
+ this.setState({
+ currentComment: newCurrentComment
+ });
+ }
+ }
+ }).catch(error => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ };
+
+ listRepoRelatedUsers = () => {
+ seafileAPI.listRepoRelatedUsers(repoID).then((res) => {
+ let users = res.data.user_list.map((item) => {
+ return { id: item.email, display: item.name, avatar_url: item.avatar_url, contact_email: item.contact_email };
+ });
+ this.setState({ relatedUsers: users });
+ });
+ };
+
+ handleCommentChange = (event) => {
+ this.setState({ comment: event.target.value });
+ };
+
+ addComment = (comment) => {
+ seafileAPI.postComment(repoID, fileUuid, comment).then(() => {
+ this.listComments();
+ }).catch(err => {
+ toaster.danger(Utils.getErrorMsg(err));
+ });
+ };
+
+ addReply = (reply) => {
+ const replyData = {
+ author: username,
+ reply,
+ type: 'reply',
+ updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss')
+ };
+ seafileAPI.insertReply(repoID, fileUuid, this.state.currentComment.id, replyData).then(() => {
+ this.listComments();
+ }).catch(err => {
+ toaster.danger(Utils.getErrorMsg(err));
+ });
+ };
+
+ resolveComment = (comment, resolveState = 'true') => {
+ seafileAPI.updateComment(repoID, fileUuid, comment.id, resolveState, null, null).then(() => {
+ this.listComments();
+ }).catch(error => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ };
+
+ deleteComment = (comment) => {
+ seafileAPI.deleteComment(repoID, fileUuid, comment.id).then(() => {
+ this.clearCurrentComment();
+ this.listComments();
+ }).catch(error => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ };
+
+ deleteReply = (commentId, replyId) => {
+ seafileAPI.deleteReply(repoID, fileUuid, commentId, replyId).then(() => {
+ this.listComments();
+ }).catch(error => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ };
+
+ updateReply = (commentId, replyId, reply) => {
+ const replyData = {
+ author: username,
+ reply,
+ type: 'reply',
+ updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss')
+ };
+ seafileAPI.updateReply(repoID, fileUuid, commentId, replyId, replyData).then(() => {
+ this.listComments();
+ }).catch(err => {
+ toaster.danger(Utils.getErrorMsg(err));
+ });
+ };
+
+ editComment = (comment, newComment) => {
+ seafileAPI.updateComment(repoID, fileUuid, comment.id, null, null, newComment).then((res) => {
+ this.listComments();
+ }).catch(error => {
+ let errMessage = Utils.getErrorMsg(error);
+ toaster.danger(errMessage);
+ });
+ };
+
+ onParticipantsChange = () => {
+ if (this.props.onParticipantsChange) {
+ this.props.onParticipantsChange();
+ } else {
+ this.getParticipants();
+ }
+ };
+
+ getParticipants = () => {
+ if (this.props.participants) {
+ this.setState({ participants: this.props.participants });
+ } else {
+ seafileAPI.listFileParticipants(repoID, filePath).then((res) => {
+ this.setState({ participants: res.data.participant_list });
+ });
+ }
+ };
+
+ componentDidMount() {
+ this.listComments();
+ this.getParticipants();
+ this.listRepoRelatedUsers();
+ }
+
+ onClickComment = (currentComment) => {
+ this.setState({ currentComment });
+ };
+
+ clearCurrentComment = () => {
+ this.setState({ currentComment: null });
+ };
+
+ render() {
+ const { commentsList } = this.state;
+ return (
+
+ {
+ this.state.currentComment ?
+
+ :
+
+ }
+
+ );
+ }
+}
+
+CommentPanel.propTypes = CommentPanelPropTypes;
+
+export default CommentPanel;
diff --git a/frontend/src/components/file-view/comment-widget/comment-body-header.css b/frontend/src/components/file-view/comment-widget/comment-body-header.css
new file mode 100644
index 0000000000..e3335fa4fc
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-body-header.css
@@ -0,0 +1,28 @@
+.comments-panel-body__header {
+ display: flex;
+ flex-direction: column;
+ padding: 0 16px;
+}
+
+.comments-panel-body__header .comments-types-count {
+ height: 38px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 0;
+}
+
+.comments-panel-body__header .comment-type {
+ color: #212529;
+ font-size: 12px;
+}
+
+.comments-panel-body__header .comment-type {
+ color: #212529;
+ font-size: 12px;
+}
+
+.comments-panel-body__header .comment-count-tip {
+ color: #999;
+ font-size: 12px;
+}
diff --git a/frontend/src/components/file-view/comment-widget/comment-body-header.js b/frontend/src/components/file-view/comment-widget/comment-body-header.js
new file mode 100644
index 0000000000..fc988bc44d
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-body-header.js
@@ -0,0 +1,59 @@
+import React, { useState } from 'react';
+import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
+import { gettext } from '../../../utils/constants';
+
+import './comment-body-header.css';
+
+const t = gettext;
+
+const CommentBodyHeader = ({ commentList = [], commentType, setCommentType }) => {
+ const [isDropdownOpen, setDropdownOpen] = useState(false);
+
+ let commentTip = null;
+ if (commentList.length === 1) {
+ commentTip = gettext('Total {comments_count} comment');
+ commentTip = commentTip.replace('{comments_count}', commentList.length);
+ }
+ if (commentList.length > 1) {
+ commentTip = gettext('Total {comments_count} comments');
+ commentTip = commentTip.replace('{comments_count}', commentList.length);
+ }
+
+ const getText = (type) => {
+ switch (type) {
+ case 'All comments':
+ return gettext('All comments');
+ case 'Resolved comments':
+ return gettext('Resolved comments');
+ case 'Unresolved comments':
+ return gettext('Unresolved comments');
+ default:
+ return gettext('All comments');
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default CommentBodyHeader;
+
diff --git a/frontend/src/components/file-view/comment-widget/comment-delete-popover.css b/frontend/src/components/file-view/comment-widget/comment-delete-popover.css
new file mode 100644
index 0000000000..beb7018ce6
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-delete-popover.css
@@ -0,0 +1,9 @@
+.comment-delete-popover .comment-delete-popover-container {
+ padding: 16px;
+}
+
+.comment-delete-popover .comment-delete-popover-container .delete-control {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+}
diff --git a/frontend/src/components/file-view/comment-widget/comment-delete-popover.js b/frontend/src/components/file-view/comment-widget/comment-delete-popover.js
new file mode 100644
index 0000000000..2e709d470d
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-delete-popover.js
@@ -0,0 +1,71 @@
+import React, { useCallback, useRef, useEffect } from 'react';
+import isHotkey from 'is-hotkey';
+import { Button, UncontrolledPopover } from 'reactstrap';
+import { getEventClassName } from '@/utils/dom';
+import { gettext } from '../../../utils/constants';
+
+import './comment-delete-popover.css';
+
+const CommentDeletePopover = ({ type, setIsShowDeletePopover, deleteConfirm, targetId, parentDom = document.body }) => {
+
+ const popoverRef = useRef(null);
+
+ const hide = useCallback((event) => {
+ if (popoverRef.current && !getEventClassName(event).includes('popover') && !popoverRef.current.contains(event.target)) {
+ setIsShowDeletePopover(false);
+ event.preventDefault();
+ event.stopPropagation();
+ return false;
+ }
+ }, [setIsShowDeletePopover]);
+
+ const onHotKey = useCallback((event) => {
+ if (isHotkey('esc', event)) {
+ event.preventDefault();
+ setIsShowDeletePopover(false);
+ }
+ }, [setIsShowDeletePopover]);
+
+ useEffect(() => {
+ document.addEventListener('click', hide, true);
+ document.addEventListener('keydown', onHotKey);
+ return () => {
+ document.removeEventListener('click', hide, true);
+ document.removeEventListener('keydown', onHotKey);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const onDeleteCancel = useCallback((event) => {
+ event.stopPropagation();
+ setIsShowDeletePopover(false);
+ }, [setIsShowDeletePopover]);
+
+ const handleConfirm = useCallback((event) => {
+ event.stopPropagation();
+ deleteConfirm();
+ }, [deleteConfirm]);
+
+ return (
+ event.stopPropagation()}
+ placement="left"
+ className='comment-delete-popover'
+ isOpen={true}
+ >
+
+
+ {type === 'comment' ? gettext('Are you sure to delete this comment?') : gettext('Are you sure to delete this reply?')}
+
+
+
+
+
+
+
+ );
+};
+
+export default CommentDeletePopover;
diff --git a/frontend/src/components/file-view/comment-widget/comment-item-readonly.js b/frontend/src/components/file-view/comment-widget/comment-item-readonly.js
new file mode 100644
index 0000000000..6ef2845d60
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-item-readonly.js
@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { processor } from '@seafile/seafile-editor';
+
+const commentItemPropTypes = {
+ time: PropTypes.string,
+ item: PropTypes.object,
+ showResolvedComment: PropTypes.bool,
+ onClickComment: PropTypes.func,
+};
+
+class CommentItemReadOnly extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ html: '',
+ newComment: this.props.item.comment,
+ };
+ }
+
+ convertComment = (mdFile) => {
+ processor.process(mdFile).then((result) => {
+ let html = String(result);
+ this.setState({ html: html });
+ });
+ };
+
+ handleCommentChange = (event) => {
+ this.setState({
+ newComment: event.target.value,
+ });
+ };
+
+ componentWillMount() {
+ this.convertComment(this.props.item.comment);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.convertComment(nextProps.item.comment);
+ }
+
+ onCommentContentClick = (e) => {
+ // click participant link, page shouldn't jump
+ if (e.target.nodeName !== 'A') return;
+ const preNode = e.target.previousSibling;
+ if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
+ e.preventDefault();
+ }
+ };
+
+ render() {
+ const item = this.props.item;
+ const replies = item.replies || [];
+ const lastReply = replies[replies.length - 1];
+ return (
+ this.props.onClickComment(item)}>
+
+

+
+
{item.user_name}
+
+ {this.props.time}
+ {item.resolved &&
+
+ }
+
+
+
+ this.onCommentContentClick(e)}
+ >
+
+ {replies.length > 0 &&
+
+
+
+ {replies.length}
+
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+CommentItemReadOnly.propTypes = commentItemPropTypes;
+
+export default CommentItemReadOnly;
diff --git a/frontend/src/components/file-view/comment-widget/comment-item.js b/frontend/src/components/file-view/comment-widget/comment-item.js
new file mode 100644
index 0000000000..6aa126ec84
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-item.js
@@ -0,0 +1,210 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import dayjs from 'dayjs';
+import { processor } from '@seafile/seafile-editor';
+import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
+import { gettext } from '../../../utils/constants';
+import CommentDeletePopover from './comment-delete-popover';
+
+const commentItemPropTypes = {
+ time: PropTypes.string,
+ item: PropTypes.object,
+ deleteComment: PropTypes.func,
+ showResolvedComment: PropTypes.bool,
+ editComment: PropTypes.func,
+};
+
+const { username } = window.app.pageOptions;
+
+class CommentItem extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropdownOpen: false,
+ html: '',
+ newComment: this.props.item.comment,
+ editable: false,
+ isShowDeletePopover: false,
+ };
+ }
+
+ toggleDropDownMenu = () => {
+ this.setState({
+ dropdownOpen: !this.state.dropdownOpen,
+ });
+ };
+
+ convertComment = (mdFile) => {
+ processor.process(mdFile).then((result) => {
+ let html = String(result);
+ this.setState({ html: html });
+ });
+ };
+
+ toggleEditComment = () => {
+ this.setState({
+ editable: !this.state.editable
+ });
+ };
+
+ updateComment = (event) => {
+ const newComment = this.state.newComment.trim();
+ if (this.props.item.comment !== newComment) {
+ this.props.editComment(this.props.item, newComment);
+ }
+ this.toggleEditComment();
+ };
+
+ handleCommentChange = (event) => {
+ this.setState({
+ newComment: event.target.value,
+ });
+ };
+
+ onCommentContentClick = (e) => {
+ // click participant link, page shouldn't jump
+ if (e.target.nodeName !== 'A') return;
+ const preNode = e.target.previousSibling;
+ if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
+ e.preventDefault();
+ }
+ };
+
+ componentWillMount() {
+ this.convertComment(this.props.item.comment);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.convertComment(nextProps.item.comment);
+ }
+
+ onCommentClick = (e) => {
+ // click participant link, page shouldn't jump
+ if (e.target.nodeName !== 'A') return;
+ const preNode = e.target.previousSibling;
+ if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
+ e.preventDefault();
+ }
+ };
+
+ toggleShowDeletePopover = () => {
+ this.setState({
+ isShowDeletePopover: !this.state.isShowDeletePopover
+ });
+ };
+
+ render() {
+ const item = this.props.item;
+ let oldTime = (new Date(item.created_at)).getTime();
+ let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
+ const commentOpToolsId = `commentOpTools_${item?.id}`;
+ if (this.state.editable) {
+ return (
+
+
+

+
+
{item.user_name}
+
{time}
+
+
+
+
+ {' '}
+
+
+
+ );
+ }
+ return (
+
+
+

+
+
{item.user_name}
+
+ {time}
+ {item.resolved &&
+
+ }
+
+
+ {(item.user_email === username) &&
+
+ }
+
+ this.onCommentContentClick(e)}
+ >
+
+ {this.state.isShowDeletePopover && (
+ this.props.deleteComment(this.props.item)}
+ setIsShowDeletePopover={this.toggleShowDeletePopover}
+ />
+ )}
+
+ );
+ }
+}
+
+CommentItem.propTypes = commentItemPropTypes;
+
+export default CommentItem;
diff --git a/frontend/src/components/file-view/comment-widget/comment-list.js b/frontend/src/components/file-view/comment-widget/comment-list.js
new file mode 100644
index 0000000000..e745afa019
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/comment-list.js
@@ -0,0 +1,251 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import dayjs from 'dayjs';
+import classname from 'classnames';
+import deepCopy from 'deep-copy';
+import { gettext } from '../../../utils/constants';
+import { seafileAPI } from '../../../utils/seafile-api';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../toast';
+import Loading from '../../loading';
+import { MentionsInput, Mention } from 'react-mentions';
+import { defaultStyle } from '../../../css/react-mentions-default-style';
+import CommentItemReadOnly from './comment-item-readonly';
+import CommentBodyHeader from './comment-body-header';
+
+const { username, repoID, filePath } = window.app.pageOptions;
+
+const CommentListPropTypes = {
+ toggleCommentList: PropTypes.func.isRequired,
+ participants: PropTypes.array,
+ onParticipantsChange: PropTypes.func,
+};
+
+class CommentList extends React.Component {
+
+ constructor(props) {
+ super(props);
+ const initStyle = defaultStyle;
+ initStyle['&multiLine']['input'].minHeight = 40;
+ initStyle['&multiLine']['input'].height = 40;
+ initStyle['&multiLine']['input'].borderRadius = '5px';
+ initStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
+ initStyle['&multiLine']['input'].lineHeight = '24px';
+ this.state = {
+ comment: '',
+ isInputFocus: false,
+ defaultStyle: initStyle,
+ commentType: 'All comments',
+ };
+ this.toBeAddedParticipant = [];
+ this.commentListScrollRef = React.createRef();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.commentsList.length < this.props.commentsList.length) {
+ let container = this.commentListScrollRef.current;
+ if (container) {
+ container.scrollTop = container.scrollHeight + 100;
+ }
+ }
+ }
+
+ onKeyDown = (e) => {
+ if (e.key == 'Enter') {
+ e.preventDefault();
+ this.onSubmit();
+ }
+ };
+
+ handleCommentChange = (event) => {
+ this.setState({ comment: event.target.value });
+ };
+
+ onSubmit = () => {
+ if (!this.state.comment.trim()) {
+ return;
+ }
+ this.addParticipant(username);
+ if (this.toBeAddedParticipant.length === 0) {
+ this.props.addComment(this.state.comment.trim());
+ this.setState({ comment: '' });
+ } else {
+ seafileAPI.addFileParticipants(repoID, filePath, this.toBeAddedParticipant).then((res) => {
+ this.onParticipantsChange(repoID, filePath);
+ this.toBeAddedParticipant = [];
+ this.props.addComment(this.state.comment.trim());
+ this.setState({ comment: '' });
+ }).catch((err) => {
+ toaster.danger(Utils.getErrorMsg(err));
+ });
+ }
+ };
+
+ onParticipantsChange = () => {
+ if (this.props.onParticipantsChange) {
+ this.props.onParticipantsChange();
+ } else {
+ this.getParticipants();
+ }
+ };
+
+ checkParticipant = (email) => {
+ return this.props.participants.map((participant) => {return participant.email;}).includes(email);
+ };
+
+ addParticipant = (email) => {
+ if (this.checkParticipant(email)) return;
+ this.toBeAddedParticipant.push(email);
+ };
+
+ renderUserSuggestion = (entry, search, highlightedDisplay, index, focused) => {
+ return (
+
+
+

+
{highlightedDisplay}
+
+
+ );
+ };
+
+ onInputFocus = () => {
+ if (this.inpurBlurTimer) {
+ clearTimeout(this.inpurBlurTimer);
+ this.inpurBlurTimer = null;
+ }
+ if (this.state.isInputFocus === false) {
+ let defaultStyle = this.state.defaultStyle;
+ defaultStyle['&multiLine']['input'].maxHeight = 90;
+ defaultStyle['&multiLine']['input'].minHeight = 90;
+ defaultStyle['&multiLine']['input'].height = 90;
+ defaultStyle['&multiLine']['input'].borderBottom = 'none';
+ defaultStyle['&multiLine']['input'].borderRadius = '5px 5px 0 0';
+ defaultStyle['&multiLine']['input'].overflowY = 'auto';
+ defaultStyle['&multiLine']['input'].lineHeight = 'default';
+ this.setState({
+ isInputFocus: true,
+ defaultStyle: deepCopy(defaultStyle),
+ });
+ }
+ };
+
+ onInputBlur = () => {
+ if (this.state.isInputFocus === true) {
+ this.inpurBlurTimer = setTimeout(() => {
+ let defaultStyle = this.state.defaultStyle;
+ defaultStyle['&multiLine']['input'].minHeight = 40;
+ defaultStyle['&multiLine']['input'].height = 40;
+ defaultStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
+ defaultStyle['&multiLine']['input'].borderRadius = '5px';
+ defaultStyle['&multiLine']['input'].lineHeight = '24px';
+ this.setState({
+ isInputFocus: false,
+ defaultStyle: deepCopy(defaultStyle),
+ });
+ }, 100);
+ }
+ };
+
+ setCommentType = (e, commentType) => {
+ this.setState({ commentType });
+ };
+
+ getFilteredComments = () => {
+ const { commentsList } = this.props;
+ if (this.state.commentType === 'All comments') {
+ return commentsList;
+ } else if (this.state.commentType === 'Resolved comments') {
+ return commentsList.filter((comment) => comment.resolved);
+ } else if (this.state.commentType === 'Unresolved comments') {
+ return commentsList.filter((comment) => !comment.resolved);
+ }
+ return commentsList;
+ };
+
+ render() {
+ const { commentsList, isLoading } = this.props;
+ const filteredComments = this.getFilteredComments();
+ return (
+
+
+
+
+ {gettext('Comments')}
+
+
+
+
+
+
+
+
+
+
+ {isLoading &&
}
+ {!isLoading && filteredComments.length > 0 &&
+
+ {filteredComments.map((item) => {
+ let oldTime = (new Date(item.created_at)).getTime();
+ let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
+ return (
+
+ );
+ })}
+
+ }
+ {!isLoading && filteredComments.length === 0 &&
+
{gettext('No comment yet.')}
+ }
+
+
+
+ `@${display}`}
+ data={this.props.relatedUsers}
+ renderSuggestion={this.renderUserSuggestion}
+ onAdd={(id, display) => {this.addParticipant(id);}}
+ appendSpaceOnAdd={true}
+ />
+
+ {this.state.isInputFocus &&
+
+ }
+
+
+ );
+ }
+}
+
+CommentList.propTypes = CommentListPropTypes;
+
+export default CommentList;
diff --git a/frontend/src/components/file-view/comment-widget/reply-item.js b/frontend/src/components/file-view/comment-widget/reply-item.js
new file mode 100644
index 0000000000..d832c5a780
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/reply-item.js
@@ -0,0 +1,175 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { processor } from '@seafile/seafile-editor';
+import { Button, Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
+import { gettext } from '../../../utils/constants';
+import CommentDeletePopover from './comment-delete-popover';
+
+const { username } = window.app.pageOptions;
+
+const commentItemPropTypes = {
+ time: PropTypes.string,
+ item: PropTypes.object,
+ deleteReply: PropTypes.func,
+ showResolvedComment: PropTypes.bool,
+ editComment: PropTypes.func,
+};
+
+class ReplyItem extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropdownOpen: false,
+ html: '',
+ newReply: this.props.item.reply,
+ editable: false,
+ isShowDeletePopover: false,
+ };
+ }
+
+ componentWillMount() {
+ this.convertComment(this.props.item.reply);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.convertComment(nextProps.item.reply);
+ }
+
+ toggleDropDownMenu = () => {
+ this.setState({
+ dropdownOpen: !this.state.dropdownOpen,
+ });
+ };
+
+ convertComment = (mdFile) => {
+ processor.process(mdFile).then((result) => {
+ let html = String(result);
+ this.setState({ html: html });
+ });
+ };
+
+ toggleEditComment = () => {
+ this.setState({
+ editable: !this.state.editable
+ });
+ };
+
+ updateComment = () => {
+ const newReply = this.state.newReply.trim();
+ if (this.props.item.reply !== newReply) {
+ this.props.updateReply(newReply);
+ }
+ this.toggleEditComment();
+ };
+
+ handleCommentChange = (event) => {
+ this.setState({
+ newReply: event.target.value,
+ });
+ };
+
+ onCommentContentClick = (e) => {
+ // click participant link, page shouldn't jump
+ if (e.target.nodeName !== 'A') return;
+ const preNode = e.target.previousSibling;
+ if (preNode && preNode.nodeType === 3 && preNode.nodeValue.slice(-1) === '@') {
+ e.preventDefault();
+ }
+ };
+
+ toggleShowDeletePopover = () => {
+ this.setState({
+ isShowDeletePopover: !this.state.isShowDeletePopover
+ });
+ };
+
+ render() {
+ const item = this.props.item;
+ const replyOpToolsId = `commentOpTools_${item?.id}`;
+ if (this.state.editable) {
+ return (
+
+
+

+
+
{item.user_name}
+
{this.props.time}
+
+
+
+
+ {' '}
+
+
+
+ );
+ }
+ return (
+
+
+

+
+
{item.user_name}
+
{this.props.time}
+
+ {(item.user_email === username) &&
+
+
+
+
+ {gettext('Delete')}
+
+
+ {gettext('Edit')}
+
+
+
+ }
+
+ this.onCommentContentClick(e)}
+ >
+
+ {this.state.isShowDeletePopover && (
+
+ )}
+
+ );
+ }
+}
+
+ReplyItem.propTypes = commentItemPropTypes;
+
+export default ReplyItem;
diff --git a/frontend/src/components/file-view/comment-widget/reply-list.js b/frontend/src/components/file-view/comment-widget/reply-list.js
new file mode 100644
index 0000000000..2172635ad8
--- /dev/null
+++ b/frontend/src/components/file-view/comment-widget/reply-list.js
@@ -0,0 +1,234 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import dayjs from 'dayjs';
+import classname from 'classnames';
+import deepCopy from 'deep-copy';
+import { gettext } from '../../../utils/constants';
+import { seafileAPI } from '../../../utils/seafile-api';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../toast';
+import { MentionsInput, Mention } from 'react-mentions';
+import { defaultStyle } from '../../../css/react-mentions-default-style';
+import CommentItem from './comment-item';
+import ReplyItem from './reply-item';
+
+const { username, repoID, filePath } = window.app.pageOptions;
+
+const ReplyListPropTypes = {
+ toggleCommentList: PropTypes.func.isRequired,
+ participants: PropTypes.array,
+ onParticipantsChange: PropTypes.func,
+ currentComment: PropTypes.object,
+ clearCurrentComment: PropTypes.func,
+ commentsList: PropTypes.array,
+};
+
+class ReplyList extends React.Component {
+
+ constructor(props) {
+ super(props);
+ const initStyle = defaultStyle;
+ initStyle['&multiLine']['input'].minHeight = 40;
+ initStyle['&multiLine']['input'].height = 40;
+ initStyle['&multiLine']['input'].borderRadius = '5px';
+ initStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
+ initStyle['&multiLine']['input'].lineHeight = '24px';
+ this.state = {
+ comment: '',
+ isInputFocus: false,
+ defaultStyle: initStyle,
+ };
+ this.toBeAddedParticipant = [];
+ this.commentListScrollRef = React.createRef();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.currentComment.replies.length < this.props.currentComment.replies.length) {
+ let container = this.commentListScrollRef.current;
+ if (container) {
+ container.scrollTop = container.scrollHeight + 100;
+ }
+ }
+ }
+
+ onKeyDown = (e) => {
+ if (e.key == 'Enter') {
+ e.preventDefault();
+ this.onSubmit();
+ }
+ };
+
+ handleCommentChange = (event) => {
+ this.setState({ comment: event.target.value });
+ };
+
+ onSubmit = () => {
+ if (!this.state.comment.trim()) return;
+ this.addParticipant(username);
+ if (this.toBeAddedParticipant.length === 0) {
+ this.props.addReply(this.state.comment.trim());
+ this.setState({ comment: '' });
+ } else {
+ seafileAPI.addFileParticipants(repoID, filePath, this.toBeAddedParticipant).then((res) => {
+ this.onParticipantsChange(repoID, filePath);
+ this.toBeAddedParticipant = [];
+ this.props.addReply(this.state.comment.trim());
+ this.setState({ comment: '' });
+ }).catch((err) => {
+ toaster.danger(Utils.getErrorMsg(err));
+ });
+ }
+ };
+
+ onParticipantsChange = () => {
+ if (this.props.onParticipantsChange) {
+ this.props.onParticipantsChange();
+ } else {
+ this.getParticipants();
+ }
+ };
+
+ checkParticipant = (email) => {
+ return this.props.participants.map((participant) => {return participant.email;}).includes(email);
+ };
+
+ addParticipant = (email) => {
+ if (this.checkParticipant(email)) return;
+ this.toBeAddedParticipant.push(email);
+ };
+
+ renderUserSuggestion = (entry, search, highlightedDisplay, index, focused) => {
+ return (
+
+
+

+
{highlightedDisplay}
+
+
+ );
+ };
+
+ onInputFocus = () => {
+ if (this.inpurBlurTimer) {
+ clearTimeout(this.inpurBlurTimer);
+ this.inpurBlurTimer = null;
+ }
+ if (this.state.isInputFocus === false) {
+ let defaultStyle = this.state.defaultStyle;
+ defaultStyle['&multiLine']['input'].maxHeight = 90;
+ defaultStyle['&multiLine']['input'].minHeight = 90;
+ defaultStyle['&multiLine']['input'].height = 90;
+ defaultStyle['&multiLine']['input'].borderBottom = 'none';
+ defaultStyle['&multiLine']['input'].borderRadius = '5px 5px 0 0';
+ defaultStyle['&multiLine']['input'].overflowY = 'auto';
+ defaultStyle['&multiLine']['input'].lineHeight = 'default';
+ this.setState({
+ isInputFocus: true,
+ defaultStyle: deepCopy(defaultStyle),
+ });
+ }
+ };
+
+ onInputBlur = () => {
+ if (this.state.isInputFocus === true) {
+ this.inpurBlurTimer = setTimeout(() => {
+ let defaultStyle = this.state.defaultStyle;
+ defaultStyle['&multiLine']['input'].minHeight = 40;
+ defaultStyle['&multiLine']['input'].height = 40;
+ defaultStyle['&multiLine']['input'].borderBottom = '1px solid rgb(230, 230, 221)';
+ defaultStyle['&multiLine']['input'].borderRadius = '5px';
+ defaultStyle['&multiLine']['input'].lineHeight = '24px';
+ this.setState({
+ isInputFocus: false,
+ defaultStyle: deepCopy(defaultStyle),
+ });
+ }, 100);
+ }
+ };
+
+ render() {
+ const { currentComment } = this.props;
+ const { replies } = currentComment;
+ return (
+
+
+
+
+
+
+
+
{gettext('Comment details')}
+
+
+
+
+
+
+
+ {replies.map((item) => {
+ let oldTime = (new Date(item.created_at)).getTime();
+ let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
+ return (
+ this.props.deleteReply(currentComment.id, item.id)}
+ updateReply={(replyContent) => this.props.updateReply(currentComment.id, item.id, replyContent)}
+ />
+ );
+ })}
+
+
+
+
+ `@${display}`}
+ data={this.state.relatedUsers}
+ renderSuggestion={this.renderUserSuggestion}
+ onAdd={(id, display) => {this.addParticipant(id);}}
+ appendSpaceOnAdd={true}
+ />
+
+ {this.state.isInputFocus &&
+
+ }
+
+
+ );
+ }
+}
+
+ReplyList.propTypes = ReplyListPropTypes;
+
+export default ReplyList;
diff --git a/frontend/src/components/file-view/file-toolbar.js b/frontend/src/components/file-view/file-toolbar.js
index 9267a5ecd6..8f2c8e7c4b 100644
--- a/frontend/src/components/file-view/file-toolbar.js
+++ b/frontend/src/components/file-view/file-toolbar.js
@@ -18,6 +18,7 @@ const propTypes = {
isSaving: PropTypes.bool,
needSave: PropTypes.bool,
toggleLockFile: PropTypes.func.isRequired,
+ toggleCommentPanel: PropTypes.func.isRequired,
toggleDetailsPanel: PropTypes.func.isRequired,
setImageScale: PropTypes.func,
rotateImage: PropTypes.func
@@ -157,15 +158,6 @@ class FileToolbar extends React.Component {
onClick={this.props.toggleLockFile}
/>
)}
- {showShareBtn && (
-
- )}
-
{(canEditFile && fileType != 'SDoc' && !err) &&
(this.props.isSaving ?
@@ -275,6 +284,11 @@ class FileToolbar extends React.Component {
)}
+ {(
+
+ {gettext('Comment')}
+
+ )}
{gettext('Details')}
diff --git a/frontend/src/components/file-view/file-view.js b/frontend/src/components/file-view/file-view.js
index 8d4ed08750..34e5b50ed2 100644
--- a/frontend/src/components/file-view/file-view.js
+++ b/frontend/src/components/file-view/file-view.js
@@ -10,6 +10,7 @@ import toaster from '../toast';
import IconButton from '../icon-button';
import FileInfo from './file-info';
import FileToolbar from './file-toolbar';
+import CommentPanel from './comment-panel';
import OnlyofficeFileToolbar from './onlyoffice-file-toolbar';
import EmbeddedFileDetails from '../dirent-detail/embedded-file-details';
import { MetadataStatusProvider } from '../../hooks';
@@ -43,6 +44,7 @@ class FileView extends React.Component {
isStarred: isStarred,
isLocked: isLocked,
lockedByMe: lockedByMe,
+ isCommentPanelOpen: false,
isHeaderShown: (storedIsHeaderShown === null) || (storedIsHeaderShown == 'true'),
isDetailsPanelOpen: false
};
@@ -53,8 +55,18 @@ class FileView extends React.Component {
document.getElementById('favicon').href = fileIcon;
}
+ toggleCommentPanel = () => {
+ this.setState({
+ isCommentPanelOpen: !this.state.isCommentPanelOpen,
+ isDetailsPanelOpen: false,
+ });
+ };
+
toggleDetailsPanel = () => {
- this.setState({ isDetailsPanelOpen: !this.state.isDetailsPanelOpen });
+ this.setState({
+ isDetailsPanelOpen: !this.state.isDetailsPanelOpen,
+ isCommentPanelOpen: false,
+ });
};
toggleStar = () => {
@@ -142,6 +154,7 @@ class FileView extends React.Component {
isSaving={this.props.isSaving}
needSave={this.props.needSave}
toggleLockFile={this.toggleLockFile}
+ toggleCommentPanel={this.toggleCommentPanel}
toggleDetailsPanel={this.toggleDetailsPanel}
setImageScale={this.props.setImageScale}
rotateImage={this.props.rotateImage}
@@ -158,6 +171,13 @@ class FileView extends React.Component {
/>
}
{this.props.content}
+ {this.state.isCommentPanelOpen &&
+
+ }
{isDetailsPanelOpen && (
diff --git a/frontend/src/css/comments-list.css b/frontend/src/css/comments-list.css
new file mode 100644
index 0000000000..23de1de802
--- /dev/null
+++ b/frontend/src/css/comments-list.css
@@ -0,0 +1,267 @@
+.seafile-comment {
+ background-color: #fff;
+ display: flex;
+ flex-direction: column;
+}
+
+.seafile-comment-title {
+ border-bottom: 1px solid #e5e5e5;
+ background-color: #fff;
+ height: 46px;
+ line-height: 46px;
+ padding: 0 16px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.seafile-comment-title .comments-panel-header-left {
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.seafile-comment-title .sdoc-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 24px;
+ width: 24px;
+ cursor: pointer;
+ margin-left: 4px;
+ border-radius: 3px;
+}
+
+.seafile-comment-title .sdoc-icon-btn .sdocfont {
+ color: #999;
+}
+
+.seafile-comment-title .sdoc-icon-btn:hover {
+ background-color: #efefef;
+}
+
+.seafile-comment-item {
+ padding: 16px;
+ margin-bottom: 0;
+}
+
+.seafile-comment-page .seafile-comment-item:hover {
+ background-color: #f5f5f5;
+ cursor: pointer;
+}
+
+.seafile-comment-item .seafile-comment-info {
+ padding-bottom: 0.5em;
+ height: 3em;
+ display: flex;
+ justify-content: flex-start;
+}
+
+.seafile-comment-item .seafile-comment-info .comment-author-name {
+ color: #1f1f1f;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 20px;
+}
+
+.seafile-comment-item .seafile-comment-info .avatar {
+ width: 24px;
+ height: 24px;
+}
+
+.seafile-comment-item .seafile-comment-info .comment-author-info {
+ padding-left: 10px;
+ max-width: 75%;
+}
+
+.seafile-comment-item .seafile-comment-info .comment-author-time {
+ display: inline-flex;
+ align-items: center;
+ color: #444746;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+.seafile-comment-item .seafile-comment-info .comment-author-time .comment-success-resolved {
+ color: rgb(71, 184, 129);
+ margin-left: 6px;
+ font-size: 14px;
+}
+
+.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown {
+ margin-left: auto;
+}
+
+.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown .sf-dropdown-toggle {
+ padding: 4px;
+ border-radius: 3px;
+}
+
+.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown .sf-dropdown-toggle:focus,
+.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown .sf-dropdown-toggle:hover {
+ color: #999;
+ background-color: #efefef;
+}
+
+.seafile-comment-item .seafile-comment-content {
+ margin-left: 24px;
+ padding: 5px 10px;
+ border-radius: 4px;
+}
+
+.seafile-comment-item .seafile-comment-content a {
+ color: #212529;
+ cursor: default;
+}
+
+.seafile-comment-item .seafile-comment-content p {
+ word-break: break-all;
+ margin: 0;
+}
+
+.seafile-comment-item .seafile-comment-content ol,
+.seafile-comment-item .seafile-comment-content ul,
+.seafile-comment-item .seafile-comment-content li {
+ margin-left: 10px;
+}
+
+.seafile-comment-item .seafile-comment-content table,
+.seafile-comment-item .seafile-comment-content th,
+.seafile-comment-item .seafile-comment-content td {
+ border: 1px solid #333;
+}
+
+.seafile-comment-footer {
+ padding: 16px 16px 0px;
+ margin-bottom: 16px;
+ border-top: 1px solid #e5e5e5;
+ display: flex;
+ flex-direction: column;
+}
+
+.seafile-comment-footer .add-comment-input,
+.seafile-edit-comment .edit-comment-input {
+ border: 1px solid #e6e6dd;
+ padding: 5px;
+ width: 100%;
+ min-height: 90px;
+ border-radius: 5px;
+ background-color: #fff;
+}
+
+.seafile-comment-footer .add-comment-input {
+ border-bottom: none;
+ border-radius: 5px 5px 0 0;
+}
+
+.seafile-comment-footer .add-comment-input:focus {
+ outline: none;
+}
+
+.seafile-comment-footer .comment-submit-container {
+ border: 1px solid #e6e6dd;
+ border-top: none;
+ border-radius: 0 0 5px 5px;
+ padding: 0px 5px;
+ background: #fff;
+ text-align: right;
+ position: absolute;
+ bottom: 10px;
+ width: 327px;
+}
+
+.seafile-comment-footer .comment-submit-container::before {
+ border-top: 1px solid #e6e6dd;
+ content: '';
+ position: absolute;
+ left: 5px;
+ right: 5px;
+ top: 0;
+}
+
+.seafile-comment-footer .sdoc-comment-btn {
+ color: #ff8e03;
+ cursor: pointer;
+}
+
+.seafile-comment-footer .sdoc-comment-btn:hover {
+ color: #d47604;
+}
+
+.seafile-edit-comment .comment-btn {
+ height: 28px;
+ line-height: 20px;
+}
+
+.seafile-comment-item .comment-footer {
+ color: #666;
+ display: flex;
+ font-size: 14px;
+ margin-left: 35px;
+ margin-top: 16px;
+}
+
+.seafile-comment-item .comment-footer .comments-count {
+ align-items: center;
+ display: flex;
+ position: relative;
+}
+
+.seafile-comment-item .comment-footer .comments-count .comments-count-number {
+ margin-left: 8px;
+ margin-top: -2px;
+}
+
+.seafile-comment-item .comment-footer .comment-author {
+ align-items: normal;
+ margin-left: 20px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.seafile-comment-item .comment-footer .comment-author__avatar {
+ height: 16px;
+ margin-top: -1px;
+ width: 16px;
+}
+
+.seafile-comment-item .comment-footer .comment-author__latest-reply {
+ margin-left: 8px;
+}
+
+.seafile-comment-item .comment-footer .comment-author__latest-reply p {
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 220px;
+}
+
+.seafile-comment-item .comment-footer .comment-author__avatar img {
+ border-radius: 50%;
+}
+
+.comment-participant-item.active,
+.comment-participant-item:hover {
+ cursor: pointer;
+}
+
+.comment-participant-item .comment-participant-container {
+ align-items: center;
+ display: flex;
+ padding: 0 8px;
+}
+
+.comment-participant-item .comment-participant-avatar {
+ border-radius: 50%;
+ height: 16px;
+ vertical-align: middle;
+ width: 16px;
+}
+
+.comment-participant-item .comment-participant-name {
+ flex: 1 1;
+ font-size: 14px;
+ margin-left: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/frontend/src/css/file-view.css b/frontend/src/css/file-view.css
index 1f589e7295..ea64eb3b47 100644
--- a/frontend/src/css/file-view.css
+++ b/frontend/src/css/file-view.css
@@ -52,6 +52,10 @@ body {
align-items: center;
}
+.file-view-header .file-toolbar-btn .sdocfont {
+ color: #666;
+}
+
.file-view-header .file-toolbar-btn .seafile-multicolor-icon {
fill: #666;
}
@@ -145,7 +149,7 @@ body {
@media (min-width: 768px) {
.file-view-body .seafile-comment {
- width: 300px;
+ width: 360px;
border-left: 1px solid #e6e6dd;
}
}
diff --git a/frontend/src/css/react-mentions-default-style.js b/frontend/src/css/react-mentions-default-style.js
new file mode 100644
index 0000000000..506def3279
--- /dev/null
+++ b/frontend/src/css/react-mentions-default-style.js
@@ -0,0 +1,66 @@
+const defaultStyle = {
+ control: {
+ backgroundColor: '#fff',
+ fontSize: 14,
+ fontWeight: 'normal',
+ },
+ highlighter: {
+ overflow: 'hidden',
+ },
+ input: {
+ margin: 0,
+ },
+ '&singleLine': {
+ control: {
+ display: 'inline-block',
+ width: 130,
+ },
+ highlighter: {
+ padding: 1,
+ border: '2px inset transparent',
+ },
+ input: {
+ padding: 1,
+ border: '2px inset',
+ },
+ },
+ '&multiLine': {
+ control: {
+ },
+ highlighter: {
+ padding: 9,
+ },
+ input: {
+ padding: '8px 6px 20px 6px',
+ minHeight: 90,
+ height: 90,
+ border: '1px solid #e6e6dd',
+ overfflowY: 'auto',
+ outline: 'none',
+ },
+ },
+ suggestions: {
+ list: {
+ backgroundColor: 'white',
+ border: '1px solid rgba(0,0,0,0.15)',
+ fontSize: 14,
+ maxHeight: 200,
+ overflow: 'auto',
+ position: 'absolute',
+ bottom: 14,
+ width: '150px',
+ },
+ item: {
+ width: 'auto',
+ padding: '5px 0px',
+ overflowX: 'auto',
+ borderBottom: '1px solid rgba(0, 0, 0, 0)',
+ '&focused': {
+ backgroundColor: '#f5f5f5',
+ fontWeight: '400',
+ },
+ },
+ },
+};
+
+export { defaultStyle };
diff --git a/frontend/src/utils/seafile-api.js b/frontend/src/utils/seafile-api.js
index 3803f94348..faa330c76f 100644
--- a/frontend/src/utils/seafile-api.js
+++ b/frontend/src/utils/seafile-api.js
@@ -1277,47 +1277,62 @@ class SeafileAPI {
return this.req.post(url, form);
}
- // file commit api
- deleteComment(repoID, commentID) {
- const url = this.server + '/api2/repos/' + repoID + '/file/comments/' + commentID + '/';
- return this.req.delete(url);
- }
-
- listComments(repoID, filePath, resolved) {
- const path = encodeURIComponent(filePath);
- let url = this.server + '/api2/repos/' + repoID + '/file/comments/?p=' + path;
- if (resolved) {
- url = url + '&resolved=' + resolved;
- }
+ listComments(repoID, fileUuid) {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/`;
return this.req.get(url);
}
- postComment(repoID, filePath, comment, detail) {
- const path = encodeURIComponent(filePath);
- const url = this.server + '/api2/repos/' + repoID + '/file/comments/?p=' + path;
+ postComment(repoID, fileUuid, comment) {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/`;
let form = new FormData();
form.append('comment', comment);
- if (detail) {
- form.append('detail', detail);
- }
return this._sendPostRequest(url, form);
}
- getCommentsNumber(repoID, path) {
- const p = encodeURIComponent(path);
- const url = this.server + '/api2/repos/' + repoID + '/file/comments/counts/?p=' + p;
- return this.req.get(url);
+ deleteComment(repoID, fileUuid, commentID) {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/`;
+ return this.req.delete(url);
}
- updateComment(repoID, commentID, resolved, detail, comment) {
- const url = this.server + '/api2/repos/' + repoID + '/file/comments/' + commentID + '/';
+ updateComment(repoID, fileUuid, commentID, resolved, detail, comment) {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/`;
let params = {};
- if (resolved) params.resolved = resolved;
- if (detail) params.detail = detail;
- if (comment) params.comment = comment;
+ if (resolved) {
+ params.resolved = resolved;
+ }
+ if (detail) {
+ params.detail = detail;
+ }
+ if (comment) {
+ params.comment = comment;
+ }
return this.req.put(url, params);
}
+ listReplies = (repoID, fileUuid, commentID) => {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/`;
+ return this.req.get(url);
+ };
+
+ insertReply = (repoID, fileUuid, commentID, replyData) => {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/`;
+ let form = new FormData();
+ for (let key in replyData) {
+ form.append(key, replyData[key]);
+ }
+ return this._sendPostRequest(url, form);
+ };
+
+ deleteReply = (repoID, fileUuid, commentID, replyId) => {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/${replyId}/`;
+ return this.req.delete(url);
+ };
+
+ updateReply = (repoID, fileUuid, commentID, replyId, replyData) => {
+ let url = `${this.server}/api/v2.1/repos/${repoID}/file/${fileUuid}/comments/${commentID}/replies/${replyId}/`;
+ return this.req.put(url, replyData);
+ };
+
// starred
listStarredItems() {
const url = this.server + '/api/v2.1/starred-items/';
diff --git a/seahub/api2/endpoints/file_comments.py b/seahub/api2/endpoints/file_comments.py
new file mode 100644
index 0000000000..376a4dea90
--- /dev/null
+++ b/seahub/api2/endpoints/file_comments.py
@@ -0,0 +1,383 @@
+# Copyright (c) 2012-2016 Seafile Ltd.
+import logging
+import json
+from rest_framework import status
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from seaserv import seafile_api
+from pysearpc import SearpcError
+from django.urls import reverse
+from django.utils import timezone
+
+from seahub.api2.authentication import TokenAuthentication
+from seahub.api2.permissions import IsRepoAccessible
+from seahub.api2.throttling import UserRateThrottle
+from seahub.api2.utils import api_error, user_to_dict, to_python_boolean
+from seahub.avatar.settings import AVATAR_DEFAULT_SIZE
+from seahub.base.models import FileComment
+from seahub.utils.repo import get_repo_owner
+from seahub.signals import comment_file_successful
+from seahub.api2.endpoints.utils import generate_links_header_for_paginator
+from seahub.views import check_folder_permission
+
+from seahub.seadoc.models import SeadocCommentReply, SeadocNotification
+from seahub.file_participants.models import FileParticipant
+from seahub.utils.timeutils import utc_to_local, datetime_to_isoformat_timestr, datetime_to_timestamp
+
+logger = logging.getLogger(__name__)
+
+
+class FileCommentsView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, IsRepoAccessible)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id, file_uuid):
+ """list comments of a sdoc, same as FileCommentsView
+ """
+
+ resolved = request.GET.get('resolved', None)
+ if resolved not in ('true', 'false', None):
+ error_msg = 'resolved invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ start = None
+ end = None
+ page = request.GET.get('page', '')
+ if page:
+ try:
+ page = int(request.GET.get('page', '1'))
+ per_page = int(request.GET.get('per_page', '25'))
+ except ValueError:
+ page = 1
+ per_page = 25
+ start = (page - 1) * per_page
+ end = page * per_page
+
+ total_count = FileComment.objects.list_by_file_uuid(file_uuid).count()
+ comments = []
+
+ if resolved is None:
+ file_comments = FileComment.objects.list_by_file_uuid(file_uuid)[start: end]
+ else:
+ comment_resolved = to_python_boolean(resolved)
+ file_comments = (
+ FileComment.objects
+ .list_by_file_uuid(file_uuid)
+ .filter(resolved=comment_resolved)
+ [start:end]
+ )
+
+ reply_queryset = SeadocCommentReply.objects.list_by_doc_uuid(file_uuid)
+
+ for file_comment in file_comments:
+ comment = file_comment.to_dict(reply_queryset)
+ comment.update(user_to_dict(file_comment.author, request=request))
+ comments.append(comment)
+
+ result = {'comments': comments, 'total_count': total_count}
+ return Response(result)
+
+ def post(self, request, repo_id, file_uuid):
+
+ comment = request.data.get('comment', '')
+ detail = request.data.get('detail', '')
+ author = request.data.get('author', '')
+ username = request.user.username
+ if comment is None:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'comment invalid.')
+ if not username:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'author invalid.')
+
+ file_comment = FileComment.objects.add_by_file_uuid(
+ file_uuid, username, comment, detail)
+ comment = file_comment.to_dict()
+ comment.update(user_to_dict(username, request=request))
+
+ # notification
+ to_users = set()
+ participant_queryset = FileParticipant.objects.get_participants(file_uuid)
+ for participant in participant_queryset:
+ to_users.add(participant.username)
+ to_users.discard(username) # remove author
+ to_users = list(to_users)
+ detail = {
+ 'author': username,
+ 'comment_id': int(file_comment.id),
+ 'comment': str(file_comment.comment),
+ 'msg_type': 'comment',
+ 'created_at': datetime_to_isoformat_timestr(file_comment.created_at),
+ 'updated_at': datetime_to_isoformat_timestr(file_comment.updated_at),
+ }
+ detail.update(user_to_dict(username, request=request))
+
+ new_notifications = []
+ for to_user in to_users:
+ new_notifications.append(
+ SeadocNotification(
+ doc_uuid=file_uuid,
+ username=to_user,
+ msg_type='comment',
+ detail=json.dumps(detail),
+ ))
+ try:
+ SeadocNotification.objects.bulk_create(new_notifications)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+ #
+ notification = detail
+ notification['to_users'] = to_users
+ comment['notification'] = notification
+ return Response(comment)
+
+
+class FileCommentView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, IsRepoAccessible)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id, file_uuid, comment_id):
+ # resource check
+ try:
+ file_comment = FileComment.objects.get(pk=comment_id)
+ except FileComment.DoesNotExist:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'Wrong comment id')
+ if str(file_comment.uuid.uuid) != file_uuid:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found: %s' % comment_id)
+
+ comment = file_comment.to_dict()
+ comment.update(user_to_dict(
+ file_comment.author, request=request))
+ return Response(comment)
+
+ def delete(self, request, repo_id, file_uuid, comment_id):
+
+ # resource check
+ try:
+ file_comment = FileComment.objects.get(pk=comment_id)
+ except FileComment.DoesNotExist:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'Wrong comment id')
+ if str(file_comment.uuid.uuid) != file_uuid:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found: %s' % comment_id)
+
+ file_comment.delete()
+ SeadocCommentReply.objects.filter(comment_id=comment_id).delete()
+ return Response({'success': True})
+
+ def put(self, request, repo_id, file_uuid, comment_id):
+
+ # argument check
+ resolved = request.data.get('resolved')
+ if resolved not in ('true', 'false', None):
+ error_msg = 'resolved invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+ detail = request.data.get('detail')
+ comment = request.data.get('comment')
+
+ # resource check
+ try:
+ file_comment = FileComment.objects.get(pk=comment_id)
+ except FileComment.DoesNotExist:
+ error_msg = 'FileComment %s not found.' % comment_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+ if str(file_comment.uuid.uuid) != file_uuid:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found: %s' % comment_id)
+
+ if resolved is not None:
+ # do not refresh updated_at
+ comment_resolved = to_python_boolean(resolved)
+ file_comment.resolved = comment_resolved
+ file_comment.save(update_fields=['resolved'])
+
+ if detail is not None or comment is not None:
+ if detail is not None:
+ file_comment.detail = detail
+ if comment is not None:
+ file_comment.comment = comment
+ # save
+ file_comment.updated_at = timezone.now()
+ file_comment.save()
+
+ comment = file_comment.to_dict()
+ comment.update(user_to_dict(file_comment.author, request=request))
+ return Response(comment)
+
+
+class FileCommentRepliesView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, IsRepoAccessible)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id, file_uuid, comment_id):
+ start = None
+ end = None
+ page = request.GET.get('page', '')
+ if page:
+ try:
+ page = int(request.GET.get('page', '1'))
+ per_page = int(request.GET.get('per_page', '25'))
+ except ValueError:
+ page = 1
+ per_page = 25
+ start = (page - 1) * per_page
+ end = page * per_page
+
+ # resource check
+ file_comment = FileComment.objects.filter(
+ id=comment_id, uuid=file_uuid).first()
+ if not file_comment:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
+
+ total_count = SeadocCommentReply.objects.list_by_comment_id(comment_id).count()
+ replies = []
+ reply_queryset = SeadocCommentReply.objects.list_by_comment_id(comment_id)[start: end]
+ for reply in reply_queryset:
+ data = reply.to_dict()
+ data.update(
+ user_to_dict(reply.author, request=request))
+ replies.append(data)
+
+ result = {'replies': replies, 'total_count': total_count}
+ return Response(result)
+
+ def post(self, request, repo_id, file_uuid, comment_id):
+ """post a comment reply of a sdoc.
+ """
+
+ reply_content = request.data.get('reply', '')
+ type_content = request.data.get('type', 'reply')
+ author = request.data.get('author', '')
+ username = request.user.username or author
+ if reply_content is None:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'reply invalid.')
+ if not username:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'author invalid.')
+
+ # resource check
+ file_comment = FileComment.objects.filter(
+ id=comment_id, uuid=file_uuid).first()
+ if not file_comment:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
+
+ reply = SeadocCommentReply.objects.create(
+ author=username,
+ reply=str(reply_content),
+ type=str(type_content),
+ comment_id=comment_id,
+ doc_uuid=file_uuid,
+ )
+ data = reply.to_dict()
+ data.update(
+ user_to_dict(reply.author, request=request))
+
+ # notification
+ to_users = set()
+ participant_queryset = FileParticipant.objects.get_participants(file_uuid)
+ for participant in participant_queryset:
+ to_users.add(participant.username)
+ to_users.discard(username) # remove author
+ to_users = list(to_users)
+ detail = {
+ 'author': username,
+ 'comment_id': int(comment_id),
+ 'reply_id': reply.pk,
+ 'reply': str(reply_content),
+ 'msg_type': 'reply',
+ 'created_at': datetime_to_isoformat_timestr(reply.created_at),
+ 'updated_at': datetime_to_isoformat_timestr(reply.updated_at),
+ 'is_resolved': type(reply_content) is bool and reply_content is True,
+ 'resolve_comment': file_comment.comment.strip()
+ }
+ detail.update(user_to_dict(username, request=request))
+
+ new_notifications = []
+ for to_user in to_users:
+ new_notifications.append(
+ SeadocNotification(
+ doc_uuid=file_uuid,
+ username=to_user,
+ msg_type='reply',
+ detail=json.dumps(detail),
+ ))
+ try:
+ SeadocNotification.objects.bulk_create(new_notifications)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+ #
+ notification = detail
+ notification['to_users'] = to_users
+ data['notification'] = notification
+ return Response(data)
+
+
+class FileCommentReplyView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, IsRepoAccessible)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id, file_uuid, comment_id, reply_id):
+ """Get a comment reply
+ """
+ # resource check
+ file_comment = FileComment.objects.filter(
+ id=comment_id, uuid=file_uuid).first()
+ if not file_comment:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
+
+ reply = SeadocCommentReply.objects.filter(
+ id=reply_id, doc_uuid=file_uuid, comment_id=comment_id).first()
+ if not reply:
+ return api_error(status.HTTP_404_NOT_FOUND, 'reply not found.')
+
+ data = reply.to_dict()
+ data.update(
+ user_to_dict(reply.author, request=request))
+ return Response(data)
+
+ def delete(self, request, repo_id, file_uuid, comment_id, reply_id):
+
+ # resource check
+ file_comment = FileComment.objects.filter(
+ id=comment_id, uuid=file_uuid).first()
+ if not file_comment:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
+
+ reply = SeadocCommentReply.objects.filter(
+ id=reply_id, doc_uuid=file_uuid, comment_id=comment_id).first()
+ if not reply:
+ return api_error(status.HTTP_404_NOT_FOUND, 'reply not found.')
+ reply.delete()
+ return Response({'success': True})
+
+ def put(self, request, repo_id, file_uuid, comment_id, reply_id):
+ # argument check
+ reply_content = request.data.get('reply')
+ if reply_content is None:
+ return api_error(status.HTTP_400_BAD_REQUEST, 'reply invalid.')
+
+ # resource check
+ file_comment = FileComment.objects.filter(
+ id=comment_id, uuid=file_uuid).first()
+ if not file_comment:
+ return api_error(status.HTTP_404_NOT_FOUND, 'comment not found.')
+
+ reply = SeadocCommentReply.objects.filter(
+ id=reply_id, doc_uuid=file_uuid, comment_id=comment_id).first()
+ if not reply:
+ return api_error(status.HTTP_404_NOT_FOUND, 'reply not found.')
+
+ # save
+ reply.reply = str(reply_content)
+ reply.updated_at = timezone.now()
+ reply.save()
+
+ data = reply.to_dict()
+ data.update(
+ user_to_dict(reply.author, request=request))
+ return Response(data)
diff --git a/seahub/templates/base_for_react.html b/seahub/templates/base_for_react.html
index a3c9cff995..543ded54f1 100644
--- a/seahub/templates/base_for_react.html
+++ b/seahub/templates/base_for_react.html
@@ -23,6 +23,7 @@
+
{% block extra_style %}{% endblock %}
{% if branding_css != '' %}{% endif %}
{% if enable_branding_css %}{% endif %}
diff --git a/seahub/templates/file_view_react.html b/seahub/templates/file_view_react.html
index 8052977bdd..e9a97e0088 100644
--- a/seahub/templates/file_view_react.html
+++ b/seahub/templates/file_view_react.html
@@ -28,6 +28,7 @@ window.app.pageOptions = {
latestContributorName: '{{ latest_contributor|email2nickname|escapejs }}',
lastModificationTime: '{{ last_modified }}',
repoID: '{{ repo.id }}',
+ fileUuid: '{{ file_uuid }}',
repoName: '{{ repo.name|escapejs }}',
repoEncrypted: {% if repo.encrypted %}true{% else %}false{% endif %},
isRepoAdmin: {% if is_repo_admin %}true{% else %}false{% endif %},
diff --git a/seahub/urls.py b/seahub/urls.py
index d83d38bc7b..95175444c7 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -3,6 +3,8 @@ from django.urls import include, path, re_path
from django.views.generic import TemplateView
from seahub.ai.apis import ImageCaption, GenerateSummary, GenerateFileTags, OCR, Translate, WritingAssistant
+from seahub.api2.endpoints.file_comments import FileCommentsView, FileCommentView, FileCommentRepliesView, \
+ FileCommentReplyView
from seahub.api2.endpoints.share_link_auth import ShareLinkUserAuthView, ShareLinkEmailAuthView
from seahub.api2.endpoints.internal_api import InternalUserListView, InternalCheckShareLinkAccess, \
InternalCheckFileOperationAccess, CheckThumbnailAccess, CheckShareLinkThumbnailAccess
@@ -479,7 +481,15 @@ urlpatterns = [
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/share-info/$', RepoShareInfoView.as_view(), name='api-v2.1-repo-share-info-view'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/image-rotate/$', RepoImageRotateView.as_view(), name='api-v2.1-repo-image-rotate-view'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/office-suite/$', OfficeSuiteConfig.as_view(), name='api-v2.1-repo-office-suite'),
-
+
+
+ ## user: repo file comments
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/(?P[-0-9a-f]{36})/comments/$', FileCommentsView.as_view(), name='api-v2.1-file-comments'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/(?P[-0-9a-f]{36})/comments/(?P\d+)/$', FileCommentView.as_view(), name='api-v2.1-file-comment'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/(?P[-0-9a-f]{36})/comments/(?P\d+)/replies/$', FileCommentRepliesView.as_view(), name='api-v2.1-file-comment-replies'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/(?P[-0-9a-f]{36})/comments/(?P\d+)/replies/(?P\d+)/$', FileCommentReplyView.as_view(), name='api-v2.1-file-comment-repolies'),
+
+
## user:: repo-api-tokens
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/repo-api-tokens/$', RepoAPITokensView.as_view(), name='api-v2.1-repo-api-tokens'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/repo-api-tokens/(?P.*)/$', RepoAPITokenView.as_view(), name='api-v2.1-repo-api-token'),
diff --git a/seahub/views/file.py b/seahub/views/file.py
index 83f54ce4ba..f214b9219a 100644
--- a/seahub/views/file.py
+++ b/seahub/views/file.py
@@ -670,6 +670,9 @@ def view_lib_file(request, repo_id, path):
return_dict['fileext'] = fileext
return_dict['filetype'] = filetype
+ file_uuid = get_seadoc_file_uuid(repo, path)
+ return_dict['file_uuid'] = file_uuid
+
# get file raw url
raw_path = ''
inner_path = ''
@@ -692,8 +695,6 @@ def view_lib_file(request, repo_id, path):
template = 'common_file_view_react.html'
if filetype == SEADOC:
- file_uuid = get_seadoc_file_uuid(repo, path)
- return_dict['file_uuid'] = file_uuid
return_dict['assets_url'] = '/api/v2.1/seadoc/download-image/' + file_uuid
return_dict['seadoc_server_url'] = SEADOC_SERVER_URL