1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-21 03:18:23 +00:00

Merge pull request #7082 from haiwen/update-user-notifications

Update user notifications
This commit is contained in:
Ranjiwei
2025-02-25 16:21:07 +08:00
committed by GitHub
15 changed files with 941 additions and 205 deletions

View File

@@ -4,6 +4,8 @@ import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { gettext, siteRoot } from '../../utils/constants';
import { Utils } from '../../utils/utils';
import { processor } from '@seafile/seafile-editor';
import '../../css/notice-item.css';
const propTypes = {
noticeItem: PropTypes.object.isRequired,
@@ -24,6 +26,8 @@ const MSG_TYPE_SAML_SSO_FAILED = 'saml_sso_failed';
const MSG_TYPE_REPO_SHARE_PERM_CHANGE = 'repo_share_perm_change';
const MSG_TYPE_REPO_SHARE_PERM_DELETE = 'repo_share_perm_delete';
const MSG_TYPE_FACE_CLUSTER = 'face_cluster';
const MSG_TYPE_SEADOC_REPLY = 'reply';
const MSG_TYPE_SEADOC_COMMENT = 'comment';
dayjs.extend(relativeTime);
@@ -35,35 +39,26 @@ class NoticeItem extends React.Component {
let detail = noticeItem.detail;
if (noticeType === MSG_TYPE_ADD_USER_TO_GROUP) {
let avatar_url = detail.group_staff_avatar_url;
let groupStaff = detail.group_staff_name;
// group name does not support special characters
let userHref = siteRoot + 'profile/' + detail.group_staff_email + '/';
let groupHref = siteRoot + 'group/' + detail.group_id + '/';
let groupName = detail.group_name;
let username = detail.group_staff_name;
let notice = gettext('User {user_link} has added you to {group_link}');
let userLink = '<a href=' + userHref + '>' + groupStaff + '</a>';
let groupLink = '<a href=' + groupHref + '>' + groupName + '</a>';
notice = notice.replace('{user_link}', userLink);
notice = notice.replace('{group_link}', groupLink);
return { avatar_url, notice };
return { avatar_url, notice, username };
}
if (noticeType === MSG_TYPE_REPO_SHARE) {
let avatar_url = detail.share_from_user_avatar_url;
let shareFrom = detail.share_from_user_name;
let repoName = detail.repo_name;
let repoUrl = siteRoot + 'library/' + detail.repo_id + '/' + repoName + '/';
let path = detail.path;
let notice = '';
// 1. handle translate
@@ -72,21 +67,17 @@ class NoticeItem extends React.Component {
} else { // share folder
notice = gettext('{share_from} has shared a folder named {repo_link} to you.');
}
// 2. handle xss(cross-site scripting)
notice = notice.replace('{share_from}', shareFrom);
notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`);
notice = Utils.HTMLescape(notice);
// 3. add jump link
notice = notice.replace('{tagA}', `<a href='${Utils.encodePath(repoUrl)}'>`);
notice = notice.replace('{/tagA}', '</a>');
return { avatar_url, notice };
return { avatar_url, notice, username: shareFrom };
}
if (noticeType === MSG_TYPE_REPO_SHARE_PERM_CHANGE) {
let avatar_url = detail.share_from_user_avatar_url;
let shareFrom = detail.share_from_user_name;
let permission = detail.permission;
@@ -100,22 +91,18 @@ class NoticeItem extends React.Component {
} else { // share folder
notice = gettext('{share_from} has changed the permission of folder {repo_link} to {permission}.');
}
// 2. handle xss(cross-site scripting)
notice = notice.replace('{share_from}', shareFrom);
notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`);
notice = notice.replace('{permission}', permission);
notice = Utils.HTMLescape(notice);
// 3. add jump link
notice = notice.replace('{tagA}', `<a href='${Utils.encodePath(repoUrl)}'>`);
notice = notice.replace('{/tagA}', '</a>');
return { avatar_url, notice };
return { avatar_url, notice, username: shareFrom };
}
if (noticeType === MSG_TYPE_REPO_SHARE_PERM_DELETE) {
let avatar_url = detail.share_from_user_avatar_url;
let shareFrom = detail.share_from_user_name;
let repoName = detail.repo_name;
@@ -127,26 +114,20 @@ class NoticeItem extends React.Component {
} else { // share folder
notice = gettext('{share_from} has cancelled the sharing of folder {repo_name}.');
}
// 2. handle xss(cross-site scripting)
notice = notice.replace('{share_from}', shareFrom);
notice = notice.replace('{repo_name}', repoName);
notice = Utils.HTMLescape(notice);
return { avatar_url, notice };
return { avatar_url, notice, username: shareFrom };
}
if (noticeType === MSG_TYPE_REPO_SHARE_TO_GROUP) {
let avatar_url = detail.share_from_user_avatar_url;
let shareFrom = detail.share_from_user_name;
let repoName = detail.repo_name;
let repoUrl = siteRoot + 'library/' + detail.repo_id + '/' + repoName + '/';
let groupUrl = siteRoot + 'group/' + detail.group_id + '/';
let groupName = detail.group_name;
let path = detail.path;
let notice = '';
// 1. handle translate
@@ -155,60 +136,50 @@ class NoticeItem extends React.Component {
} else {
notice = gettext('{share_from} has shared a folder named {repo_link} to group {group_link}.');
}
// 2. handle xss(cross-site scripting)
notice = notice.replace('{share_from}', shareFrom);
notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`);
notice = notice.replace('{group_link}', `{tagB}${groupName}{/tagB}`);
notice = Utils.HTMLescape(notice);
// 3. add jump link
notice = notice.replace('{tagA}', `<a href='${Utils.encodePath(repoUrl)}'>`);
notice = notice.replace('{/tagA}', '</a>');
notice = notice.replace('{tagB}', `<a href='${Utils.encodePath(groupUrl)}'>`);
notice = notice.replace('{/tagB}', '</a>');
return { avatar_url, notice };
return { avatar_url, notice, username: shareFrom };
}
if (noticeType === MSG_TYPE_REPO_TRANSFER) {
let avatar_url = detail.transfer_from_user_avatar_url;
let repoOwner = detail.transfer_from_user_name;
let repoName = detail.repo_name;
let repoUrl = siteRoot + 'library/' + detail.repo_id + '/' + repoName + '/';
// 1. handle translate
let notice = gettext('{user} has transfered a library named {repo_link} to you.');
// 2. handle xss(cross-site scripting)
notice = notice.replace('{user}', repoOwner);
notice = notice.replace('{repo_link}', `{tagA}${repoName}{/tagA}`);
notice = Utils.HTMLescape(notice);
// 3. add jump link
notice = notice.replace('{tagA}', `<a href=${Utils.encodePath(repoUrl)}>`);
notice = notice.replace('{/tagA}', '</a>');
return { avatar_url, notice };
return { avatar_url, notice, username: repoOwner };
}
if (noticeType === MSG_TYPE_FILE_UPLOADED) {
let avatar_url = detail.uploaded_user_avatar_url;
let fileName = detail.file_name;
let fileLink = siteRoot + 'lib/' + detail.repo_id + '/' + 'file' + detail.file_path;
let folderName = detail.folder_name;
let folderLink = siteRoot + 'library/' + detail.repo_id + '/' + detail.repo_name + detail.folder_path;
let notice = '';
if (detail.repo_id) { // todo is repo exist ?
// 1. handle translate
notice = gettext('A file named {upload_file_link} is uploaded to {uploaded_link}.');
// 2. handle xss(cross-site scripting)
notice = notice.replace('{upload_file_link}', `{tagA}${fileName}{/tagA}`);
notice = notice.replace('{uploaded_link}', `{tagB}${folderName}{/tagB}`);
notice = Utils.HTMLescape(notice);
// 3. add jump link
notice = notice.replace('{tagA}', `<a href=${Utils.encodePath(fileLink)}>`);
notice = notice.replace('{/tagA}', '</a>');
@@ -217,7 +188,6 @@ class NoticeItem extends React.Component {
} else {
// 1. handle translate
notice = gettext('A file named {upload_file_link} is uploaded.');
// 2. handle xss(cross-site scripting)
notice = notice.replace('{upload_file_link}', `${fileName}`);
notice = Utils.HTMLescape(notice);
@@ -341,17 +311,11 @@ class NoticeItem extends React.Component {
}
if (noticeType === MSG_TYPE_DELETED_FILES) {
const {
repo_id,
repo_name,
} = detail;
const { repo_id, repo_name } = detail;
const repoURL = `${siteRoot}library/${repo_id}/${encodeURIComponent(repo_name)}/`;
const repoLink = `<a href=${repoURL} target="_blank">${Utils.HTMLescape(repo_name)}</a>`;
let notice = gettext('Your library {libraryName} has recently deleted a large number of files.');
notice = notice.replace('{libraryName}', repoLink);
return { avatar_url: null, notice };
}
@@ -371,15 +335,56 @@ class NoticeItem extends React.Component {
if (noticeType === MSG_TYPE_SAML_SSO_FAILED) {
const { error_msg } = detail;
let notice = gettext(error_msg);
return { avatar_url: null, notice };
}
if (noticeType === MSG_TYPE_SEADOC_COMMENT) {
let avatar_url = detail.avatar_url;
let notice = detail.comment;
let username = detail.user_name;
let is_resolved = detail.is_resolved;
let sdoc_name = detail.sdoc_name;
const repo_id = detail.repo_id;
const sdoc_path = detail.sdoc_path;
const sdoc_href = siteRoot + 'lib/' + repo_id + '/file' + sdoc_path;
let sdoc_link = '<a href=' + sdoc_href + '>' + sdoc_name + '</a>';
processor.process(notice, (error, vfile) => {
notice = String(vfile);
});
if (is_resolved) {
notice = 'Marked "' + detail.resolve_comment + '" as resolved in document ' + sdoc_link;
} else {
notice = 'Added a new comment in document ' + sdoc_link + ':' + notice;
}
return { avatar_url, username, notice };
}
if (noticeType === MSG_TYPE_SEADOC_REPLY) {
let avatar_url = detail.avatar_url;
let notice = detail.reply;
let username = detail.user_name;
let is_resolved = detail.is_resolved;
let sdoc_name = detail.sdoc_name;
const repo_id = detail.repo_id;
const sdoc_path = detail.sdoc_path;
const sdoc_href = siteRoot + 'lib/' + repo_id + '/file' + sdoc_path;
let sdoc_link = '<a href=' + sdoc_href + '>' + sdoc_name + '</a>';
processor.process(notice, (error, vfile) => {
notice = String(vfile);
});
if (is_resolved) {
notice = 'Marked "' + detail.resolve_comment + '" as resolved in document ' + sdoc_link;
} else {
notice = 'Added a new reply in document ' + sdoc_link + ':' + notice;
}
return { avatar_url, username, notice };
}
// if (noticeType === MSG_TYPE_GUEST_INVITATION_ACCEPTED) {
// }
return { avatar_url: null, notice: null };
return { avatar_url: null, notice: null, username: null };
}
onNoticeItemClick = () => {
@@ -392,16 +397,19 @@ class NoticeItem extends React.Component {
render() {
let noticeItem = this.props.noticeItem;
let { avatar_url, notice } = this.generatorNoticeInfo();
let { avatar_url, username, notice } = this.generatorNoticeInfo();
if (!avatar_url && !notice) {
return '';
}
return this.props.tr ? (
<tr className={noticeItem.seen ? 'read' : 'unread font-weight-bold'}>
<tr className='notification-item'>
<td className="text-center">
{!noticeItem.seen && <span className="notification-point" onClick={this.onMarkNotificationRead}></span>}
</td>
<td>
<img src={avatar_url} width="32" height="32" className="avatar" alt="" />
<span className="ml-2 notification-user-name">{username || gettext('System')}</span>
</td>
<td className="pr-1 pr-md-8">
<p className="m-0" dangerouslySetInnerHTML={{ __html: notice }}></p>
@@ -411,13 +419,21 @@ class NoticeItem extends React.Component {
</td>
</tr>
) : (
<li onClick={this.onNoticeItemClick} className={noticeItem.seen ? 'read' : 'unread'}>
<div className="notice-item">
<div className="main-info">
<img src={avatar_url} width="32" height="32" className="avatar" alt=""/>
<p className="brief" dangerouslySetInnerHTML={{ __html: notice }}></p>
<li className='notification-item' onClick={this.onNoticeItemClick}>
<div className="notification-item-header">
{!noticeItem.seen &&
<span className="notification-point" onClick={this.onMarkNotificationRead}></span>
}
<div className="notification-header-info">
<div className="notification-user-detail">
<img className="notification-user-avatar" src={avatar_url} alt="" />
<span className="ml-2 notification-user-name">{username || gettext('System')}</span>
</div>
<span className="notification-time">{dayjs(noticeItem.time).fromNow()}</span>
</div>
<p className="time">{dayjs(noticeItem.time).fromNow()}</p>
</div>
<div className="notification-content-wrapper">
<div dangerouslySetInnerHTML={{ __html: notice }}></div>
</div>
</li>
);

View File

@@ -5,7 +5,7 @@
.notification-container {
position: absolute;
background: #fff;
width: 320px;
width: 400px;
right: -16px;
top: -1px;
border-radius: 3px;
@@ -65,21 +65,15 @@
margin-left: 20px;
}
.notification-container .notification-body .mark-notifications {
color: #b4b4b4;
.notification-container .mark-all-read {
color: #666;
cursor: pointer;
border-bottom: 1px solid #ededed;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 1rem;
}
.notification-container .notification-body .mark-notifications:hover {
text-decoration: underline;
}
.notification-body .notification-list-container {
max-height: 260px;
overflow: auto;
@@ -190,3 +184,33 @@
.notification-body .notification-footer:hover {
text-decoration: underline;
}
.notification-container .notification-body .mark-notifications {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ededed;
padding-left: 15px;
}
.notification-container .notification-body .mark-notifications .mark-all-read:hover {
text-decoration: underline;
}
.notification-container .notification-body .nav .nav-item .nav-link {
height: 46px;
margin-right: 15px;
margin-left: 15px;
font-size: 14px;
color: #212529;
}
.notification-container .notification-body .nav .nav-item .nav-link.active {
color: #ED7109;
}
@media (max-width: 768px) {
.notification-container {
right: -60px;
width: 360px;
}
}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Popover } from 'reactstrap';
import { gettext } from '../../../utils/constants';
import './index.css';
export default class NotificationPopover extends React.Component {
@@ -13,7 +15,11 @@ export default class NotificationPopover extends React.Component {
onNotificationDialogToggle: PropTypes.func,
listNotifications: PropTypes.func,
onMarkAllNotifications: PropTypes.func,
tabItemClick: PropTypes.func,
children: PropTypes.any,
currentTab: PropTypes.string,
generalNoticeListUnseen: PropTypes.number,
discussionNoticeListUnseen: PropTypes.number,
};
static defaultProps = {
@@ -47,8 +53,12 @@ export default class NotificationPopover extends React.Component {
}
};
tabItemClick = (tab) => {
this.props.tabItemClick(tab);
};
render() {
const { headerText, bodyText, footerText } = this.props;
const { headerText, bodyText, footerText, currentTab, generalNoticeListUnseen, discussionNoticeListUnseen } = this.props;
return (
<Popover
className="notification-wrapper"
@@ -64,12 +74,39 @@ export default class NotificationPopover extends React.Component {
<span className="sf3-font sf3-font-x-01 notification-close-icon" onClick={this.props.onNotificationListToggle}></span>
</div>
<div className="notification-body">
<div className="mark-notifications" onClick={this.props.onMarkAllNotifications}>{bodyText}</div>
<div className="mark-notifications">
<ul className="nav">
<li className="nav-item" onClick={() => this.tabItemClick('general')}>
<span className={`nav-link ${currentTab === 'general' ? 'active' : ''}`}>
{gettext('General')}
{generalNoticeListUnseen > 0 && <span>({generalNoticeListUnseen})</span>}
</span>
</li>
<li className="nav-item" onClick={() => this.tabItemClick('discussion')}>
<span className={`nav-link ${currentTab === 'discussion' ? 'active' : ''}`}>
{gettext('Discussion')}
{discussionNoticeListUnseen > 0 && <span>({discussionNoticeListUnseen})</span>}
</span>
</li>
</ul>
<span className="mark-all-read" onClick={this.props.onMarkAllNotifications}>
{bodyText}
</span>
</div>
{currentTab === 'general' &&
<div className="notification-list-container" onScroll={this.onHandleScroll} ref={ref => this.notificationListRef = ref}>
<div ref={ref => this.notificationsWrapperRef = ref}>
{this.props.children}
</div>
</div>
}
{currentTab === 'discussion' &&
<div className="notification-list-container" onScroll={this.onHandleScroll} ref={ref => this.notificationListRef = ref}>
<div ref={ref => this.notificationsWrapperRef = ref}>
{this.props.children}
</div>
</div>
}
<div className="notification-footer" onClick={this.onNotificationDialogToggle}>{footerText}</div>
</div>
</div>

View File

@@ -13,14 +13,17 @@ class Notification extends React.Component {
this.state = {
showNotice: false,
unseenCount: 0,
noticeList: [],
generalNoticeList: [],
discussionNoticeList: [],
currentTab: 'general',
isShowNotificationDialog: this.getInitDialogState(),
};
}
componentDidMount() {
seafileAPI.getUnseenNotificationCount().then(res => {
this.setState({ unseenCount: res.data.unseen_count });
seafileAPI.listAllNotifications().then(res => {
let unseen_count = res.data.general.unseen_count + res.data.discussion.unseen_count;
this.setState({ unseenCount: unseen_count });
});
}
@@ -38,28 +41,57 @@ class Notification extends React.Component {
}
};
tabItemClick = (tab) => {
const { currentTab } = this.state;
if (currentTab === tab) return;
this.setState({
showNotice: true,
currentTab: tab
});
};
loadNotices = () => {
let page = 1;
let perPage = 5;
seafileAPI.listNotifications(page, perPage).then(res => {
let noticeList = res.data.notification_list;
this.setState({ noticeList: noticeList });
seafileAPI.listAllNotifications(page, perPage).then(res => {
let generalNoticeList = res.data.general.notification_list;
let discussionNoticeList = res.data.discussion.notification_list;
this.setState({
generalNoticeList: generalNoticeList,
discussionNoticeList: discussionNoticeList
});
});
};
onNoticeItemClick = (noticeItem) => {
let noticeList = this.state.noticeList.map(item => {
if (item.id === noticeItem.id) {
item.seen = true;
}
return item;
});
seafileAPI.markNoticeAsRead(noticeItem.id);
let unseenCount = this.state.unseenCount === 0 ? 0 : this.state.unseenCount - 1;
this.setState({
noticeList: noticeList,
unseenCount: unseenCount,
});
if (this.state.currentTab === 'general') {
let noticeList = this.state.generalNoticeList.map(item => {
if (item.id === noticeItem.id) {
item.seen = true;
}
return item;
});
let unseenCount = this.state.unseenCount === 0 ? 0 : this.state.unseenCount - 1;
this.setState({
generalNoticeList: noticeList,
unseenCount: unseenCount,
});
seafileAPI.markNoticeAsRead(noticeItem.id);
}
if (this.state.currentTab === 'discussion') {
let noticeList = this.state.discussionNoticeList.map(item => {
if (item.id === noticeItem.id) {
item.seen = true;
}
return item;
});
let unseenCount = this.state.unseenCount === 0 ? 0 : this.state.unseenCount - 1;
this.setState({
discussionNoticeList: noticeList,
unseenCount: unseenCount,
});
seafileAPI.markSdocNoticeAsRead(noticeItem.id);
}
};
@@ -79,7 +111,7 @@ class Notification extends React.Component {
};
onMarkAllNotifications = () => {
seafileAPI.updateNotifications().then(() => {
seafileAPI.updateAllNotifications().then(() => {
this.setState({
unseenCount: 0,
});
@@ -91,7 +123,9 @@ class Notification extends React.Component {
};
render() {
const { unseenCount } = this.state;
const { unseenCount, currentTab, generalNoticeList, discussionNoticeList } = this.state;
const generalNoticeListUnseen = generalNoticeList.filter(item => !item.seen).length;
const discussionNoticeListUnseen = discussionNoticeList.filter(item => !item.seen).length;
return (
<div id="notifications">
<a href="#" onClick={this.onClick} className="no-deco" id="notice-icon" title={gettext('Notifications')} aria-label={gettext('Notifications')}>
@@ -103,19 +137,40 @@ class Notification extends React.Component {
headerText={gettext('Notification')}
bodyText={gettext('Mark all as read')}
footerText={gettext('View all notifications')}
currentTab={currentTab}
onNotificationListToggle={this.onNotificationListToggle}
onNotificationDialogToggle={this.onNotificationDialogToggle}
onMarkAllNotifications={this.onMarkAllNotifications}
tabItemClick={this.tabItemClick}
generalNoticeListUnseen={generalNoticeListUnseen}
discussionNoticeListUnseen={discussionNoticeListUnseen}
>
<ul className="notice-list list-unstyled" id="notice-popover">
{this.state.noticeList.map(item => {
return (<NoticeItem key={item.id} noticeItem={item} onNoticeItemClick={this.onNoticeItemClick}/>);
})}
</ul>
{currentTab === 'general' &&
<ul className="notice-list list-unstyled" id="notice-popover">
{generalNoticeList.map(item => {
return (
<NoticeItem key={item.id} noticeItem={item} onNoticeItemClick={this.onNoticeItemClick}/>
);
})}
</ul>
}
{currentTab === 'discussion' &&
<ul className="notice-list list-unstyled" id="notice-popover">
{discussionNoticeList.map(item => {
return (
<NoticeItem key={item.id} noticeItem={item} onNoticeItemClick={this.onNoticeItemClick}/>
);
})}
</ul>
}
</NotificationPopover>
}
{this.state.isShowNotificationDialog &&
<UserNotificationsDialog onNotificationDialogToggle={this.onNotificationDialogToggle} />
<UserNotificationsDialog
onNotificationDialogToggle={this.onNotificationDialogToggle}
generalNoticeListUnseen={generalNoticeListUnseen}
discussionNoticeListUnseen={discussionNoticeListUnseen}
/>
}
</div>
);