[NEW][BREAK] Message retention policy and pruning (#11236)
Closes #6749 Closes #8321 Closes #9374 Closes #2700 Closes #2639 Closes #2355 Closes #1861 Closes #8757 Closes #7228 Closes #10870 Closes #6193 Closes #11299 Closes #11468 Closes #9317 Closes #11300 (will incorporate a fix to this PR's issue) Closes #11046 (will incorporate a fix to this PR's issue) Contributes to #5944 Contributes to #11475 _...and possibly more!_ This PR makes deleting messages (automatically and manually) a lot easier on Rocket.Chat. - [X] Implement a bulk message deletion notification, to quickly push large message deletions to users without reload - [X] Use it in `rooms.cleanHistory` - [X] Use it in user deletions - [X] Completely remove cleanChannelHistory as required by v0.67 - [X] Remove server method `cleanChannelHistory` - [X] Remove REST API `channels.cleanHistory` - [x] Implement a sidebar option to clean history - [x] Basic implementation - [x] Allow excluding pinned messages - [x] Allow attachment-only mode - [x] Allow specifying user(s) to narrow down to - [x] Also update REST API - [x] Also update docs - [x] Break the deletion into multiple different requests, so the client can keep track of progress - [x] Clear animation / progress bar for deleting - [x] Retention policy - [X] Global, set by admin - [X] Global timer that runs every second and deletes messages over the set limit - [X] Can change its timer's resolution to prevent insane CPU overhead - [X] Admin can decide what room types to target (channels, groups and/or DMs) - [X] Allow excluding pinned messages - [X] Allow attachment-only mode - [x] Per-channel, set by those with a new permission - [x] Disabled when master switch off - [x] Set in channel info - [x] Can override global policy with a switch that requires `edit-privileged-setting` - [x] Allow excluding pinned messages - [x] Allow attachment-only mode - [x] Uses same global timer for cleanup - [X] Message at start of channel history / in channel info if there is a retention policy set - [x] Message in channel info if there is a retention policy set on that channel specifically - [X] Make cleaning history also delete files (completely!) - [X] Manual purging - [X] Automatic purging - [x] Make other deletions also delete files - [x] User deletion - [X] Own messages - [x] DMs with them's partner messages - [x] Room deletion - [x] Cleanup - [x] Finish related [docs](https://github.com/RocketChat/docs/pull/815) - [x] Link to the docs in the settings Please suggest any cool changes/additions! Any support is greatly appreciated. **Breaking change:** This PR removes REST API endpoint `channels.cleanHistory` and Meteor callable `cleanChannelHistory` as per the protocol specified for them. pull/11519/head
parent
5244bd51e7
commit
829c5d1bfb
@ -0,0 +1,7 @@ |
||||
Meteor.startup(function() { |
||||
RocketChat.Notifications.onLogged('Users:Deleted', ({ userId }) => |
||||
ChatMessage.remove({ |
||||
'u._id': userId |
||||
}) |
||||
); |
||||
}); |
@ -0,0 +1,43 @@ |
||||
RocketChat.cleanRoomHistory = function({ rid, latest = new Date(), oldest = new Date('0001-01-01T00:00:00Z'), inclusive = true, limit = 0, excludePinned = true, filesOnly = false, fromUsers = [] }) { |
||||
const gt = inclusive ? '$gte' : '$gt'; |
||||
const lt = inclusive ? '$lte' : '$lt'; |
||||
|
||||
const ts = { [gt]: oldest, [lt]: latest }; |
||||
|
||||
const text = `_${ TAPi18n.__('File_removed_by_prune') }_`; |
||||
|
||||
let fileCount = 0; |
||||
RocketChat.models.Messages.findFilesByRoomIdPinnedTimestampAndUsers( |
||||
rid, |
||||
excludePinned, |
||||
ts, |
||||
fromUsers, |
||||
{ fields: { 'file._id': 1, pinned: 1 }, limit } |
||||
).forEach(document => { |
||||
FileUpload.getStore('Uploads').deleteById(document.file._id); |
||||
fileCount++; |
||||
if (filesOnly) { |
||||
RocketChat.models.Messages.update({ _id: document._id }, { $unset: { file: 1 }, $set: { attachments: [{ color: '#FD745E', text }] } }); |
||||
} |
||||
}); |
||||
if (filesOnly) { |
||||
return fileCount; |
||||
} |
||||
|
||||
let count = 0; |
||||
if (limit) { |
||||
count = RocketChat.models.Messages.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ts, limit, fromUsers); |
||||
} else { |
||||
count = RocketChat.models.Messages.removeByIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers); |
||||
} |
||||
|
||||
if (count) { |
||||
RocketChat.Notifications.notifyRoom(rid, 'deleteMessageBulk', { |
||||
rid, |
||||
excludePinned, |
||||
ts, |
||||
users: fromUsers |
||||
}); |
||||
} |
||||
return count; |
||||
}; |
@ -1,10 +0,0 @@ |
||||
Meteor.methods({ |
||||
/** |
||||
DEPRECATED |
||||
// TODO: Remove this after three versions have been released. That means at 0.67 this should be gone.
|
||||
*/ |
||||
cleanChannelHistory({ roomId, latest, oldest, inclusive }) { |
||||
console.warn('The method "cleanChannelHistory" is deprecated and will be removed after version 0.67, please use "cleanRoomHistory" instead'); |
||||
Meteor.call('cleanRoomHistory', { roomId, latest, oldest, inclusive }); |
||||
} |
||||
}); |
@ -1,34 +1,26 @@ |
||||
/* globals FileUpload */ |
||||
|
||||
Meteor.methods({ |
||||
cleanRoomHistory({ roomId, latest, oldest, inclusive }) { |
||||
cleanRoomHistory({ roomId, latest, oldest, inclusive = true, limit, excludePinned = false, filesOnly = false, fromUsers = [] }) { |
||||
check(roomId, String); |
||||
check(latest, Date); |
||||
check(oldest, Date); |
||||
check(inclusive, Boolean); |
||||
check(limit, Match.Maybe(Number)); |
||||
check(excludePinned, Match.Maybe(Boolean)); |
||||
check(filesOnly, Match.Maybe(Boolean)); |
||||
check(fromUsers, Match.Maybe([String])); |
||||
|
||||
const userId = Meteor.userId(); |
||||
|
||||
if (!Meteor.userId()) { |
||||
if (!userId) { |
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cleanRoomHistory' }); |
||||
} |
||||
|
||||
if (!RocketChat.authz.hasPermission(Meteor.userId(), 'clean-channel-history')) { |
||||
if (!RocketChat.authz.hasPermission(userId, 'clean-channel-history', roomId)) { |
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' }); |
||||
} |
||||
|
||||
if (inclusive) { |
||||
RocketChat.models.Messages.remove({ |
||||
rid: roomId, |
||||
ts: { |
||||
$gte: oldest, |
||||
$lte: latest |
||||
} |
||||
}); |
||||
} else { |
||||
RocketChat.models.Messages.remove({ |
||||
rid: roomId, |
||||
ts: { |
||||
$gt: oldest, |
||||
$lt: latest |
||||
} |
||||
}); |
||||
} |
||||
return RocketChat.cleanRoomHistory({ rid: roomId, latest, oldest, inclusive, limit, excludePinned, filesOnly, fromUsers }); |
||||
} |
||||
}); |
||||
|
@ -0,0 +1,25 @@ |
||||
Package.describe({ |
||||
name: 'rocketchat:retention-policy', |
||||
version: '0.0.1', |
||||
// Brief, one-line summary of the package.
|
||||
summary: '', |
||||
// URL to the Git repository containing the source code for this package.
|
||||
git: '', |
||||
// By default, Meteor will default to using README.md for documentation.
|
||||
// To avoid submitting documentation, set this field to null.
|
||||
documentation: 'README.md' |
||||
}); |
||||
|
||||
Package.onUse(function(api) { |
||||
api.use([ |
||||
'mongo', |
||||
'ecmascript', |
||||
'templating', |
||||
'rocketchat:lib' |
||||
]); |
||||
|
||||
api.addFiles([ |
||||
'server/startup/settings.js', |
||||
'server/cronPruneMessages.js' |
||||
], 'server'); |
||||
}); |
@ -0,0 +1,123 @@ |
||||
/* globals SyncedCron */ |
||||
|
||||
let types = []; |
||||
|
||||
const oldest = new Date('0001-01-01T00:00:00Z'); |
||||
|
||||
let lastPrune = oldest; |
||||
|
||||
const maxTimes = { |
||||
c: 0, |
||||
p: 0, |
||||
d: 0 |
||||
}; |
||||
const toDays = 1000 * 60 * 60 * 24; |
||||
const gracePeriod = 5000; |
||||
function job() { |
||||
const now = new Date(); |
||||
const filesOnly = RocketChat.settings.get('RetentionPolicy_FilesOnly'); |
||||
const excludePinned = RocketChat.settings.get('RetentionPolicy_ExcludePinned'); |
||||
|
||||
// get all rooms with default values
|
||||
types.forEach(type => { |
||||
const maxAge = maxTimes[type] || 0; |
||||
const latest = new Date(now.getTime() - maxAge * toDays); |
||||
|
||||
RocketChat.models.Rooms.find({ |
||||
t: type, |
||||
_updatedAt: { $gte: lastPrune }, |
||||
$or: [{'retention.enabled': { $eq: true } }, { 'retention.enabled': { $exists: false } }], |
||||
'retention.overrideGlobal': { $ne: true } |
||||
}).forEach(({ _id: rid }) => { |
||||
RocketChat.cleanRoomHistory({ rid, latest, oldest, filesOnly, excludePinned }); |
||||
}); |
||||
}); |
||||
|
||||
RocketChat.models.Rooms.find({ |
||||
'retention.enabled': { $eq: true }, |
||||
'retention.overrideGlobal': { $eq: true }, |
||||
'retention.maxAge': { $gte: 0 }, |
||||
_updatedAt: { $gte: lastPrune } |
||||
}).forEach(room => { |
||||
const { maxAge = 30, filesOnly, excludePinned } = room.retention; |
||||
const latest = new Date(now.getTime() - maxAge * toDays); |
||||
RocketChat.cleanRoomHistory({ rid: room._id, latest, oldest, filesOnly, excludePinned }); |
||||
}); |
||||
lastPrune = new Date(now.getTime() - gracePeriod); |
||||
} |
||||
|
||||
function getSchedule(precision) { |
||||
switch (precision) { |
||||
case '0': |
||||
return '0 */30 * * * *'; |
||||
case '1': |
||||
return '0 0 * * * *'; |
||||
case '2': |
||||
return '0 0 */6 * * *'; |
||||
case '3': |
||||
return '0 0 0 * * *'; |
||||
} |
||||
} |
||||
|
||||
const pruneCronName = 'Prune old messages by retention policy'; |
||||
|
||||
function deployCron(precision) { |
||||
const schedule = parser => parser.cron(getSchedule(precision), true); |
||||
|
||||
SyncedCron.remove(pruneCronName); |
||||
SyncedCron.add({ |
||||
name: pruneCronName, |
||||
schedule, |
||||
job |
||||
}); |
||||
} |
||||
|
||||
function reloadPolicy() { |
||||
types = []; |
||||
|
||||
if (RocketChat.settings.get('RetentionPolicy_Enabled')) { |
||||
if (RocketChat.settings.get('RetentionPolicy_AppliesToChannels')) { |
||||
types.push('c'); |
||||
} |
||||
|
||||
if (RocketChat.settings.get('RetentionPolicy_AppliesToGroups')) { |
||||
types.push('p'); |
||||
} |
||||
|
||||
if (RocketChat.settings.get('RetentionPolicy_AppliesToDMs')) { |
||||
types.push('d'); |
||||
} |
||||
|
||||
maxTimes.c = RocketChat.settings.get('RetentionPolicy_MaxAge_Channels'); |
||||
maxTimes.p = RocketChat.settings.get('RetentionPolicy_MaxAge_Groups'); |
||||
maxTimes.d = RocketChat.settings.get('RetentionPolicy_MaxAge_DMs'); |
||||
|
||||
return deployCron(RocketChat.settings.get('RetentionPolicy_Precision')); |
||||
} |
||||
return SyncedCron.remove(pruneCronName); |
||||
} |
||||
|
||||
Meteor.startup(function() { |
||||
Meteor.defer(function() { |
||||
RocketChat.models.Settings.find({ |
||||
_id: { |
||||
$in: [ |
||||
'RetentionPolicy_Enabled', |
||||
'RetentionPolicy_Precision', |
||||
'RetentionPolicy_AppliesToChannels', |
||||
'RetentionPolicy_AppliesToGroups', |
||||
'RetentionPolicy_AppliesToDMs', |
||||
'RetentionPolicy_MaxAge_Channels', |
||||
'RetentionPolicy_MaxAge_Groups', |
||||
'RetentionPolicy_MaxAge_DMs' |
||||
] |
||||
} |
||||
}).observe({ |
||||
changed() { |
||||
reloadPolicy(); |
||||
} |
||||
}); |
||||
|
||||
reloadPolicy(); |
||||
}); |
||||
}); |
@ -0,0 +1,107 @@ |
||||
RocketChat.settings.addGroup('RetentionPolicy', function() { |
||||
|
||||
this.add('RetentionPolicy_Enabled', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_Enabled', |
||||
alert: 'Watch out! Tweaking these settings without utmost care can destroy all message history. Please read the documentation before turning the feature on at rocket.chat/docs/administrator-guides/retention-policies/' |
||||
}); |
||||
|
||||
this.add('RetentionPolicy_Precision', '0', { |
||||
type: 'select', |
||||
values: [ |
||||
{ |
||||
key: '0', |
||||
i18nLabel: 'every_30_minutes' |
||||
}, { |
||||
key: '1', |
||||
i18nLabel: 'every_hour' |
||||
}, { |
||||
key: '2', |
||||
i18nLabel: 'every_six_hours' |
||||
}, { |
||||
key: '3', |
||||
i18nLabel: 'every_day' |
||||
} |
||||
], |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_Precision', |
||||
i18nDescription: 'RetentionPolicy_Precision_Description', |
||||
enableQuery: { |
||||
_id: 'RetentionPolicy_Enabled', |
||||
value: true |
||||
} |
||||
}); |
||||
|
||||
this.section('Global Policy', function() { |
||||
const globalQuery = { |
||||
_id: 'RetentionPolicy_Enabled', |
||||
value: true |
||||
}; |
||||
|
||||
this.add('RetentionPolicy_AppliesToChannels', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_AppliesToChannels', |
||||
enableQuery: globalQuery |
||||
}); |
||||
this.add('RetentionPolicy_MaxAge_Channels', 30, { |
||||
type: 'int', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_MaxAge_Channels', |
||||
i18nDescription: 'RetentionPolicy_MaxAge_Description', |
||||
enableQuery: [{ |
||||
_id: 'RetentionPolicy_AppliesToChannels', |
||||
value: true |
||||
}, globalQuery] |
||||
}); |
||||
|
||||
this.add('RetentionPolicy_AppliesToGroups', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_AppliesToGroups', |
||||
enableQuery: globalQuery |
||||
}); |
||||
this.add('RetentionPolicy_MaxAge_Groups', 30, { |
||||
type: 'int', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_MaxAge_Groups', |
||||
i18nDescription: 'RetentionPolicy_MaxAge_Description', |
||||
enableQuery: [{ |
||||
_id: 'RetentionPolicy_AppliesToGroups', |
||||
value: true |
||||
}, globalQuery] |
||||
}); |
||||
|
||||
this.add('RetentionPolicy_AppliesToDMs', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_AppliesToDMs', |
||||
enableQuery: globalQuery |
||||
}); |
||||
this.add('RetentionPolicy_MaxAge_DMs', 30, { |
||||
type: 'int', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_MaxAge_DMs', |
||||
i18nDescription: 'RetentionPolicy_MaxAge_Description', |
||||
enableQuery: [{ |
||||
_id: 'RetentionPolicy_AppliesToDMs', |
||||
value: true |
||||
}, globalQuery] |
||||
}); |
||||
|
||||
this.add('RetentionPolicy_ExcludePinned', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_ExcludePinned', |
||||
enableQuery: globalQuery |
||||
}); |
||||
this.add('RetentionPolicy_FilesOnly', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
i18nLabel: 'RetentionPolicy_FilesOnly', |
||||
i18nDescription: 'RetentionPolicy_FilesOnly_Description', |
||||
enableQuery: globalQuery |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,12 @@ |
||||
Meteor.startup(() => { |
||||
RocketChat.TabBar.addButton({ |
||||
groups: ['channel', 'group', 'direct'], |
||||
id: 'clean-history', |
||||
anonymous: true, |
||||
i18nTitle: 'Prune_Messages', |
||||
icon: 'trash', |
||||
template: 'cleanHistory', |
||||
order: 250, |
||||
condition: () => RocketChat.authz.hasAllPermission('clean-channel-history', Session.get('openedRoom')) |
||||
}); |
||||
}); |
@ -0,0 +1,142 @@ |
||||
<template name="cleanHistory"> |
||||
{{#unless busy}} |
||||
<main class="rc-user-info__scroll"> |
||||
<div class="rc-input"> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ "Newer_than"}}</div> |
||||
<div class="rc-input__wrapper rc-datetime__left"> |
||||
<div class="rc-input__icon"> |
||||
{{> icon block="rc-input__icon-svg" icon="calendar"}} |
||||
</div> |
||||
<input type="date" class="rc-input__element" value="" name="from__date" autocomplete="off" placeholder="YYYY-MM-DD"/> |
||||
</div> |
||||
<div class="rc-input__wrapper rc-datetime__right"> |
||||
<div class="rc-input__icon"> |
||||
{{> icon block="rc-input__icon-svg" icon="clock"}} |
||||
</div> |
||||
<input type="time" class="rc-input__element" value="" name="from__time" autocomplete="off" placeholder="HH:MM"/> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
<div class="rc-input"> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ "Older_than"}}</div> |
||||
<div class="rc-input__wrapper rc-datetime__left"> |
||||
<div class="rc-input__icon"> |
||||
{{> icon block="rc-input__icon-svg" icon="calendar"}} |
||||
</div> |
||||
<input type="date" class="rc-input__element" value="" name="to__date" autocomplete="off" placeholder="YYYY-MM-DD"/> |
||||
</div> |
||||
<div class="rc-input__wrapper rc-datetime__right"> |
||||
<div class="rc-input__icon"> |
||||
{{> icon block="rc-input__icon-svg" icon="clock"}} |
||||
</div> |
||||
<input type="time" class="rc-input__element" value="" name="to__time" autocomplete="off" placeholder="HH:MM"/> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
<div class="rc-input rc-input--usernames"> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ "Only_from_users"}}</div> |
||||
<div class="rc-input__wrapper"> |
||||
<div class="rc-input__icon"> |
||||
{{> icon block="rc-input__icon-svg" icon="at"}} |
||||
</div> |
||||
<div class="rc-tags"> |
||||
{{#each user in selectedUsers}} |
||||
{{> tag user}} |
||||
{{/each}} |
||||
<input type="text" class="rc-tags__input" placeholder="{{_ "Username_Placeholder"}}" name="users" autocomplete="off"/> |
||||
</div> |
||||
</div> |
||||
{{#with config}} |
||||
{{#if autocomplete 'isShowing'}} |
||||
{{#if autocomplete 'isLoaded'}} |
||||
{{> popupList data=config items=items}} |
||||
{{/if}} |
||||
{{/if}} |
||||
{{/with}} |
||||
</label> |
||||
</div> |
||||
<label class="rc-checkbox"> |
||||
<input type="checkbox" name="inclusive" class="rc-checkbox__input"> |
||||
{{> icon icon="check" block="rc-checkbox__icon"}} |
||||
<span class="rc-checkbox__text rc-text__small">{{_ "Inclusive"}}</span> |
||||
</label> |
||||
<label class="rc-checkbox"> |
||||
<input type="checkbox" name="excludePinned" class="rc-checkbox__input"> |
||||
{{> icon icon="check" block="rc-checkbox__icon"}} |
||||
<span class="rc-checkbox__text rc-text__small">{{_ "Exclude_pinned"}}</span> |
||||
</label> |
||||
<label class="rc-checkbox"> |
||||
<input type="checkbox" name="filesOnly" class="rc-checkbox__input"> |
||||
{{> icon icon="check" block="rc-checkbox__icon"}} |
||||
<span class="rc-checkbox__text rc-text__small">{{_ "Files_only"}}</span> |
||||
</label> |
||||
{{#unless validate}} |
||||
<div class="mail-messages__instructions mail-messages__instructions--warning"> |
||||
<div class="mail-messages__instructions-wrapper"> |
||||
{{> icon block="mail-messages__instructions-icon" icon="warning"}} |
||||
<div class="mail-messages__instructions-text"> |
||||
{{warningBox}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{else}} |
||||
<div class="mail-messages__instructions mail-messages__instructions--warning"> |
||||
<div class="mail-messages__instructions-wrapper"> |
||||
{{> icon block="mail-messages__instructions-icon" icon="modal-error"}} |
||||
<div class="mail-messages__instructions-text"> |
||||
{{validate}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{/unless}} |
||||
</main> |
||||
<div class="rc-user-info__flex rc-user-info__row"> |
||||
<button class="rc-button rc-button--cancel js-prune" title="{{_ 'Prune'}}" disabled="{{#with validate}}{{.}}{{/with}}">{{_ 'Prune'}}</button> |
||||
</div> |
||||
{{else}} |
||||
{{#unless finished}} |
||||
<main class="rc-user-info__pruning"> |
||||
<p class="pruning__header"> |
||||
{{#unless filesOnly}} |
||||
{{_ "Pruning_messages"}} |
||||
{{else}} |
||||
{{_ "Pruning_files"}} |
||||
{{/unless}} |
||||
</p> |
||||
<div class="pruning-wrapper"> |
||||
{{> icon icon="loading-thin"}} |
||||
<div class="pruning__text"> |
||||
{{prunedCount}} |
||||
</div> |
||||
<div class="pruning__text-sub"> |
||||
{{#unless filesOnly}} |
||||
{{_ "messages_pruned"}} |
||||
{{else}} |
||||
{{_ "files_pruned"}} |
||||
{{/unless}} |
||||
</div> |
||||
</div> |
||||
</main> |
||||
{{else}} |
||||
<main class="rc-user-info__pruning"> |
||||
<p class="pruning__header">{{_ "Prune_finished"}}</p> |
||||
<div class="pruning-wrapper prune__finished"> |
||||
{{> icon icon="loading-thin-done"}} |
||||
<div class="pruning__text"> |
||||
{{prunedCount}} |
||||
</div> |
||||
<div class="pruning__text-sub"> |
||||
{{#unless filesOnly}} |
||||
{{_ "messages_pruned"}} |
||||
{{else}} |
||||
{{_ "files_pruned"}} |
||||
{{/unless}} |
||||
</div> |
||||
</div> |
||||
</main> |
||||
{{/unless}} |
||||
{{/unless}} |
||||
</template> |
@ -0,0 +1,352 @@ |
||||
/* globals AutoComplete */ |
||||
import moment from 'moment'; |
||||
|
||||
import { call } from 'meteor/rocketchat:lib'; |
||||
|
||||
const getRoomName = function() { |
||||
const room = ChatRoom.findOne(Session.get('openedRoom')); |
||||
if (!room) { |
||||
return; |
||||
} |
||||
if (room.name) { |
||||
return `#${ room.name }`; |
||||
} |
||||
|
||||
return t('conversation_with_s', RocketChat.roomTypes.getRoomName(room.t, room)); |
||||
}; |
||||
|
||||
const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePinned, filesOnly, fromUsers) { |
||||
return call('cleanRoomHistory', { |
||||
roomId, |
||||
latest, |
||||
oldest, |
||||
inclusive, |
||||
limit, |
||||
excludePinned, |
||||
filesOnly, |
||||
fromUsers |
||||
}); |
||||
}; |
||||
|
||||
|
||||
const getTimeZoneOffset = function() { |
||||
const offset = new Date().getTimezoneOffset(); |
||||
const absOffset = Math.abs(offset); |
||||
return `${ offset < 0 ? '+' : '-' }${ (`00${ Math.floor(absOffset / 60) }`).slice(-2) }:${ (`00${ (absOffset % 60) }`).slice(-2) }`; |
||||
}; |
||||
|
||||
|
||||
const filterNames = (old) => { |
||||
const reg = new RegExp(`^${ RocketChat.settings.get('UTF8_Names_Validation') }$`); |
||||
return [...old.replace(' ', '').toLocaleLowerCase()].filter(f => reg.test(f)).join(''); |
||||
}; |
||||
|
||||
Template.cleanHistory.helpers({ |
||||
roomId() { |
||||
const room = ChatRoom.findOne(Session.get('openedRoom')); |
||||
return room && room._id; |
||||
}, |
||||
roomName() { |
||||
return getRoomName(); |
||||
}, |
||||
warningBox() { |
||||
return Template.instance().warningBox.get(); |
||||
}, |
||||
validate() { |
||||
return Template.instance().validate.get(); |
||||
}, |
||||
filesOnly() { |
||||
return Template.instance().cleanHistoryFilesOnly.get(); |
||||
}, |
||||
busy() { |
||||
return Template.instance().cleanHistoryBusy.get(); |
||||
}, |
||||
finished() { |
||||
return Template.instance().cleanHistoryFinished.get(); |
||||
}, |
||||
prunedCount() { |
||||
return Template.instance().cleanHistoryPrunedCount.get(); |
||||
}, |
||||
config() { |
||||
const filter = Template.instance().userFilter; |
||||
return { |
||||
filter: filter.get(), |
||||
noMatchTemplate: 'userSearchEmpty', |
||||
modifier(text) { |
||||
const f = filter.get(); |
||||
return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), function(part) { |
||||
return `<strong>${ part }</strong>`; |
||||
}) }`;
|
||||
} |
||||
}; |
||||
}, |
||||
autocompleteSettings() { |
||||
return { |
||||
limit: 10, |
||||
rules: [ |
||||
{ |
||||
collection: 'CachedChannelList', |
||||
subscription: 'userAutocomplete', |
||||
field: 'username', |
||||
template: Template.userSearch, |
||||
noMatchTemplate: Template.userSearchEmpty, |
||||
matchAll: true, |
||||
filter: { |
||||
exceptions: Template.instance().selectedUsers.get() |
||||
}, |
||||
selector(match) { |
||||
return { |
||||
term: match |
||||
}; |
||||
}, |
||||
sort: 'username' |
||||
} |
||||
] |
||||
}; |
||||
}, |
||||
selectedUsers() { |
||||
return Template.instance().selectedUsers.get(); |
||||
}, |
||||
autocomplete(key) { |
||||
const instance = Template.instance(); |
||||
const param = instance.ac[key]; |
||||
return typeof param === 'function' ? param.apply(instance.ac): param; |
||||
}, |
||||
items() { |
||||
return Template.instance().ac.filteredList(); |
||||
} |
||||
}); |
||||
|
||||
Template.cleanHistory.onCreated(function() { |
||||
this.warningBox = new ReactiveVar(''); |
||||
this.validate = new ReactiveVar(''); |
||||
this.selectedUsers = new ReactiveVar([]); |
||||
this.userFilter = new ReactiveVar(''); |
||||
|
||||
this.cleanHistoryFromDate = new ReactiveVar(''); |
||||
this.cleanHistoryFromTime = new ReactiveVar(''); |
||||
this.cleanHistoryToDate = new ReactiveVar(''); |
||||
this.cleanHistoryToTime = new ReactiveVar(''); |
||||
this.cleanHistorySelectedUsers = new ReactiveVar([]); |
||||
this.cleanHistoryInclusive = new ReactiveVar(false); |
||||
this.cleanHistoryExcludePinned = new ReactiveVar(false); |
||||
this.cleanHistoryFilesOnly = new ReactiveVar(false); |
||||
|
||||
|
||||
this.cleanHistoryBusy = new ReactiveVar(false); |
||||
this.cleanHistoryFinished = new ReactiveVar(false); |
||||
this.cleanHistoryPrunedCount = new ReactiveVar(0); |
||||
|
||||
this.ac = new AutoComplete( |
||||
{ |
||||
selector:{ |
||||
item: '.rc-popup-list__item', |
||||
container: '.rc-popup-list__list' |
||||
}, |
||||
|
||||
limit: 10, |
||||
inputDelay: 300, |
||||
rules: [ |
||||
{ |
||||
collection: 'UserAndRoom', |
||||
subscription: 'userAutocomplete', |
||||
field: 'username', |
||||
matchAll: true, |
||||
doNotChangeWidth: false, |
||||
selector(match) { |
||||
return { term: match }; |
||||
}, |
||||
sort: 'username' |
||||
} |
||||
] |
||||
|
||||
}); |
||||
this.ac.tmplInst = this; |
||||
}); |
||||
|
||||
Template.cleanHistory.onRendered(function() { |
||||
const users = this.selectedUsers; |
||||
|
||||
this.ac.element = this.firstNode.parentElement.querySelector('[name="users"]'); |
||||
this.ac.$element = $(this.ac.element); |
||||
this.ac.$element.on('autocompleteselect', function(e, {item}) { |
||||
const usersArr = users.get(); |
||||
usersArr.push(item); |
||||
users.set(usersArr); |
||||
Session.set('cleanHistorySelectedUsers', usersArr); |
||||
}); |
||||
|
||||
Tracker.autorun(() => { |
||||
const metaFromDate = this.cleanHistoryFromDate.get(); |
||||
const metaFromTime = this.cleanHistoryFromTime.get(); |
||||
const metaToDate = this.cleanHistoryToDate.get(); |
||||
const metaToTime = this.cleanHistoryToTime.get(); |
||||
const metaSelectedUsers = this.cleanHistorySelectedUsers.get(); |
||||
const metaCleanHistoryExcludePinned = this.cleanHistoryExcludePinned.get(); |
||||
const metaCleanHistoryFilesOnly = this.cleanHistoryFilesOnly.get(); |
||||
|
||||
let fromDate = new Date('0001-01-01T00:00:00Z'); |
||||
let toDate = new Date('9999-12-31T23:59:59Z'); |
||||
|
||||
if (metaFromDate) { |
||||
fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`); |
||||
} |
||||
|
||||
if (metaToDate) { |
||||
toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`); |
||||
} |
||||
|
||||
const exceptPinned = metaCleanHistoryExcludePinned ? ` ${ t('except_pinned', {}) }` : ''; |
||||
const ifFrom = metaSelectedUsers.length ? ` ${ t('if_they_are_from', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [metaSelectedUsers.map(element => element.username).join(', ')] |
||||
}) }` : '';
|
||||
const filesOrMessages = t(metaCleanHistoryFilesOnly ? 'files' : 'messages', {}); |
||||
|
||||
if (metaFromDate && metaToDate) { |
||||
this.warningBox.set(t('Prune_Warning_between', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT'), moment(toDate).format('L LT')] |
||||
}) + exceptPinned + ifFrom); |
||||
} else if (metaFromDate) { |
||||
this.warningBox.set(t('Prune_Warning_after', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT')] |
||||
}) + exceptPinned + ifFrom); |
||||
} else if (metaToDate) { |
||||
this.warningBox.set(t('Prune_Warning_before', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [filesOrMessages, getRoomName(), moment(toDate).format('L LT')] |
||||
}) + exceptPinned + ifFrom); |
||||
} else { |
||||
this.warningBox.set(t('Prune_Warning_all', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [filesOrMessages, getRoomName()] |
||||
}) + exceptPinned + ifFrom); |
||||
} |
||||
|
||||
if (fromDate > toDate) { |
||||
return this.validate.set(t('Newer_than_may_not_exceed_Older_than', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [] |
||||
})); |
||||
} |
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { |
||||
return this.validate.set(t('error-invalid-date', { |
||||
postProcess: 'sprintf', |
||||
sprintf: [] |
||||
})); |
||||
} |
||||
this.validate.set(''); |
||||
}); |
||||
}); |
||||
|
||||
Template.cleanHistory.events({ |
||||
'change [name=from__date]'(e, instance) { |
||||
instance.cleanHistoryFromDate.set(e.target.value); |
||||
}, |
||||
'change [name=from__time]'(e, instance) { |
||||
instance.cleanHistoryFromTime.set(e.target.value); |
||||
}, |
||||
'change [name=to__date]'(e, instance) { |
||||
instance.cleanHistoryToDate.set(e.target.value); |
||||
}, |
||||
'change [name=to__time]'(e, instance) { |
||||
instance.cleanHistoryToTime.set(e.target.value); |
||||
}, |
||||
'change [name=inclusive]'(e, instance) { |
||||
instance.cleanHistoryInclusive.set(e.target.checked); |
||||
}, |
||||
'change [name=excludePinned]'(e, instance) { |
||||
instance.cleanHistoryExcludePinned.set(e.target.checked); |
||||
}, |
||||
'change [name=filesOnly]'(e, instance) { |
||||
instance.cleanHistoryFilesOnly.set(e.target.checked); |
||||
}, |
||||
'click .js-prune'(e, instance) { |
||||
|
||||
modal.open({ |
||||
title: t('Are_you_sure'), |
||||
text: t('Prune_Modal'), |
||||
type: 'warning', |
||||
showCancelButton: true, |
||||
confirmButtonColor: '#DD6B55', |
||||
confirmButtonText: t('Yes_prune_them'), |
||||
cancelButtonText: t('Cancel'), |
||||
closeOnConfirm: true, |
||||
html: false |
||||
}, async function() { |
||||
instance.cleanHistoryBusy.set(true); |
||||
const metaFromDate = instance.cleanHistoryFromDate.get(); |
||||
const metaFromTime = instance.cleanHistoryFromTime.get(); |
||||
const metaToDate = instance.cleanHistoryToDate.get(); |
||||
const metaToTime = instance.cleanHistoryToTime.get(); |
||||
const metaSelectedUsers = instance.cleanHistorySelectedUsers.get(); |
||||
const metaCleanHistoryInclusive = instance.cleanHistoryInclusive.get(); |
||||
const metaCleanHistoryExcludePinned = instance.cleanHistoryExcludePinned.get(); |
||||
const metaCleanHistoryFilesOnly = instance.cleanHistoryFilesOnly.get(); |
||||
|
||||
let fromDate = new Date('0001-01-01T00:00:00Z'); |
||||
let toDate = new Date('9999-12-31T23:59:59Z'); |
||||
|
||||
if (metaFromDate) { |
||||
fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`); |
||||
} |
||||
|
||||
if (metaToDate) { |
||||
toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`); |
||||
} |
||||
|
||||
const roomId = Session.get('openedRoom'); |
||||
const users = metaSelectedUsers.map(element => element.username); |
||||
const limit = 2000; |
||||
let count = 0; |
||||
let result; |
||||
do { |
||||
result = await purgeWorker(roomId, fromDate, toDate, metaCleanHistoryInclusive, limit, metaCleanHistoryExcludePinned, metaCleanHistoryFilesOnly, users); |
||||
count += result; |
||||
} while (result === limit); |
||||
|
||||
instance.cleanHistoryPrunedCount.set(count); |
||||
instance.cleanHistoryFinished.set(true); |
||||
}); |
||||
}, |
||||
'click .rc-input--usernames .rc-tags__tag'({target}, t) { |
||||
const {username} = Blaze.getData(target); |
||||
t.selectedUsers.set(t.selectedUsers.get().filter(user => user.username !== username)); |
||||
Session.set('cleanHistorySelectedUsers', t.selectedUsers.get()); |
||||
}, |
||||
'click .rc-popup-list__item'(e, t) { |
||||
t.ac.onItemClick(this, e); |
||||
}, |
||||
'input [name="users"]'(e, t) { |
||||
const input = e.target; |
||||
const position = input.selectionEnd || input.selectionStart; |
||||
const length = input.value.length; |
||||
const modified = filterNames(input.value); |
||||
input.value = modified; |
||||
document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length); |
||||
|
||||
t.userFilter.set(modified); |
||||
}, |
||||
'keydown [name="users"]'(e, t) { |
||||
if ([8, 46].includes(e.keyCode) && e.target.value === '') { |
||||
const users = t.selectedUsers; |
||||
const usersArr = users.get(); |
||||
usersArr.pop(); |
||||
Session.set('cleanHistorySelectedUsers', usersArr); |
||||
return users.set(usersArr); |
||||
} |
||||
|
||||
t.ac.onKeyDown(e); |
||||
}, |
||||
'keyup [name="users"]'(e, t) { |
||||
t.ac.onKeyUp(e); |
||||
}, |
||||
'focus [name="users"]'(e, t) { |
||||
t.ac.onFocus(e); |
||||
}, |
||||
'blur [name="users"]'(e, t) { |
||||
t.ac.onBlur(e); |
||||
} |
||||
}); |
@ -0,0 +1,52 @@ |
||||
.rc-datetime__left { |
||||
display: inline-block; |
||||
width: 52%; |
||||
} |
||||
|
||||
.rc-datetime__right { |
||||
display: inline-block; |
||||
width: calc(48% - 0.3rem); |
||||
} |
||||
|
||||
.rc-user-info__pruning { |
||||
position: absolute; |
||||
left: 50%; |
||||
top: 50%; |
||||
transform: translate(-50%, calc(-50% - 2rem)); |
||||
} |
||||
|
||||
.pruning__header { |
||||
text-align: center; |
||||
font-weight: 900; |
||||
} |
||||
|
||||
.pruning-wrapper { |
||||
text-align: center; |
||||
color: var(--rc-color-link-active); |
||||
|
||||
&.prune__finished { |
||||
color: #12C212; |
||||
} |
||||
|
||||
& svg { |
||||
width: 16rem; |
||||
height: 16rem; |
||||
margin: 1rem 0; |
||||
animation: pruningSpin 2s linear infinite; |
||||
} |
||||
|
||||
& .pruning__text { |
||||
margin-top: -17rem; |
||||
line-height: 16rem; |
||||
font-size: 3.5em; |
||||
} |
||||
|
||||
& .pruning__text-sub { |
||||
margin-top: calc(-8rem + 1.5em); |
||||
} |
||||
} |
||||
|
||||
@keyframes pruningSpin { |
||||
0% { transform: rotate(0deg); } |
||||
100% { transform: rotate(360deg); } |
||||
} |
@ -0,0 +1,27 @@ |
||||
Package.describe({ |
||||
name: 'rocketchat:ui-clean-history', |
||||
version: '0.0.1', |
||||
// Brief, one-line summary of the package.
|
||||
summary: '', |
||||
// URL to the Git repository containing the source code for this package.
|
||||
git: '', |
||||
// By default, Meteor will default to using README.md for documentation.
|
||||
// To avoid submitting documentation, set this field to null.
|
||||
documentation: 'README.md' |
||||
}); |
||||
|
||||
Package.onUse(function(api) { |
||||
api.use([ |
||||
'mongo', |
||||
'ecmascript', |
||||
'templating', |
||||
'rocketchat:lib' |
||||
]); |
||||
|
||||
api.addFiles([ |
||||
'client/lib/startup.js', |
||||
'client/views/cleanHistory.html', |
||||
'client/views/cleanHistory.js', |
||||
'client/views/stylesheets/cleanHistory.css' |
||||
], 'client'); |
||||
}); |
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
Loading…
Reference in new issue