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 ( +
+
+
+ setDropdownOpen(!isDropdownOpen)}> + +
{getText(commentType)}
+
+ + setCommentType(e, 'All comments')}> + {t('All comments')} + + setCommentType(e, 'Resolved comments')}>{t('Resolved comments')} + setCommentType(e, 'Unresolved comments')}>{t('Unresolved comments')} + +
+
+
{commentTip}
+
+
+ ); +}; + +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} + +
    + + + +
    +

    {lastReply.reply}

    +
    +
    +
    + } +
  • + ); + } +} + +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) && + + + + + {gettext('Delete')} + + + {gettext('Edit')} + + {!item.resolved && + this.props.resolveComment(this.props.item, 'true')} + className="resolve-comment" + id={item.id} + > + {gettext('Mark as resolved')} + + } + {item.resolved && + this.props.resolveComment(this.props.item, 'false')} + className="resolve-comment" + id={item.id} + > + {gettext('Resubmit')} + + } + + + } +
    +
    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} +
    {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} +
    {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')} +
    +
    +
    + +
    +
    +
    + +
    + +
    +
    + + `@${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 ?
    @@ -198,12 +190,19 @@ class FileToolbar extends React.Component { text={gettext('Details')} onClick={this.props.toggleDetailsPanel} /> - {filePerm == 'rw' && ( +
    + +
    + {showShareBtn && ( )} @@ -216,6 +215,11 @@ class FileToolbar extends React.Component { + {/* {( + + {gettext('Comment')} + + )} */} {filePerm == 'rw' && ( {gettext('History')} @@ -224,6 +228,11 @@ class FileToolbar extends React.Component { {gettext('Open parent folder')} + {filePerm == 'rw' && ( + + {gettext('Open via client')} + + )}
    @@ -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