mirror of
https://github.com/haiwen/seahub.git
synced 2025-04-27 11:01:14 +00:00
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>
This commit is contained in:
parent
1cf26c3d2c
commit
7ff4b52005
45
frontend/package-lock.json
generated
45
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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}', `<a href=${Utils.encodePath(fileUrl)}>`);
|
||||
notice = notice.replace('{/tagA}', '</a>');
|
||||
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;
|
||||
|
213
frontend/src/components/file-view/comment-panel.js
Normal file
213
frontend/src/components/file-view/comment-panel.js
Normal file
@ -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 (
|
||||
<div className="seafile-comment">
|
||||
{
|
||||
this.state.currentComment ?
|
||||
<ReplyList
|
||||
currentComment={this.state.currentComment}
|
||||
clearCurrentComment={this.clearCurrentComment}
|
||||
toggleCommentList={this.props.toggleCommentPanel}
|
||||
commentsList={commentsList}
|
||||
relatedUsers={this.state.relatedUsers}
|
||||
participants={this.state.participants}
|
||||
deleteComment={this.deleteComment}
|
||||
resolveComment={this.resolveComment}
|
||||
editComment={this.editComment}
|
||||
addReply={this.addReply}
|
||||
deleteReply={this.deleteReply}
|
||||
updateReply={this.updateReply}
|
||||
onParticipantsChange={this.onParticipantsChange}
|
||||
/>
|
||||
:
|
||||
<CommentList
|
||||
onClickComment={this.onClickComment}
|
||||
commentsList={commentsList}
|
||||
relatedUsers={this.state.relatedUsers}
|
||||
participants={this.state.participants}
|
||||
addComment={this.addComment}
|
||||
toggleCommentList={this.props.toggleCommentPanel}
|
||||
onParticipantsChange={this.onParticipantsChange}
|
||||
isLoading={this.state.isLoading}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentPanel.propTypes = CommentPanelPropTypes;
|
||||
|
||||
export default CommentPanel;
|
@ -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;
|
||||
}
|
@ -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 (
|
||||
<div className='comments-panel-body__header'>
|
||||
<div className="comments-types-count">
|
||||
<div id="comment-types" className='comment-type'>
|
||||
<Dropdown isOpen={isDropdownOpen} toggle={() => setDropdownOpen(!isDropdownOpen)}>
|
||||
<DropdownToggle tag={'div'} caret className='d-flex align-items-center justify-content-center'>
|
||||
<div id={'comment-type-controller'}>{getText(commentType)}</div>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu className='sdoc-dropdown-menu sdoc-comment-filter-dropdown' container="comment-types">
|
||||
<DropdownItem className='sdoc-dropdown-menu-item' tag={'div'} onClick={(e) => setCommentType(e, 'All comments')}>
|
||||
{t('All comments')}
|
||||
</DropdownItem>
|
||||
<DropdownItem className='sdoc-dropdown-menu-item' tag={'div'} onClick={(e) => setCommentType(e, 'Resolved comments')}>{t('Resolved comments')}</DropdownItem>
|
||||
<DropdownItem className='sdoc-dropdown-menu-item' tag={'div'} onClick={(e) => setCommentType(e, 'Unresolved comments')}>{t('Unresolved comments')}</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className='comment-count-tip'>{commentTip}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentBodyHeader;
|
||||
|
@ -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%;
|
||||
}
|
@ -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 (
|
||||
<UncontrolledPopover
|
||||
container={parentDom}
|
||||
target={targetId}
|
||||
onClick={event => event.stopPropagation()}
|
||||
placement="left"
|
||||
className='comment-delete-popover'
|
||||
isOpen={true}
|
||||
>
|
||||
<div className='comment-delete-popover-container' ref={popoverRef}>
|
||||
<div className='delete-tip'>
|
||||
{type === 'comment' ? gettext('Are you sure to delete this comment?') : gettext('Are you sure to delete this reply?')}
|
||||
</div>
|
||||
<div className='delete-control mt-5'>
|
||||
<Button color='secondary' size='sm' className='mr-2' onClick={onDeleteCancel}>{gettext('Cancel')}</Button>
|
||||
<Button color='primary' size='sm' onClick={handleConfirm}>{gettext('Confirm')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</UncontrolledPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentDeletePopover;
|
@ -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 (
|
||||
<li className={'seafile-comment-item'} id={item.id} onClick={() => this.props.onClickComment(item)}>
|
||||
<div className="seafile-comment-info">
|
||||
<img className="avatar mt-1" src={item.avatar_url} alt=""/>
|
||||
<div className="comment-author-info">
|
||||
<div className="comment-author-name ellipsis">{item.user_name}</div>
|
||||
<div className="comment-author-time">
|
||||
{this.props.time}
|
||||
{item.resolved &&
|
||||
<span className="comment-success-resolved sdocfont sdoc-mark-as-resolved"></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="seafile-comment-content"
|
||||
dangerouslySetInnerHTML={{ __html: this.state.html }}
|
||||
onClick={e => this.onCommentContentClick(e)}
|
||||
>
|
||||
</div>
|
||||
{replies.length > 0 &&
|
||||
<div className="comment-footer">
|
||||
<span className="comments-count">
|
||||
<i className="sdocfont sdoc-comments"></i>
|
||||
<span className="comments-count-number">{replies.length}</span>
|
||||
</span>
|
||||
<div className="comment-author">
|
||||
<span className="comment-author__avatar">
|
||||
<img alt="" src={lastReply.avatar_url}/>
|
||||
</span>
|
||||
<div className="comment-author__latest-reply">
|
||||
<p>{lastReply.reply}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentItemReadOnly.propTypes = commentItemPropTypes;
|
||||
|
||||
export default CommentItemReadOnly;
|
210
frontend/src/components/file-view/comment-widget/comment-item.js
Normal file
210
frontend/src/components/file-view/comment-widget/comment-item.js
Normal file
@ -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 (
|
||||
<li className="seafile-comment-item" id={item.id}>
|
||||
<div className="seafile-comment-info">
|
||||
<img className="avatar mt-1" src={item.avatar_url} alt=""/>
|
||||
<div className="comment-author-info">
|
||||
<div className="comment-author-name ellipsis">{item.user_name}</div>
|
||||
<div className="comment-author-time">{time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seafile-edit-comment">
|
||||
<textarea className="edit-comment-input" value={this.state.newComment} onChange={this.handleCommentChange} clos="100" rows="3" warp="virtual"></textarea>
|
||||
<Button className="comment-btn" color="primary" size="sm" onClick={this.updateComment} id={item.id}>{gettext('Update')}</Button>{' '}
|
||||
<Button className="comment-btn" color="secondary" size="sm" onClick={this.toggleEditComment}>{gettext('Cancel')}</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className={'seafile-comment-item'} id={item.id}>
|
||||
<div className="seafile-comment-info">
|
||||
<img className="avatar mt-1" src={item.avatar_url} alt=""/>
|
||||
<div className="comment-author-info">
|
||||
<div className="comment-author-name ellipsis">{item.user_name}</div>
|
||||
<div className="comment-author-time">
|
||||
{time}
|
||||
{item.resolved &&
|
||||
<span className="comment-success-resolved sdocfont sdoc-mark-as-resolved"></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{(item.user_email === username) &&
|
||||
<Dropdown
|
||||
isOpen={this.state.dropdownOpen}
|
||||
size="sm"
|
||||
className="seafile-comment-dropdown"
|
||||
toggle={this.toggleDropDownMenu}
|
||||
id={commentOpToolsId}
|
||||
>
|
||||
<DropdownToggle
|
||||
tag="i"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
className="seafile-comment-dropdown-btn sf-dropdown-toggle sf3-font-more sf3-font"
|
||||
title={gettext('More operations')}
|
||||
aria-label={gettext('More operations')}
|
||||
data-toggle="dropdown"
|
||||
aria-expanded={this.state.dropdownOpen}
|
||||
aria-haspopup={true}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
onClick={this.toggleShowDeletePopover}
|
||||
className="delete-comment"
|
||||
id={item.id}
|
||||
>
|
||||
{gettext('Delete')}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
onClick={this.toggleEditComment}
|
||||
className="edit-comment"
|
||||
id={item.id}
|
||||
>
|
||||
{gettext('Edit')}
|
||||
</DropdownItem>
|
||||
{!item.resolved &&
|
||||
<DropdownItem
|
||||
onClick={() => this.props.resolveComment(this.props.item, 'true')}
|
||||
className="resolve-comment"
|
||||
id={item.id}
|
||||
>
|
||||
{gettext('Mark as resolved')}
|
||||
</DropdownItem>
|
||||
}
|
||||
{item.resolved &&
|
||||
<DropdownItem
|
||||
onClick={() => this.props.resolveComment(this.props.item, 'false')}
|
||||
className="resolve-comment"
|
||||
id={item.id}
|
||||
>
|
||||
{gettext('Resubmit')}
|
||||
</DropdownItem>
|
||||
}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className="seafile-comment-content"
|
||||
dangerouslySetInnerHTML={{ __html: this.state.html }}
|
||||
onClick={e => this.onCommentContentClick(e)}
|
||||
>
|
||||
</div>
|
||||
{this.state.isShowDeletePopover && (
|
||||
<CommentDeletePopover
|
||||
type="comment"
|
||||
targetId={commentOpToolsId}
|
||||
deleteConfirm={() => this.props.deleteComment(this.props.item)}
|
||||
setIsShowDeletePopover={this.toggleShowDeletePopover}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentItem.propTypes = commentItemPropTypes;
|
||||
|
||||
export default CommentItem;
|
251
frontend/src/components/file-view/comment-widget/comment-list.js
Normal file
251
frontend/src/components/file-view/comment-widget/comment-list.js
Normal file
@ -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 (
|
||||
<div className={`comment-participant-item user ${focused ? 'active' : ''}`}>
|
||||
<div className="comment-participant-container">
|
||||
<img className="comment-participant-avatar" alt={highlightedDisplay} src={entry.avatar_url}/>
|
||||
<div className="comment-participant-name">{highlightedDisplay}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="seafile-comment-page h-100">
|
||||
|
||||
<div className="seafile-comment-title">
|
||||
<div className="comments-panel-header-left">
|
||||
{gettext('Comments')}
|
||||
</div>
|
||||
<div className="comments-panel-header-right">
|
||||
<span className="sdoc-icon-btn" onClick={this.props.toggleCommentList}>
|
||||
<i className="sdocfont sdoc-sm-close"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-fill o-auto"
|
||||
style={{ height: this.state.isInputFocus ? 'calc(100% - 170px)' : 'calc(100% - 124px)' }}
|
||||
ref={this.commentListScrollRef}
|
||||
>
|
||||
<CommentBodyHeader
|
||||
commentList={commentsList}
|
||||
commentType={this.state.commentType}
|
||||
setCommentType={this.setCommentType}
|
||||
/>
|
||||
{isLoading && <Loading/>}
|
||||
{!isLoading && filteredComments.length > 0 &&
|
||||
<ul className="seafile-comment-list">
|
||||
{filteredComments.map((item) => {
|
||||
let oldTime = (new Date(item.created_at)).getTime();
|
||||
let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
|
||||
return (
|
||||
<CommentItemReadOnly
|
||||
key={item.id}
|
||||
item={item}
|
||||
time={time}
|
||||
onClickComment={this.props.onClickComment}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
{!isLoading && filteredComments.length === 0 &&
|
||||
<p className="text-center my-4">{gettext('No comment yet.')}</p>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className={classname('seafile-comment-footer flex-shrink-0')}
|
||||
style={{ height: this.state.isInputFocus ? '120px' : '72px' }}
|
||||
>
|
||||
<MentionsInput
|
||||
value={this.state.comment}
|
||||
onChange={this.handleCommentChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={gettext('Enter comment, Shift + Enter for new line, Enter to send')}
|
||||
style={this.state.defaultStyle}
|
||||
onFocus={this.onInputFocus}
|
||||
onBlur={this.onInputBlur}
|
||||
>
|
||||
<Mention
|
||||
trigger="@"
|
||||
displayTransform={(username, display) => `@${display}`}
|
||||
data={this.props.relatedUsers}
|
||||
renderSuggestion={this.renderUserSuggestion}
|
||||
onAdd={(id, display) => {this.addParticipant(id);}}
|
||||
appendSpaceOnAdd={true}
|
||||
/>
|
||||
</MentionsInput>
|
||||
{this.state.isInputFocus &&
|
||||
<div className="comment-submit-container">
|
||||
<div onClick={this.onSubmit}>
|
||||
<i className="sdocfont sdoc-save sdoc-comment-btn"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentList.propTypes = CommentListPropTypes;
|
||||
|
||||
export default CommentList;
|
175
frontend/src/components/file-view/comment-widget/reply-item.js
Normal file
175
frontend/src/components/file-view/comment-widget/reply-item.js
Normal file
@ -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 (
|
||||
<li className="seafile-comment-item" id={item.id}>
|
||||
<div className="seafile-comment-info mt-1">
|
||||
<img className="avatar" src={item.avatar_url} alt=""/>
|
||||
<div className="comment-author-info">
|
||||
<div className="comment-author-name ellipsis">{item.user_name}</div>
|
||||
<div className="comment-author-time">{this.props.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seafile-edit-comment">
|
||||
<textarea className="edit-comment-input" value={this.state.newReply} onChange={this.handleCommentChange} clos="100" rows="3" warp="virtual"></textarea>
|
||||
<Button className="comment-btn" color="primary" size="sm" onClick={this.updateComment} id={item.id}>{gettext('Update')}</Button>{' '}
|
||||
<Button className="comment-btn" color="secondary" size="sm" onClick={this.toggleEditComment}>{gettext('Cancel')}</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className={'seafile-comment-item'} id={item.id}>
|
||||
<div className="seafile-comment-info mt-1">
|
||||
<img className="avatar" src={item.avatar_url} alt=""/>
|
||||
<div className="comment-author-info">
|
||||
<div className="comment-author-name ellipsis">{item.user_name}</div>
|
||||
<div className="comment-author-time">{this.props.time}</div>
|
||||
</div>
|
||||
{(item.user_email === username) &&
|
||||
<Dropdown
|
||||
isOpen={this.state.dropdownOpen}
|
||||
size="sm"
|
||||
className="seafile-comment-dropdown"
|
||||
toggle={this.toggleDropDownMenu}
|
||||
id={replyOpToolsId}
|
||||
>
|
||||
<DropdownToggle
|
||||
tag="i"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
className="seafile-comment-dropdown-btn sf-dropdown-toggle sf3-font-more sf3-font"
|
||||
title={gettext('More operations')}
|
||||
aria-label={gettext('More operations')}
|
||||
data-toggle="dropdown"
|
||||
aria-expanded={this.state.dropdownOpen}
|
||||
aria-haspopup={true}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
onClick={this.toggleShowDeletePopover}
|
||||
className="delete-comment"
|
||||
id={item.id}
|
||||
>
|
||||
{gettext('Delete')}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
onClick={this.toggleEditComment}
|
||||
className="edit-comment"
|
||||
id={item.id}
|
||||
>
|
||||
{gettext('Edit')}
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className="seafile-comment-content"
|
||||
dangerouslySetInnerHTML={{ __html: this.state.html }}
|
||||
onClick={e => this.onCommentContentClick(e)}
|
||||
>
|
||||
</div>
|
||||
{this.state.isShowDeletePopover && (
|
||||
<CommentDeletePopover
|
||||
type="reply"
|
||||
deleteConfirm={this.props.deleteReply}
|
||||
setIsShowDeletePopover={this.toggleShowDeletePopover}
|
||||
targetId={replyOpToolsId}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReplyItem.propTypes = commentItemPropTypes;
|
||||
|
||||
export default ReplyItem;
|
234
frontend/src/components/file-view/comment-widget/reply-list.js
Normal file
234
frontend/src/components/file-view/comment-widget/reply-list.js
Normal file
@ -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 (
|
||||
<div className={`comment-participant-item user ${focused ? 'active' : ''}`}>
|
||||
<div className="comment-participant-container">
|
||||
<img className="comment-participant-avatar" alt={highlightedDisplay} src={entry.avatar_url}/>
|
||||
<div className="comment-participant-name">{highlightedDisplay}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="seafile-reply-page h-100">
|
||||
|
||||
<div className="seafile-comment-title">
|
||||
<div className="comments-panel-header-left">
|
||||
<div className="goback sdoc-icon-btn ml-0 mr-1" onClick={this.props.clearCurrentComment}>
|
||||
<i className="sdocfont sdoc-previous-page" style={{ transform: 'scale(1.2)' }}></i>
|
||||
</div>
|
||||
<span className="title">{gettext('Comment details')}</span>
|
||||
</div>
|
||||
<div className="comments-panel-header-right">
|
||||
<div className="sdoc-icon-btn" onClick={this.props.toggleCommentList}>
|
||||
<i className="sdocfont sdoc-sm-close"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-fill o-auto"
|
||||
style={{ height: this.state.isInputFocus ? 'calc(100% - 170px)' : 'calc(100% - 124px)' }}
|
||||
ref={this.commentListScrollRef}
|
||||
>
|
||||
<ul className="seafile-comment-list">
|
||||
<CommentItem
|
||||
key={currentComment.id}
|
||||
item={currentComment}
|
||||
deleteComment={this.props.deleteComment}
|
||||
resolveComment={this.props.resolveComment}
|
||||
editComment={this.props.editComment}
|
||||
/>
|
||||
{replies.map((item) => {
|
||||
let oldTime = (new Date(item.created_at)).getTime();
|
||||
let time = dayjs(oldTime).format('YYYY-MM-DD HH:mm');
|
||||
return (
|
||||
<ReplyItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
time={time}
|
||||
deleteReply={() => this.props.deleteReply(currentComment.id, item.id)}
|
||||
updateReply={(replyContent) => this.props.updateReply(currentComment.id, item.id, replyContent)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
className={classname('seafile-comment-footer flex-shrink-0')}
|
||||
style={{ height: this.state.isInputFocus ? '120px' : '72px' }}
|
||||
>
|
||||
<MentionsInput
|
||||
value={this.state.comment}
|
||||
onChange={this.handleCommentChange}
|
||||
placeholder={gettext('Enter reply, Shift + Enter for new line, Enter to send')}
|
||||
onKeyDown={this.onKeyDown}
|
||||
style={this.state.defaultStyle}
|
||||
onFocus={this.onInputFocus}
|
||||
onBlur={this.onInputBlur}
|
||||
>
|
||||
<Mention
|
||||
trigger="@"
|
||||
displayTransform={(username, display) => `@${display}`}
|
||||
data={this.state.relatedUsers}
|
||||
renderSuggestion={this.renderUserSuggestion}
|
||||
onAdd={(id, display) => {this.addParticipant(id);}}
|
||||
appendSpaceOnAdd={true}
|
||||
/>
|
||||
</MentionsInput>
|
||||
{this.state.isInputFocus &&
|
||||
<div className="comment-submit-container">
|
||||
<div onClick={this.onSubmit}>
|
||||
<i className="sdocfont sdoc-save sdoc-comment-btn"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReplyList.propTypes = ReplyListPropTypes;
|
||||
|
||||
export default ReplyList;
|
@ -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 && (
|
||||
<IconButton
|
||||
id="share-file"
|
||||
icon='share'
|
||||
text={gettext('Share')}
|
||||
onClick={this.toggleShareDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(canEditFile && fileType != 'SDoc' && !err) &&
|
||||
(this.props.isSaving ?
|
||||
<div type='button' aria-label={gettext('Saving...')} className={'file-toolbar-btn'}>
|
||||
@ -198,12 +190,19 @@ class FileToolbar extends React.Component {
|
||||
text={gettext('Details')}
|
||||
onClick={this.props.toggleDetailsPanel}
|
||||
/>
|
||||
{filePerm == 'rw' && (
|
||||
<div
|
||||
className='file-toolbar-btn'
|
||||
onClick={this.props.toggleCommentPanel}
|
||||
aria-label={gettext('Comment')}
|
||||
>
|
||||
<i className="sdocfont sdoc-comments"></i>
|
||||
</div>
|
||||
{showShareBtn && (
|
||||
<IconButton
|
||||
id="open-via-client"
|
||||
icon="client"
|
||||
text={gettext('Open via Client')}
|
||||
href={`seafile://openfile?repo_id=${encodeURIComponent(repoID)}&path=${encodeURIComponent(filePath)}`}
|
||||
id="share-file"
|
||||
icon='share'
|
||||
text={gettext('Share')}
|
||||
onClick={this.toggleShareDialog}
|
||||
/>
|
||||
)}
|
||||
<Dropdown isOpen={moreDropdownOpen} toggle={this.toggleMoreOpMenu}>
|
||||
@ -216,6 +215,11 @@ class FileToolbar extends React.Component {
|
||||
<Icon symbol="more-vertical" />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
{/* {(
|
||||
<DropdownItem onClick={this.props.toggleCommentPanel}>
|
||||
{gettext('Comment')}
|
||||
</DropdownItem>
|
||||
)} */}
|
||||
{filePerm == 'rw' && (
|
||||
<a href={`${siteRoot}repo/file_revisions/${repoID}/?p=${encodeURIComponent(filePath)}&referer=${encodeURIComponent(location.href)}`} className="dropdown-item">
|
||||
{gettext('History')}
|
||||
@ -224,6 +228,11 @@ class FileToolbar extends React.Component {
|
||||
<a href={`${siteRoot}library/${repoID}/${Utils.encodePath(repoName + parentDir)}`} className="dropdown-item">
|
||||
{gettext('Open parent folder')}
|
||||
</a>
|
||||
{filePerm == 'rw' && (
|
||||
<a href={`seafile://openfile?repo_id=${encodeURIComponent(repoID)}&path=${encodeURIComponent(filePath)}`} className="dropdown-item">
|
||||
{gettext('Open via client')}
|
||||
</a>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@ -275,6 +284,11 @@ class FileToolbar extends React.Component {
|
||||
</a>
|
||||
</DropdownItem>
|
||||
)}
|
||||
{(
|
||||
<DropdownItem onClick={this.props.toggleCommentPanel}>
|
||||
{gettext('Comment')}
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={this.props.toggleDetailsPanel}>{gettext('Details')}</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
@ -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 &&
|
||||
<CommentPanel
|
||||
toggleCommentPanel={this.toggleCommentPanel}
|
||||
participants={this.props.participants}
|
||||
onParticipantsChange={this.props.onParticipantsChange}
|
||||
/>
|
||||
}
|
||||
{isDetailsPanelOpen && (
|
||||
<MetadataStatusProvider repoID={repoID} repoInfo={repoInfo}>
|
||||
<CollaboratorsProvider repoID={repoID}>
|
||||
|
267
frontend/src/css/comments-list.css
Normal file
267
frontend/src/css/comments-list.css
Normal file
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
66
frontend/src/css/react-mentions-default-style.js
vendored
Normal file
66
frontend/src/css/react-mentions-default-style.js
vendored
Normal file
@ -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 };
|
@ -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/';
|
||||
|
383
seahub/api2/endpoints/file_comments.py
Normal file
383
seahub/api2/endpoints/file_comments.py
Normal file
@ -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)
|
@ -23,6 +23,7 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}css/seafile-ui.css?t=20250313" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}css/seahub_react.css?t=20250218" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}seafile-editor/seafile-editor-font.css" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}sdoc-editor/sdoc-editor-font.css" />
|
||||
{% block extra_style %}{% endblock %}
|
||||
{% if branding_css != '' %}<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}{{ branding_css }}" />{% endif %}
|
||||
{% if enable_branding_css %}<link rel="stylesheet" type="text/css" href="{% url 'custom_css' %}" />{% endif %}
|
||||
|
@ -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 %},
|
||||
|
@ -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
|
||||
@ -480,6 +482,14 @@ urlpatterns = [
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-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<repo_id>[-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<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/$', FileCommentsView.as_view(), name='api-v2.1-file-comments'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/(?P<comment_id>\d+)/$', FileCommentView.as_view(), name='api-v2.1-file-comment'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/(?P<comment_id>\d+)/replies/$', FileCommentRepliesView.as_view(), name='api-v2.1-file-comment-replies'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/(?P<file_uuid>[-0-9a-f]{36})/comments/(?P<comment_id>\d+)/replies/(?P<reply_id>\d+)/$', FileCommentReplyView.as_view(), name='api-v2.1-file-comment-repolies'),
|
||||
|
||||
|
||||
## user:: repo-api-tokens
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-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<repo_id>[-0-9a-f]{36})/repo-api-tokens/(?P<app_name>.*)/$', RepoAPITokenView.as_view(), name='api-v2.1-repo-api-token'),
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user