[NEW] Channel avatar (#18443)

pull/17649/head
Guilherme Gazzo 5 years ago committed by GitHub
parent 87966634ba
commit deecd73d5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      app/analytics/client/trackEvents.js
  2. 2
      app/api/server/lib/rooms.js
  3. 8
      app/api/server/v1/users.js
  4. 1
      app/authorization/server/startup.js
  5. 12
      app/channel-settings/client/startup/messageTypes.js
  6. 354
      app/channel-settings/client/views/channelSettings.html
  7. 659
      app/channel-settings/client/views/channelSettings.js
  8. 6
      app/channel-settings/server/methods/saveRoomSettings.js
  9. 1
      app/file-upload/server/config/GridFS.js
  10. 53
      app/file-upload/server/lib/FileUpload.js
  11. 19
      app/lib/lib/roomTypes/private.js
  12. 17
      app/lib/lib/roomTypes/public.js
  13. 1
      app/lib/server/functions/deleteRoom.js
  14. 38
      app/lib/server/functions/setRoomAvatar.js
  15. 7
      app/models/server/models/Avatars.js
  16. 25
      app/models/server/models/Rooms.js
  17. 2
      app/theme/client/imports/components/contextual-bar.css
  18. 11
      app/ui-account/client/avatar/avatar.js
  19. 2
      app/ui-utils/client/index.js
  20. 17
      app/ui-utils/client/lib/avatar.js
  21. 4
      app/utils/lib/getRoomAvatarURL.js
  22. 2
      client/admin/apps/AppInstallPage.js
  23. 2
      client/admin/customEmoji/AddCustomEmoji.js
  24. 2
      client/admin/customEmoji/EditCustomEmoji.js
  25. 2
      client/admin/customSounds/AddCustomSound.js
  26. 2
      client/admin/customSounds/EditCustomSound.js
  27. 284
      client/admin/rooms/EditRoom.js
  28. 424
      client/channel/ChannelInfo/EditChannel.js
  29. 27
      client/components/DeleteChannelWarning.js
  30. 2
      client/components/basic/avatar/BaseAvatar.js
  31. 7
      client/components/basic/avatar/RoomAvatar.js
  32. 51
      client/components/basic/avatar/RoomAvatarEditor.js
  33. 2
      client/components/basic/avatar/UserAvatarEditor.js
  34. 21
      client/hooks/useFileInput.js
  35. 2
      client/notifications/updateAvatar.js
  36. 1
      packages/rocketchat-i18n/i18n/en.i18n.json
  37. 1
      server/publications/room/index.js
  38. 44
      server/routes/avatar/room.js
  39. 5
      server/startup/initialData.js
  40. 1
      server/startup/migrations/index.js
  41. 10
      server/startup/migrations/v203.js

@ -148,4 +148,10 @@ if (!window._paq || window.ga) {
trackEvent('User', 'Avatar Changed', service);
}
}, callbacks.priority.MEDIUM, 'analytics-user-avatar-set');
callbacks.add('roomAvatarChanged', (room) => {
if (settings.get('Analytics_features_rooms')) {
trackEvent('Room', 'Changed Avatar', `${ room.name } (${ room._id })`);
}
}, callbacks.priority.MEDIUM, 'analytics-room-avatar-changed');
}

@ -79,6 +79,8 @@ export async function findAdminRoom({ uid, rid }) {
msgs: 1,
archived: 1,
tokenpass: 1,
announcement: 1,
description: 1,
};
return Rooms.findOneById(rid, { fields });

@ -342,8 +342,12 @@ API.v1.addRoute('users.setAvatar', { authRequired: true }, {
return callback(new Meteor.Error('error-not-allowed', 'Not allowed'));
}
}
setUserAvatar(user, Buffer.concat(imageData), mimetype, 'rest');
callback();
try {
setUserAvatar(user, Buffer.concat(imageData), mimetype, 'rest');
callback();
} catch (e) {
callback(e);
}
}));
}));
busboy.on('field', (fieldname, val) => {

@ -43,6 +43,7 @@ Meteor.startup(function() {
{ _id: 'edit-other-user-avatar', roles: ['admin'] },
{ _id: 'edit-privileged-setting', roles: ['admin'] },
{ _id: 'edit-room', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-room-avatar', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-room-retention-policy', roles: ['admin'] },
{ _id: 'force-delete-message', roles: ['admin', 'owner'] },
{ _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] },

@ -29,6 +29,18 @@ Meteor.startup(function() {
},
});
MessageTypes.registerType({
id: 'room_changed_avatar',
system: true,
message: 'room_changed_avatar',
data(message) {
return {
user_by: message.u && message.u.username,
};
},
});
MessageTypes.registerType({
id: 'room_changed_announcement',
system: true,

@ -1,360 +1,10 @@
<template name="channelSettings">
{{#if editing}}
{{> channelSettingsEditing}}
{{> channelSettingsEditing rid=rid }}
{{else}}
{{> channelSettingsInfo}}
{{/if}}
</template>
<template name="channelSettingsEditing">
<div class="rc-user-info__scroll">
{{#with settings.name}}
{{#if canView}}
<div class="rc-user-info__row">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<input type="text" name="name" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}"/>
</div>
</label>
</div>
</div>
{{/if}}
{{/with}}
{{#with settings.topic}}
<div class="rc-user-info__row">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<textarea name="topic" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}" id="" cols="30" rows="2"></textarea>
</div>
</label>
</div>
</div>
{{/with}}
{{#with settings.announcement}}
<div class="rc-user-info__row">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<input type="text" name="announcement" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}"/>
</div>
</label>
</div>
</div>
{{/with}}
{{#unless isDirectMessage}}
{{#with settings.description}}
<div class="rc-user-info__row">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<textarea name="description" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}" id="" cols="30" rows="2"></textarea>
</div>
</label>
</div>
</div>
{{/with}}
{{/unless}}
{{#with settings.t}}
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-switch-double">
<div class="rc-switch-double__label {{equal false value 'disabled'}}">
{{_ "Public"}}{{equal default value '*'}}
<div class="rc-switch-double__description">
{{_ "Everyone_can_access_this_channel"}}
</div>
</div>
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<input type="checkbox" class="rc-switch__input js-input-check" name="t" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
<div class="rc-switch-double__label {{equal true value 'disabled'}}">
{{_ "Private"}}
<div class="rc-switch-double__description">
{{_ "Just_invited_people_can_access_this_channel"}}
</div>
</div>
</div>
</div>
{{/with}}
{{#with settings.ro}}
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-switch-double">
<div class="rc-switch-double__label {{equal false value 'disabled'}}">
{{_ "Collaborative"}}{{equal default value '*'}}
<div class="rc-switch-double__description">
{{_ "All_users_in_the_channel_can_write_new_messages"}}
</div>
</div>
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<input type="checkbox" class="rc-switch__input js-input-check" name="ro" checked="{{checked}}" disabled="{{disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
<div class="rc-switch-double__label {{equal true value 'disabled'}}">
{{_ "Read_only"}}
<div class="rc-switch-double__description">
{{_ "Only_authorized_users_can_write_new_messages"}}
</div>
</div>
</div>
</div>
{{/with}}
{{#with settings.reactWhenReadOnly}}
{{#if canView}}
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-switch-double">
<div class="rc-switch-double__label {{equal false value 'disabled'}}">
{{_ "Disallow_reacting"}}
<div class="rc-switch-double__description">
{{_ "Disallow_reacting_Description"}}
</div>
</div>
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<input type="checkbox" class="rc-switch__input js-input-check" name="reactWhenReadOnly" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
<div class="rc-switch-double__label {{equal true value 'disabled'}}">
{{_ "React_when_read_only"}}
<div class="rc-switch-double__description">
{{_ "React_when_read_only"}}
</div>
</div>
</div>
</div>
{{/if}}
{{/with}}
{{#with settings.sysMes}}
{{#if canView}}
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-toggle" name="sysMes" checked="{{c}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
<span class="rc-switch__description">{{# unless c}} {{_ "Use_Server_configuration" }} {{else}} {{_ "Use_Room_configuration"}} {{/unless}}</span>
</div>
</div>
{{# if c}}
<div class="rc-user-info__row">
{{> Multiselect values=values onChangeValue=onChangeValue value=get }}
</div>
{{/if}}
</div>
{{/if}}
{{/with}}
{{#with settings.archived}}
{{#if canView}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="archived" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/if}}
{{/with}}
{{#with settings.encrypted}}
{{#if canView}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="encrypted" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/if}}
{{/with}}
{{#with settings.broadcast}}
{{#if canView}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="archived" checked="{{checked}}" disabled>
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/if}}
{{/with}}
{{#with settings.joinCode}}
{{#if canView}}
<div class="rc-user-info__row rc-user-info__row--separator">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{_ label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<input type="text" name="joinCode" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}"/>
</div>
</label>
</div>
</div>
{{/if}}
{{/with}}
{{#if hasRetentionPermission}}
<div class="rc-user-info__config">
<div class="rc-user-info__config-header">
{{> icon block="rc-user-info__config-icon" icon="trash"}}
<span class="rc-user-info__config-label">{{_ "Prune"}}</span>
</div>
{{#with settings.retentionEnabled}}
<div class="rc-user-info__config-content">
<div class="rc-user-info__config-name">{{_ label}}:</div>
<div class="rc-user-info__config-value">
{{subValue value}} {{> icon block="rc-user-info__config-content-icon" icon="arrow-down"}}
</div>
</div>
{{/with}}
</div>
{{# if settings.retentionEnabled.value.get }}
{{#with settings.retentionOverrideGlobal}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionOverrideGlobal" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{/if}}
{{# if settings.retentionOverrideGlobal.value.get }}
<div class="mail-messages__instructions mail-messages__instructions--warning" style="margin-bottom: 0;">
<div class="mail-messages__instructions-wrapper">
<div class="mail-messages__instructions-text">
<span>
{{{_ "RetentionPolicyRoom_ReadTheDocs"}}}
</span>
</div>
</div>
</div>
{{#with settings.retentionMaxAge}}
<div class="rc-user-info__row">
<div class="rc-input">
<label class="rc-input__label">
<div class="rc-input__title">{{retentionMaxAgeLabel label}}{{equal default value '*'}}</div>
<div class="rc-input__wrapper">
<input type="number" name="retentionMaxAge" value="{{value}}" class="rc-input__element js-input" disabled="{{./disabled}}"/>
</div>
</label>
</div>
</div>
{{/with}}
{{#with settings.retentionExcludePinned}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionExcludePinned" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{#with settings.retentionFilesOnly}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionFilesOnly" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{#with settings.retentionKeepThreads}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionKeepThreads" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{/if}}
{{/if}}
</div>
<div class="rc-user-info__row">
<div class="rc-user-info__flex rc-user-info__row rc-user-info__row--separator">
<button class="rc-button js-cancel rc-button--outline" title="{{_ 'Cancel'}}">{{_ 'Cancel'}}</button>
<button class="rc-button rc-button--secondary js-reset" {{modified 'disabled'}} title="{{_ 'Reset'}}">{{_ 'Reset'}}</button>
<button class="rc-button rc-button--primary js-save" {{modified 'disabled'}} title="{{_ 'Save'}}">{{_ 'Save'}}</button>
</div>
<div class="rc-user-info__flex">
{{#if canDeleteRoom}}
<button class="rc-button rc-button--outline rc-button--cancel js-delete" title="{{_ 'Delete'}}">{{> icon icon='trash'}}{{_ 'Delete'}}</button>
{{/if}}
</div>
</div>
</template>
<template name="channelSettingsInfo">
<section class="rc-user-info__scroll{{#if archived}} archived{{/if}}">
{{# with settings=settings}}
@ -368,7 +18,7 @@
</div>
{{/if}}
{{> avatar username=channelName}}
{{> avatar rid=rid}}
</div>
{{/if}}
<h3 title="{{name}}" class="rc-user-info__name">{{> icon block="rc-header__icon" icon=channelIcon}}{{ unscape name}}</h3>

@ -1,20 +1,17 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import toastr from 'toastr';
import moment from 'moment';
import s from 'underscore.string';
import { modal, popover, call, erase, hide, leave } from '../../../ui-utils';
import { erase, hide, leave } from '../../../ui-utils';
import { ChatRoom } from '../../../models';
import { settings } from '../../../settings';
import { callbacks } from '../../../callbacks';
import { hasPermission, hasAllPermission, hasRole, hasAtLeastOnePermission } from '../../../authorization';
import { t, roomTypes, RoomSettingsEnum } from '../../../utils';
import { hasPermission, hasAllPermission } from '../../../authorization';
import { roomTypes } from '../../../utils';
import { ChannelSettings } from '../lib/ChannelSettings';
import { MessageTypesValues } from '../../../lib/lib/MessageTypes';
import { createTemplateForComponent } from '../../../../client/reactAdapters';
createTemplateForComponent('channelSettingsEditing', () => import('../../../../client/channel/ChannelInfo/EditChannel'));
const common = {
canLeaveRoom() {
@ -93,18 +90,6 @@ function roomHasPurge(room) {
return roomHasGlobalPurge(room);
}
function retentionEnabled({ t: type }) {
switch (type) {
case 'c':
return settings.get('RetentionPolicy_AppliesToChannels');
case 'p':
return settings.get('RetentionPolicy_AppliesToGroups');
case 'd':
return settings.get('RetentionPolicy_AppliesToDMs');
}
return false;
}
function roomMaxAgeDefault(type) {
switch (type) {
case 'c':
@ -130,635 +115,10 @@ function roomMaxAge(room) {
return roomMaxAgeDefault(room.t);
}
const fixRoomName = (old) => {
if (settings.get('UI_Allow_room_names_with_special_chars')) {
return old;
}
const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`);
return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join('');
};
Template.channelSettingsEditing.events({
'input [name="name"]'(e) {
const input = e.currentTarget;
const modified = fixRoomName(input.value);
input.value = modified;
},
'input .js-input'(e) {
this.value.set(e.currentTarget.value);
},
'change .js-input-check'(e) {
this.value.set(e.currentTarget.checked);
},
'change .js-input-toggle'(e) {
this.toogle.set(e.currentTarget.checked);
if (!e.currentTarget.checked) {
this.value.set(null);
}
},
'click .js-reset'(e, t) {
const { settings } = t;
Object.keys(settings).forEach((key) => settings[key].value.set(settings[key].default.get()));
},
'click .rc-user-info__config-value'(e) {
const options = [{
id: 'prune_default',
name: 'prune_value',
label: 'Default',
value: 'default',
},
{
id: 'prune_enabled',
name: 'prune_value',
label: 'Enabled',
value: 'enabled',
},
{
id: 'prune_disabled',
name: 'prune_value',
label: 'Disabled',
value: 'disabled',
}];
const falseOrDisabled = this.value.get() === false ? 'disabled' : 'default';
const value = this.value.get() ? 'enabled' : falseOrDisabled;
const config = {
popoverClass: 'notifications-preferences',
template: 'pushNotificationsPopover',
data: {
change: (value) => {
const falseOrUndefined = value === 'disabled' ? false : undefined;
const realValue = value === 'enabled' ? true : falseOrUndefined;
return this.value.set(realValue);
},
value,
options,
},
currentTarget: e.currentTarget,
offsetVertical: e.currentTarget.clientHeight + 10,
};
popover.open(config);
},
async 'click .js-save'(e, t) {
const { settings } = t;
Object.keys(settings).forEach(async (name) => {
const setting = settings[name];
const value = setting.value.get();
if (setting.default.get() !== value) {
await setting.save(value).then(() => {
setting.default.set(value);
setting.value.set(value);
}, console.log);
}
});
},
});
Template.channelSettingsEditing.onCreated(function() {
const room = ChatRoom.findOne(this.data && this.data.rid);
this.room = room;
this.settings = {
name: {
type: 'text',
label: 'Name',
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.NAME);
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
getValue() {
if (room.prid) {
return room.fname;
}
if (settings.get('UI_Allow_room_names_with_special_chars')) {
return room.fname || room.name;
}
return room.name;
},
save(value) {
let nameValidation;
if (!settings.get('UI_Allow_room_names_with_special_chars')) {
try {
nameValidation = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`);
} catch (error1) {
nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$');
}
if (!nameValidation.test(value)) {
return Promise.reject(toastr.error(t('error-invalid-room-name', {
room_name: {
name: value,
},
})));
}
}
return call('saveRoomSettings', room._id, RoomSettingsEnum.NAME, value).then(function() {
callbacks.run('roomNameChanged', {
_id: room._id,
name: value,
});
return toastr.success(t('Room_name_changed_successfully'));
});
},
},
topic: {
type: 'markdown',
label: 'Topic',
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.TOPIC);
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.TOPIC, value).then(function() {
toastr.success(t('Room_topic_changed_successfully'));
return callbacks.run('roomTopicChanged', room);
});
},
},
announcement: {
type: 'markdown',
label: 'Announcement',
getValue() {
return Template.instance().room.announcement;
},
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.ANNOUNCEMENT);
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.ANNOUNCEMENT, value).then(() => {
toastr.success(t('Room_announcement_changed_successfully'));
return callbacks.run('roomAnnouncementChanged', room);
});
},
},
description: {
type: 'text',
label: 'Description',
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.DESCRIPTION);
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.DESCRIPTION, value).then(function() {
return toastr.success(t('Room_description_changed_successfully'));
});
},
},
t: {
type: 'boolean',
// label() {
// return ;
// },
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return room.t === 'p';
},
disabled() {
return room.default && !hasRole(Meteor.userId(), 'admin');
},
message() {
if (hasAllPermission('edit-room', room._id) && room.default) {
if (!hasRole(Meteor.userId(), 'admin')) {
return 'Room_type_of_default_rooms_cant_be_changed';
}
}
},
canView() {
if (!['c', 'p'].includes(room.t)) {
return false;
} if (room.t === 'p' && !hasAllPermission('create-c')) {
return false;
} if (room.t === 'c' && !hasAllPermission('create-p')) {
return false;
}
return true;
},
canEdit() {
return (hasAllPermission('edit-room', room._id) && !room.default) || hasRole(Meteor.userId(), 'admin');
},
save(value) {
const saveRoomSettings = () => {
value = value ? 'p' : 'c';
callbacks.run('roomTypeChanged', room);
return call('saveRoomSettings', room._id, 'roomType', value).then(() => toastr.success(t('Room_type_changed_successfully')));
};
if (room.default) {
if (hasRole(Meteor.userId(), 'admin')) {
return new Promise((resolve, reject) => {
modal.open({
title: t('Room_default_change_to_private_will_be_default_no_more'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#DD6B55',
confirmButtonText: t('Yes'),
cancelButtonText: t('Cancel'),
closeOnConfirm: true,
html: false,
}, function(confirmed) {
if (confirmed) {
return resolve(saveRoomSettings());
}
return reject();
});
});
}
// return $('.channel-settings form [name=\'t\']').prop('checked', !!room.type === 'p');
}
return saveRoomSettings();
},
},
ro: {
type: 'boolean',
label: 'Read_only',
isToggle: true,
processing: new ReactiveVar(false),
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.READ_ONLY);
},
canEdit() {
return !room.broadcast && hasAllPermission('set-readonly', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, RoomSettingsEnum.READ_ONLY, value).then(() => toastr.success(t('Read_only_changed_successfully')));
},
},
reactWhenReadOnly: {
type: 'boolean',
label: 'React_when_read_only',
isToggle: true,
processing: new ReactiveVar(false),
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY);
},
canEdit() {
return !room.broadcast && hasAllPermission('set-react-when-readonly', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'reactWhenReadOnly', value).then(() => {
toastr.success(t('React_when_read_only_changed_successfully'));
});
},
},
sysMes: {
type: 'boolean',
label: 'Hide_System_Messages',
isToggle: true,
processing: new ReactiveVar(false),
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(
room,
RoomSettingsEnum.SYSTEM_MESSAGES,
);
},
onChangeValue() {
return function(value) { this.value.set(value || []); }.bind(this);
},
getValue(room) {
return room.sysMes;
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
get() {
return this.value.get() || [];
},
save() {
const value = this.toogle.get() ? this.value.get() : null;
return call('saveRoomSettings', room._id, 'systemMessages', value).then(
() => {
toastr.success(
t('System_messages_setting_changed_successfully'),
);
},
);
},
c() {
return this.toogle.get();
},
toogle: new ReactiveVar(room.sysMes && room.sysMes.length > 0),
values() {
return MessageTypesValues;
},
},
archived: {
type: 'boolean',
label: 'Room_archivation_state_true',
isToggle: true,
processing: new ReactiveVar(false),
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE);
},
canEdit() {
return hasAtLeastOnePermission(['archive-room', 'unarchive-room'], room._id);
},
save(value) {
return new Promise((resolve, reject) => {
modal.open({
title: t('Are_you_sure'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#DD6B55',
confirmButtonText: value ? t('Yes_archive_it') : t('Yes_unarchive_it'),
cancelButtonText: t('Cancel'),
closeOnConfirm: false,
html: false,
}, function(confirmed) {
if (confirmed) {
const action = value ? 'archiveRoom' : 'unarchiveRoom';
return resolve(call(action, room._id).then(() => {
modal.open({
title: value ? t('Room_archived') : t('Room_unarchived'),
text: value ? t('Room_has_been_archived') : t('Room_has_been_unarchived'),
type: 'success',
timer: 2000,
showConfirmButton: false,
});
return callbacks.run(action, room);
}));
}
return reject();
});
});
},
},
broadcast: {
type: 'boolean',
label: 'Broadcast_channel',
isToggle: true,
processing: new ReactiveVar(false),
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.BROADCAST);
},
canEdit() {
return false;
},
save() {
return Promise.resolve();
},
},
joinCode: {
type: 'text',
label: 'Password',
showingValue: new ReactiveVar(false),
realValue: null,
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.JOIN_CODE) && hasAllPermission('edit-room', room._id);
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
getValue() {
if (this.showingValue.get()) {
return this.realValue;
}
return room.joinCodeRequired ? '*****' : '';
},
showHideValue() {
return room.joinCodeRequired;
},
cancelEditing() {
this.showingValue.set(false);
this.realValue = null;
},
async showValue(_room, forceShow = false) {
if (this.showingValue.get()) {
if (forceShow) {
return;
}
this.showingValue.set(false);
this.realValue = null;
return null;
}
return call('getRoomJoinCode', room._id).then((result) => {
this.realValue = result;
this.showingValue.set(true);
});
},
save(value) {
return call('saveRoomSettings', room._id, 'joinCode', value).then(function() {
toastr.success(t('Room_password_changed_successfully'));
return callbacks.run('roomCodeChanged', room);
});
},
},
retentionEnabled: {
type: 'boolean',
label: 'RetentionPolicyRoom_Enabled',
processing: new ReactiveVar(false),
getValue() {
const { room = {} } = Template.instance() || {};
return room.retention && room.retention.enabled;
},
canView() {
return true;
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionEnabled', value).then(() => toastr.success(t('Retention_setting_changed_successfully')));
},
},
retentionOverrideGlobal: {
type: 'boolean',
label: 'RetentionPolicyRoom_OverrideGlobal',
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return Template.instance().room.retention && Template.instance().room.retention.overrideGlobal;
},
canView() {
return true;
},
canEdit() {
return hasAllPermission('edit-privileged-setting', room._id);
},
disabled() {
return !hasAllPermission('edit-privileged-setting', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionOverrideGlobal', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully'),
);
},
);
},
},
retentionMaxAge: {
type: 'number',
label: 'RetentionPolicyRoom_MaxAge',
processing: new ReactiveVar(false),
getValue() {
const { room } = Template.instance();
return Math.min(roomMaxAge(room), roomMaxAgeDefault(room.t));
},
canView() {
return true;
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionMaxAge', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully'),
);
},
);
},
},
retentionExcludePinned: {
type: 'boolean',
label: 'RetentionPolicyRoom_ExcludePinned',
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return Template.instance().room.retention && Template.instance().room.retention.excludePinned;
},
canView() {
return true;
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionExcludePinned', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully'),
);
},
);
},
},
retentionFilesOnly: {
type: 'boolean',
label: 'RetentionPolicyRoom_FilesOnly',
isToggle: true,
processing: new ReactiveVar(false),
getValue() {
return Template.instance().room.retention && Template.instance().room.retention.filesOnly;
},
canView() {
return true;
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'retentionFilesOnly', value).then(
() => {
toastr.success(
t('Retention_setting_changed_successfully'),
);
},
);
},
},
encrypted: {
type: 'boolean',
label: 'Encrypted',
isToggle: true,
processing: new ReactiveVar(false),
canView() {
return roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E);
},
canEdit() {
return hasAllPermission('edit-room', room._id);
},
save(value) {
return call('saveRoomSettings', room._id, 'encrypted', value).then(() => {
toastr.success(
t('Encrypted_setting_changed_successfully'),
);
});
},
},
};
Object.keys(this.settings).forEach((key) => {
const setting = this.settings[key];
const def = setting.getValue ? setting.getValue(this.room) : this.room[key] || false;
setting.default = new ReactiveVar(def);
setting.value = new ReactiveVar(def);
});
});
Template.channelSettingsEditing.helpers({
...common,
value() {
return this.value.get();
},
default() {
return this.default.get();
},
disabled() {
return !this.canEdit();
},
checked() {
return this.value.get();// ? '' : 'checked';
},
modified(text = '') {
const { settings } = Template.instance();
return !Object.keys(settings).some((key) => settings[key].default.get() !== settings[key].value.get()) ? text : '';
},
equal(text = '', text2 = '', ret = '*') {
return text === text2 ? '' : ret;
},
settings() {
return Template.instance().settings;
},
editing(field) {
return Template.instance().editing.get() === field;
},
isDisabled(field, room) {
const setting = Template.instance().settings[field];
return (typeof setting.disabled === 'function' && setting.disabled(room)) || setting.processing.get() || !hasAllPermission('edit-room', room._id);
},
unscape(value) {
return s.unescapeHTML(value);
},
hasRetentionPermission() {
const { room } = Template.instance();
return settings.get('RetentionPolicy_Enabled') && hasAllPermission('edit-room-retention-policy', room._id);
},
subValue(value) {
if (value === undefined) {
const text = t(retentionEnabled(Template.instance().room) ? 'enabled' : 'disabled');
const _default = t('default');
return `${ text } (${ _default })`;
}
return t(value ? 'enabled' : 'disabled');
},
retentionEnabled(value) {
const { room } = Template.instance();
return (value || value === undefined) && retentionEnabled(room);
},
retentionMaxAgeLabel(label) {
const { room } = Template.instance();
return TAPi18n.__(label, { max: roomMaxAgeDefault(room.t) });
},
});
Template.channelSettings.helpers({
rid() {
return Template.currentData().rid;
},
editing() {
return Template.instance().editing.get();
},
@ -797,6 +157,9 @@ Template.channelSettingsInfo.onCreated(function() {
Template.channelSettingsInfo.helpers({
...common,
rid() {
return Template.instance().room._id;
},
channelName() {
return `@${ Template.instance().room.name }`;
},

@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { setRoomAvatar } from '../../../lib/server/functions/setRoomAvatar';
import { hasPermission } from '../../../authorization';
import { Rooms } from '../../../models';
import { callbacks } from '../../../callbacks';
@ -17,7 +18,7 @@ import { saveRoomTokenpass } from '../functions/saveRoomTokens';
import { saveStreamingOptions } from '../functions/saveStreamingOptions';
import { RoomSettingsEnum, roomTypes } from '../../../utils';
const fields = ['featured', 'roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionIgnoreThreads', 'retentionOverrideGlobal', 'encrypted', 'favorite'];
const fields = ['roomAvatar', 'featured', 'roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionIgnoreThreads', 'retentionOverrideGlobal', 'encrypted', 'favorite'];
Meteor.methods({
saveRoomSettings(rid, settings, value) {
const userId = Meteor.userId();
@ -234,6 +235,9 @@ Meteor.methods({
case 'favorite':
Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue);
break;
case 'roomAvatar':
setRoomAvatar(rid, value, user);
break;
}
});

@ -151,7 +151,6 @@ FileUpload.configureUploadsStore('GridFS', 'GridFS:Avatars', {
collectionName: 'rocketchat_avatars',
});
new FileUploadClass({
name: 'GridFS:Uploads',

@ -96,6 +96,31 @@ export const FileUpload = {
return true;
},
validateAvatarUpload(file) {
if (!Match.test(file.rid, String) && !Match.test(file.userId, String)) {
return false;
}
const user = file.uid ? Meteor.users.findOne(file.uid, { fields: { language: 1 } }) : null;
const language = user?.language || 'en';
// accept only images
if (!/^image\//.test(file.type)) {
const reason = TAPi18n.__('File_type_is_not_accepted', language);
throw new Meteor.Error('error-invalid-file-type', reason);
}
// -1 maxFileSize means there is no limit
if (maxFileSize > -1 && file.size > maxFileSize) {
const reason = TAPi18n.__('File_exceeds_allowed_size_of_bytes', {
size: filesize(maxFileSize),
}, language);
throw new Meteor.Error('error-file-too-large', reason);
}
return true;
},
defaultUploads() {
return {
collection: Uploads.model,
@ -121,9 +146,9 @@ export const FileUpload = {
defaultAvatars() {
return {
collection: Avatars.model,
// filter: new UploadFS.Filter({
// onCheck: FileUpload.validateFileUpload
// }),
filter: new UploadFS.Filter({
onCheck: FileUpload.validateAvatarUpload,
}),
getPath(file) {
return `${ settings.get('uniqueID') }/avatars/${ file.userId }`;
},
@ -275,7 +300,16 @@ export const FileUpload = {
return fut.wait();
},
avatarRoomOnFinishUpload(file) {
if (!hasPermission(Meteor.userId(), 'edit-room-avatar', file.rid)) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
},
avatarsOnFinishUpload(file) {
if (file.rid) {
return FileUpload.avatarRoomOnFinishUpload(file);
}
if (Meteor.userId() !== file.userId && !hasPermission(Meteor.userId(), 'edit-other-user-info')) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
@ -499,6 +533,19 @@ export class FileUploadClass {
return store.delete(file._id);
}
deleteByRoomId(rid) {
const file = this.model.findOneByRoomId(rid);
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
_doInsert(fileData, streamOrBuffer, cb) {
const fileId = this.store.create(fileData);
const token = this.store.createToken(fileId);

@ -4,8 +4,7 @@ import { ChatRoom, ChatSubscription } from '../../../models';
import { openRoom } from '../../../ui-utils';
import { settings } from '../../../settings';
import { hasAtLeastOnePermission, hasPermission } from '../../../authorization';
import { getUserPreference, RoomSettingsEnum, RoomTypeConfig, RoomTypeRouteConfig, UiTextContext, roomTypes, RoomMemberActions } from '../../../utils';
import { getRoomAvatarURL } from '../../../utils/lib/getRoomAvatarURL';
import { getUserPreference, RoomSettingsEnum, RoomTypeConfig, RoomTypeRouteConfig, UiTextContext, RoomMemberActions } from '../../../utils';
import { getAvatarURL } from '../../../utils/lib/getAvatarURL';
@ -123,21 +122,7 @@ export class PrivateRoomType extends RoomTypeConfig {
}
getAvatarPath(roomData) {
// TODO: change to always get avatar from _id when rooms have avatars
// if room is not a discussion, returns the avatar for its name
if (!roomData.prid) {
return getAvatarURL({ username: `@${ this.roomName(roomData) }` });
}
// if discussion's parent room is known, get his avatar
const proom = ChatRoom.findOne({ _id: roomData.prid }, { reactive: false });
if (proom) {
return roomTypes.getConfig(proom.t).getAvatarPath(proom);
}
// otherwise gets discussion's avatar via _id
return getRoomAvatarURL(roomData.prid);
return getAvatarURL({ roomId: roomData._id, cache: roomData.avatarETag });
}
includeInDashboard() {

@ -4,7 +4,7 @@ import { openRoom } from '../../../ui-utils';
import { ChatRoom, ChatSubscription } from '../../../models';
import { settings } from '../../../settings';
import { hasAtLeastOnePermission } from '../../../authorization';
import { getUserPreference, RoomTypeConfig, RoomTypeRouteConfig, RoomSettingsEnum, UiTextContext, RoomMemberActions, roomTypes } from '../../../utils';
import { getUserPreference, RoomTypeConfig, RoomTypeRouteConfig, RoomSettingsEnum, UiTextContext, RoomMemberActions } from '../../../utils';
import { getAvatarURL } from '../../../utils/lib/getAvatarURL';
export class PublicRoomRoute extends RoomTypeRouteConfig {
@ -134,20 +134,7 @@ export class PublicRoomType extends RoomTypeConfig {
}
getAvatarPath(roomData) {
// TODO: change to always get avatar from _id when rooms have avatars
// if room is not a discussion, returns the avatar for its name
if (!roomData.prid) {
return getAvatarURL({ username: `@${ this.roomName(roomData) }` });
}
// if discussion's parent room is known, get his avatar
const proom = ChatRoom.findOne({ _id: roomData.prid }, { reactive: false });
if (proom) {
return roomTypes.getConfig(proom.t).getAvatarPath(proom);
}
return getAvatarURL({ username: `@${ this.roomName(roomData) }` });
return getAvatarURL({ roomId: roomData._id, cache: roomData.avatarETag });
}
getDiscussionType() {

@ -7,6 +7,7 @@ export const deleteRoom = function(rid) {
Messages.removeByRoomId(rid);
callbacks.run('beforeDeleteRoom', rid);
Subscriptions.removeByRoomId(rid);
FileUpload.getStore('Avatars').deleteByRoomId(rid);
callbacks.run('afterDeleteRoom', rid);
return Rooms.removeById(rid);
};

@ -0,0 +1,38 @@
import { Meteor } from 'meteor/meteor';
import { RocketChatFile } from '../../../file';
import { FileUpload } from '../../../file-upload';
import { Notifications } from '../../../notifications';
import { Rooms, Avatars } from '../../../models/server';
export const setRoomAvatar = function(rid, dataURI, user) {
const fileStore = FileUpload.getStore('Avatars');
const current = Avatars.findOneByRoomId(rid);
if (!dataURI) {
fileStore.deleteByRoomId(rid);
return Rooms.unsetAvatarData(rid);
}
const fileData = RocketChatFile.dataURIParse(dataURI);
const buffer = Buffer.from(fileData.image, 'base64');
const file = {
rid,
type: fileData.contentType,
size: buffer.length,
uid: user._id,
};
fileStore.insert(file, buffer, (err, result) => {
Meteor.setTimeout(function() {
if (current) {
fileStore.deleteById(current._id);
}
Rooms.setAvatarData(rid, 'upload', result.etag);
Notifications.notifyLogged('updateAvatar', { rid, etag: result.etag });
}, 500);
});
};

@ -12,7 +12,8 @@ export class Avatars extends Base {
doc.instanceId = InstanceStatus.id();
});
this.tryEnsureIndex({ name: 1 });
this.tryEnsureIndex({ name: 1 }, { sparse: true });
this.tryEnsureIndex({ rid: 1 }, { sparse: true });
}
insertAvatarFileInit(name, userId, store, file, extra) {
@ -63,6 +64,10 @@ export class Avatars extends Base {
return this.findOne({ name });
}
findOneByRoomId(rid) {
return this.findOne({ rid });
}
updateFileNameById(fileId, name) {
const filter = { _id: fileId };
const update = {

@ -220,6 +220,31 @@ export class Rooms extends Base {
return this.update(query, update);
}
setAvatarData(_id, origin, etag) {
const update = {
$set: {
avatarOrigin: origin,
avatarETag: etag,
},
};
return this.update({ _id }, update);
}
unsetAvatarData(_id) {
const update = {
$set: {
avatarETag: Date.now(),
},
$unset: {
avatarOrigin: 1,
},
};
return this.update({ _id }, update);
}
setSystemMessagesById = function(_id, systemMessages) {
const query = {
_id,

@ -1,4 +1,6 @@
.contextual-bar {
box-sizing: content-box;
&.contextual-bar {
z-index: 10;

@ -1,6 +1,8 @@
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { Rooms } from '../../../models/client';
import { getRoomAvatarURL } from '../../../utils/lib/getRoomAvatarURL';
import { getUserAvatarURL } from '../../../utils/lib/getUserAvatarURL';
const getUsername = ({ userId, username }) => {
@ -21,13 +23,20 @@ const getUsername = ({ userId, username }) => {
return user;
};
const getRoomETag = (rid) => Rooms.findOne({ _id: rid }, { fields: { avatarETag: 1 } });
Template.avatar.helpers({
src() {
const { url } = Template.instance().data;
const { url, rid } = Template.instance().data;
if (url) {
return url;
}
if (rid) {
const { avatarETag } = getRoomETag(rid) || {};
return getRoomAvatarURL(rid, avatarETag);
}
if (this.roomIcon && this.username) {
return getUserAvatarURL(`@${ this.username }`);
}

@ -15,7 +15,7 @@ export { renderMessageBody } from './lib/renderMessageBody';
export { Layout } from './lib/Layout';
export { IframeLogin, iframeLogin } from './lib/IframeLogin';
export { fireGlobalEvent } from './lib/fireGlobalEvent';
export { getAvatarAsPng, updateAvatarOfUsername } from './lib/avatar';
export { getAvatarAsPng } from './lib/avatar';
export { TabBar, TABBAR_DEFAULT_VISIBLE_ICON_COUNT } from './lib/TabBar';
export { RocketChatTabBar } from './lib/RocketChatTabBar';
export { popout } from './lib/popout';

@ -1,7 +1,5 @@
import { Blaze } from 'meteor/blaze';
import { Session } from 'meteor/session';
import { RoomManager } from './RoomManager';
import { getUserAvatarURL } from '../../../utils/lib/getUserAvatarURL';
Blaze.registerHelper('avatarUrlFromUsername', getUserAvatarURL);
@ -26,18 +24,3 @@ export const getAvatarAsPng = function(username, cb) {
};
return image.onerror;
};
export const updateAvatarOfUsername = function(username) {
Session.set(`avatar_random_${ username }`, Date.now());
const url = getUserAvatarURL(username);
// force reload of avatars of messages
$(Object.values(RoomManager.openedRooms).map((room) => room.dom))
.find(`.message[data-username='${ username }'] .avatar-image`).attr('src', url);
// force reload of avatar on sidenav
$(`.sidebar-item.js-sidebar-type-d .sidebar-item__link[aria-label='${ username }'] .avatar-image`)
.attr('src', url);
return true;
};

@ -1,7 +1,7 @@
import { getAvatarURL } from './getAvatarURL';
import { settings } from '../../settings';
export const getRoomAvatarURL = function(roomId) {
export const getRoomAvatarURL = function(roomId, etag) {
const externalSource = (settings.get('Accounts_AvatarExternalProviderUrl') || '').trim().replace(/\/$/, '');
if (externalSource !== '') {
return externalSource.replace('{roomId}', roomId);
@ -9,5 +9,5 @@ export const getRoomAvatarURL = function(roomId) {
if (!roomId) {
return;
}
return getAvatarURL({ roomId });
return getAvatarURL({ roomId, cache: etag });
};

@ -46,7 +46,7 @@ function AppInstallPage() {
queryUrl && handleUrl(queryUrl);
}, [queryUrl, handleUrl]);
const handleUploadButtonClick = useFileInput(handleFile, 'app');
const [handleUploadButtonClick] = useFileInput(handleFile, 'app');
const install = useCallback(async () => {
setInstalling(true);

@ -34,7 +34,7 @@ export function AddCustomEmoji({ close, onChange, ...props }) {
}
}, [emojiFile, name, aliases, saveAction, onChange, close]);
const clickUpload = useFileInput(setEmojiPreview, 'emoji');
const [clickUpload] = useFileInput(setEmojiPreview, 'emoji');
return <VerticalBar.ScrollableContent {...props}>
<Field>

@ -131,7 +131,7 @@ export function EditCustomEmoji({ close, onChange, data, ...props }) {
const handleAliasesChange = useCallback((e) => setAliases(e.currentTarget.value), [setAliases]);
const clickUpload = useFileInput(setEmojiPreview, 'emoji');
const [clickUpload] = useFileInput(setEmojiPreview, 'emoji');
return <>
<VerticalBar.ScrollableContent {...props}>

@ -23,7 +23,7 @@ export function AddCustomSound({ goToNew, close, onChange, ...props }) {
setSound(soundFile);
}, []);
const clickUpload = useFileInput(handleChangeFile, 'audio/mp3');
const [clickUpload] = useFileInput(handleChangeFile, 'audio/mp3');
const saveAction = useCallback(async (name, soundFile) => {
const soundData = createSoundData(soundFile, name);

@ -155,7 +155,7 @@ function EditSound({ close, onChange, data, ...props }) {
const openConfirmDelete = () => setModal(() => <DeleteWarningModal onDelete={onDeleteConfirm} onCancel={() => setModal(undefined)}/>);
const clickUpload = useFileInput(handleChangeFile, 'audio/mp3');
const [clickUpload] = useFileInput(handleChangeFile, 'audio/mp3');
return <>
<VerticalBar.ScrollableContent {...props}>

@ -1,14 +1,34 @@
import React, { useCallback, useState, useMemo } from 'react';
import { Box, Button, Margins, TextInput, Skeleton, Field, ToggleSwitch, Divider, Icon, Callout, RadioButton } from '@rocket.chat/fuselage';
import React, { useState, useMemo } from 'react';
import { Box, Skeleton, Button, ButtonGroup, TextInput, Field, ToggleSwitch, Icon, Callout, TextAreaInput } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import VerticalBar from '../../components/basic/VerticalBar';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
import RoomAvatarEditor from '../../components/basic/avatar/RoomAvatarEditor';
import DeleteChannelWarning from '../../components/DeleteChannelWarning';
import { useSetModal } from '../../contexts/ModalContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useForm } from '../../hooks/useForm';
import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../hooks/useEndpointDataExperimental';
import { roomTypes } from '../../../app/utils/client';
import { roomTypes, RoomSettingsEnum } from '../../../app/utils/client';
import { useMethod } from '../../contexts/ServerContext';
import { usePermission } from '../../contexts/AuthorizationContext';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
import { useEndpointAction } from '../../hooks/useEndpointAction';
import VerticalBar from '../../components/basic/VerticalBar';
import { useEndpointActionExperimental } from '../../hooks/useEndpointAction';
const getInitialValues = (room) => ({
roomName: room.t === 'd' ? room.usernames.join(' x ') : roomTypes.getRoomName(room.t, { type: room.t, ...room }),
roomType: room.t,
readOnly: !!room.ro,
archived: !!room.archived,
isDefault: !!room.default,
favorite: !!room.favorite,
featured: !!room.featured,
roomTopic: room.topic ?? '',
roomDescription: room.description ?? '',
roomAnnouncement: room.announcement ?? '',
roomAvatar: undefined,
});
export function EditRoomContextBar({ rid }) {
const canViewRoomAdministration = usePermission('view-room-administration');
@ -16,10 +36,7 @@ export function EditRoomContextBar({ rid }) {
}
function EditRoomWithData({ rid }) {
const [cache, setState] = useState();
// TODO: remove cache. Is necessary for data invalidation
const { data = {}, state, error } = useEndpointDataExperimental('rooms.adminRooms.getRoom', useMemo(() => ({ rid }), [rid, cache]));
const { data = {}, state, error, reload } = useEndpointDataExperimental('rooms.adminRooms.getRoom', useMemo(() => ({ rid }), [rid]));
if (state === ENDPOINT_STATES.LOADING) {
return <Box w='full' pb='x24'>
@ -36,146 +53,211 @@ function EditRoomWithData({ rid }) {
return error.message;
}
return <EditRoom room={data} onChange={() => setState(new Date())}/>;
return <EditRoom room={{ type: data.t, ...data }} onChange={reload}/>;
}
function EditRoom({ room, onChange }) {
const t = useTranslation();
const [deleted, setDeleted] = useState(false);
const [newData, setNewData] = useState({});
const [changeArchivation, setChangeArchivation] = useState(false);
const canDelete = usePermission(`delete-${ room.t }`);
const setModal = useSetModal();
const { values, handlers, hasUnsavedChanges, reset } = useForm(getInitialValues(room));
const [
canViewName,
canViewTopic,
canViewAnnouncement,
canViewArchived,
canViewDescription,
canViewType,
canViewReadOnly,
] = useMemo(() => {
const isAllowed = roomTypes.getConfig(room.t).allowRoomSettingChange;
return [
isAllowed(room, RoomSettingsEnum.NAME),
isAllowed(room, RoomSettingsEnum.TOPIC),
isAllowed(room, RoomSettingsEnum.ANNOUNCEMENT),
isAllowed(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE),
isAllowed(room, RoomSettingsEnum.DESCRIPTION),
isAllowed(room, RoomSettingsEnum.TYPE),
isAllowed(room, RoomSettingsEnum.READ_ONLY),
];
}, [room]);
const hasUnsavedChanges = useMemo(() => Object.values(newData).filter((current) => current === null).length < Object.keys(newData).length, [newData]);
const saveQuery = useMemo(() => ({ rid: room._id, ...Object.fromEntries(Object.entries(newData).filter(([, value]) => value !== null)) }), [room._id, newData]);
const {
roomName,
roomType,
readOnly,
archived,
isDefault,
favorite,
featured,
roomTopic,
roomAvatar,
roomDescription,
roomAnnouncement,
} = values;
const {
handleIsDefault,
handleFavorite,
handleFeatured,
handleRoomName,
handleRoomType,
handleReadOnly,
handleArchived,
handleRoomAvatar,
handleRoomTopic,
handleRoomDescription,
handleRoomAnnouncement,
} = handlers;
const changeArchivation = archived !== !!room.archived;
const canDelete = usePermission(`delete-${ room.t }`);
const archiveSelector = room.archived ? 'unarchive' : 'archive';
const archiveMessage = archiveSelector === 'archive' ? 'Room_has_been_archived' : 'Room_has_been_archived';
const archiveQuery = useMemo(() => ({ rid: room._id, action: room.archived ? 'unarchive' : 'archive' }), [room._id, room.archived]);
const archiveMessage = room.archived ? 'Room_has_been_unarchived' : 'Room_has_been_archived';
const saveAction = useEndpointActionExperimental('POST', 'rooms.saveRoomSettings', t('Room_updated_successfully'));
const archiveAction = useEndpointActionExperimental('POST', 'rooms.changeArchivationState', t(archiveMessage));
const saveAction = useEndpointAction('POST', 'rooms.saveRoomSettings', saveQuery, t('Room_updated_successfully'));
const archiveAction = useEndpointAction('POST', 'rooms.changeArchivationState', archiveQuery, t(archiveMessage));
const handleSave = useMutableCallback(async () => {
const save = () => saveAction({
rid: room._id,
roomName,
roomTopic,
roomType,
readOnly,
default: isDefault,
favorite: { defaultValue: isDefault, favorite },
featured,
roomDescription,
roomAnnouncement,
roomAvatar,
});
const updateType = (type) => () => (type === 'p' ? 'c' : 'p');
const areEqual = (a, b) => a === b || !(a || b);
const archive = () => archiveAction({ rid: room._id, action: archiveSelector });
const handleChange = (field, currentValue, getValue = (e) => e.currentTarget.value) => (e) => setNewData({ ...newData, [field]: areEqual(getValue(e), currentValue) ? null : getValue(e) });
const handleSave = async () => {
await Promise.all([hasUnsavedChanges && saveAction(), changeArchivation && archiveAction()].filter(Boolean));
onChange('update');
};
await Promise.all([hasUnsavedChanges && save(), changeArchivation && archive()].filter(Boolean));
onChange();
});
const changeRoomType = useMutableCallback(() => {
handleRoomType(roomType === 'p' ? 'c' : 'p');
});
const deleteRoom = useMethod('eraseRoom');
const handleDelete = useCallback(async () => {
await deleteRoom(room._id);
setDeleted(true);
}, [deleteRoom, room._id]);
const handleDelete = useMutableCallback(() => {
const onCancel = () => setModal(undefined);
const onConfirm = async () => {
await deleteRoom(room._id);
onCancel();
setDeleted(true);
};
const roomName = room.t === 'd' ? room.usernames.join(' x ') : roomTypes.getRoomName(room.t, { type: room.t, ...room });
const roomType = newData.roomType ?? room.t;
const readOnly = newData.readOnly ?? !!room.ro;
const isArchived = changeArchivation ? !room.archived : !!room.archived;
const isDefault = newData.default ?? !!room.default;
const isFavorite = newData.favorite ?? !!room.favorite;
const isFeatured = newData.featured ?? !!room.featured;
setModal(<DeleteChannelWarning onConfirm={onConfirm} onCancel={onCancel} />);
});
return <VerticalBar.ScrollableContent is='form' onSubmit={useCallback((e) => e.preventDefault(), [])}>
return <VerticalBar.ScrollableContent is='form' onSubmit={useMutableCallback((e) => e.preventDefault())}>
{deleted && <Callout type='danger' title={t('Room_has_been_deleted')}></Callout>}
{room.t !== 'd' && <RoomAvatarEditor roomAvatar={roomAvatar} room={room} onChangeAvatar={handleRoomAvatar}/>}
<Field>
<Field.Label>{t('Name')}</Field.Label>
<Field.Row>
<TextInput disabled={deleted || room.t === 'd'} value={newData.roomName ?? roomName} onChange={handleChange('roomName', roomName)} flexGrow={1}/>
<TextInput disabled={deleted || !canViewName} value={roomName} onChange={handleRoomName} flexGrow={1}/>
</Field.Row>
</Field>
{ room.t !== 'd' && <>
{room.t !== 'd' && <>
<Field>
<Field.Label>{t('Owner')}</Field.Label>
<Field.Row>
<Box fontScale='p1'>{room.u?.username}</Box>
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Topic')}</Field.Label>
{canViewDescription && <Field>
<Field.Label>{t('Description')}</Field.Label>
<Field.Row>
<TextInput disabled={deleted} value={(newData.roomTopic ?? room.topic) || ''} onChange={handleChange('roomTopic', room.topic)} flexGrow={1}/>
<TextAreaInput rows={4} disabled={deleted} value={roomDescription} onChange={handleRoomDescription} flexGrow={1}/>
</Field.Row>
</Field>
<Divider />
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Public')}</Field.Label>
<RadioButton disabled={deleted} checked={roomType !== 'p'} onChange={handleChange('roomType', room.t, updateType(roomType))}/>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Private')}</Field.Label>
<RadioButton disabled={deleted} checked={roomType === 'p'} onChange={handleChange('roomType', room.t, updateType(roomType))}/>
</Box>
</Field.Row>
</Field>
<Divider />
<Field>
</Field>}
{canViewAnnouncement && <Field>
<Field.Label>{t('Announcement')}</Field.Label>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Read_only')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={readOnly} onChange={handleChange('readOnly', room.ro, () => !readOnly)}/>
</Box>
<TextAreaInput rows={4} disabled={deleted} value={roomAnnouncement} onChange={handleRoomAnnouncement} flexGrow={1}/>
</Field.Row>
</Field>
<Field>
</Field>}
{canViewTopic && <Field>
<Field.Label>{t('Topic')}</Field.Label>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Archived')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={isArchived} onChange={() => setChangeArchivation(!changeArchivation)}/>
</Box>
<TextAreaInput rows={4} disabled={deleted} value={roomTopic} onChange={handleRoomTopic} flexGrow={1}/>
</Field.Row>
</Field>
<Field>
</Field>}
{canViewType && <Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Default')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={isDefault} onChange={handleChange('default', room.default, () => !isDefault)}/>
</Box>
<Field.Label>{t('Private')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={roomType === 'p'} onChange={changeRoomType}/>
</Field.Row>
</Field>
<Field>
<Field.Hint>{t('Just_invited_people_can_access_this_channel')}</Field.Hint>
</Field>}
{canViewReadOnly && <Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Favorite')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={isFavorite} onChange={handleChange('favorite', room.favorite, () => !isFavorite)}/>
<Field.Label>{t('Read_only')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={readOnly} onChange={handleReadOnly}/>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Hint>{t('Only_authorized_users_can_write_new_messages')}</Field.Hint>
</Field>}
{canViewArchived && <Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Featured')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={isFeatured} onChange={handleChange('featured', room.featured, () => !isFeatured)}/>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' w='full'>
<Margins inlineEnd='x4'>
<Button disabled={deleted} flexGrow={1} type='reset' disabled={!hasUnsavedChanges && !changeArchivation} onClick={() => setNewData({})}>{t('Reset')}</Button>
<Button disabled={deleted} mie='none' flexGrow={1} disabled={!hasUnsavedChanges && !changeArchivation} onClick={handleSave}>{t('Save')}</Button>
</Margins>
<Field.Label>{t('Archived')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={archived} onChange={handleArchived}/>
</Box>
</Field.Row>
</Field>
</Field>}
</>}
<Field>
<Field.Row>
<Button primary danger disabled={deleted || !canDelete} onClick={handleDelete} display='flex' alignItems='center' justifyContent='center' flexGrow={1}><Icon name='trash' size='x16' />{t('Delete')}</Button>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Default')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={isDefault} onChange={handleIsDefault}/>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Favorite')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={favorite} onChange={handleFavorite}/>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Featured')}</Field.Label>
<ToggleSwitch disabled={deleted} checked={featured} onChange={handleFeatured}/>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' w='full'>
<ButtonGroup stretch flexGrow={1}>
<Button type='reset' disabled={!hasUnsavedChanges || deleted} onClick={reset}>{t('Reset')}</Button>
<Button flexGrow={1} disabled={!hasUnsavedChanges || deleted} onClick={handleSave}>{t('Save')}</Button>
</ButtonGroup>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Button primary flexGrow={1} danger disabled={deleted || !canDelete} onClick={handleDelete} ><Icon name='trash' size='x16' />{t('Delete')}</Button>
</Field.Row>
</Field>
</VerticalBar.ScrollableContent>;

@ -0,0 +1,424 @@
import React, { useCallback, useMemo, useRef } from 'react';
import {
Field,
TextInput,
ToggleSwitch,
MultiSelect,
Accordion,
Callout,
NumberInput,
FieldGroup,
Button,
ButtonGroup,
Box,
Icon,
TextAreaInput,
} from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import VerticalBar from '../../components/basic/VerticalBar';
import RawText from '../../components/basic/RawText';
import RoomAvatarEditor from '../../components/basic/avatar/RoomAvatarEditor';
import DeleteChannelWarning from '../../components/DeleteChannelWarning';
import { useTranslation } from '../../contexts/TranslationContext';
import { useForm } from '../../hooks/useForm';
import { roomTypes, RoomSettingsEnum } from '../../../app/utils/client';
import { MessageTypesValues } from '../../../app/lib/lib/MessageTypes';
import { useMethod } from '../../contexts/ServerContext';
import { useSetModal } from '../../contexts/ModalContext';
import { useSetting } from '../../contexts/SettingsContext';
import { usePermission, useAtLeastOnePermission, useRole } from '../../contexts/AuthorizationContext';
import { useEndpointActionExperimental } from '../../hooks/useEndpointAction';
import { useUserRoom } from '../hooks/useUserRoom';
const typeMap = {
c: 'Channels',
p: 'Groups',
d: 'DMs',
};
const useInitialValues = (room, settings) => {
const {
t,
ro,
archived,
topic,
description,
announcement,
joinCodeRequired,
sysMes,
encrypted,
retention = {},
} = room;
const {
retentionPolicyEnabled,
maxAgeDefault,
} = settings;
const retentionEnabledDefault = useSetting(`RetentionPolicy_AppliesTo${ typeMap[room.t] }`);
const excludePinnedDefault = useSetting('RetentionPolicy_DoNotPrunePinned');
const filesOnlyDefault = useSetting('RetentionPolicy_FilesOnly');
return useMemo(() => ({
roomName: t === 'd' ? room.usernames.join(' x ') : roomTypes.getRoomName(t, { type: t, ...room }),
roomType: t,
readOnly: !!ro,
reactWhenReadOnly: false,
archived: !!archived,
roomTopic: topic ?? '',
roomDescription: description ?? '',
roomAnnouncement: announcement ?? '',
roomAvatar: undefined,
joinCode: '',
joinCodeRequired: !!joinCodeRequired,
systemMessages: Array.isArray(sysMes) ? sysMes : [],
hideSysMes: !!sysMes?.length,
encrypted,
...retentionPolicyEnabled && {
retentionEnabled: retention.enabled ?? retentionEnabledDefault,
retentionOverrideGlobal: !!retention.overrideGlobal,
retentionMaxAge: Math.min(retention.maxAge, maxAgeDefault) || maxAgeDefault,
retentionExcludePinned: retention.excludePinned ?? excludePinnedDefault,
retentionFilesOnly: retention.filesOnly ?? filesOnlyDefault,
},
}), [
announcement,
archived,
description,
excludePinnedDefault,
filesOnlyDefault,
joinCodeRequired,
maxAgeDefault,
retention.enabled,
retention.excludePinned,
retention.filesOnly,
retention.maxAge,
retention.overrideGlobal,
retentionEnabledDefault,
retentionPolicyEnabled,
ro,
room,
sysMes,
t,
topic,
encrypted,
]);
};
function EditChannelWithData({ rid }) {
const room = useUserRoom(rid);
return <EditChannel room={{ type: room?.t, ...room }}/>;
}
const getCanChangeType = (room, canCreateChannel, canCreateGroup, isAdmin) => (!room.default || isAdmin) && ((room.t === 'p' && canCreateChannel) || (room.t === 'c' && canCreateGroup));
function EditChannel({ room }) {
const t = useTranslation();
const setModal = useSetModal();
const retentionPolicyEnabled = useSetting('RetentionPolicy_Enabled');
const maxAgeDefault = useSetting(`RetentionPolicy_MaxAge_${ typeMap[room.t] }`) || 30;
const saveData = useRef({});
const onChange = useCallback(({ initialValue, value, key }) => {
const { current } = saveData;
if (JSON.stringify(initialValue) !== JSON.stringify(value)) {
current[key] = value;
} else {
delete current[key];
}
}, []);
const { values, handlers, hasUnsavedChanges, reset, commit } = useForm(useInitialValues(room, { retentionPolicyEnabled, maxAgeDefault }), onChange);
const sysMesOptions = useMemo(() => MessageTypesValues.map(({ key, i18nLabel }) => [key, t(i18nLabel)]), [t]);
const {
roomName,
roomType,
readOnly,
encrypted,
roomAvatar,
archived,
roomTopic,
roomDescription,
roomAnnouncement,
reactWhenReadOnly,
joinCode,
joinCodeRequired,
systemMessages,
hideSysMes,
retentionEnabled,
retentionOverrideGlobal,
retentionMaxAge,
retentionExcludePinned,
retentionFilesOnly,
} = values;
const {
handleJoinCode,
handleJoinCodeRequired,
handleSystemMessages,
handleEncrypted,
handleHideSysMes,
handleRoomName,
handleReadOnly,
handleArchived,
handleRoomAvatar,
handleReactWhenReadOnly,
handleRoomType,
handleRoomTopic,
handleRoomDescription,
handleRoomAnnouncement,
handleRetentionEnabled,
handleRetentionOverrideGlobal,
handleRetentionMaxAge,
handleRetentionExcludePinned,
handleRetentionFilesOnly,
} = handlers;
const [
canViewName,
canViewTopic,
canViewAnnouncement,
canViewArchived,
canViewDescription,
canViewType,
canViewReadOnly,
canViewHideSysMes,
canViewJoinCode,
canViewReactWhenReadOnly,
canViewEncrypted,
] = useMemo(() => {
const isAllowed = roomTypes.getConfig(room.t)?.allowRoomSettingChange || (() => {});
return [
isAllowed(room, RoomSettingsEnum.NAME),
isAllowed(room, RoomSettingsEnum.TOPIC),
isAllowed(room, RoomSettingsEnum.ANNOUNCEMENT),
isAllowed(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE),
isAllowed(room, RoomSettingsEnum.DESCRIPTION),
isAllowed(room, RoomSettingsEnum.TYPE),
isAllowed(room, RoomSettingsEnum.READ_ONLY),
isAllowed(room, RoomSettingsEnum.SYSTEM_MESSAGES),
isAllowed(room, RoomSettingsEnum.JOIN_CODE),
isAllowed(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY),
isAllowed(room, RoomSettingsEnum.E2E),
];
}, [room]);
const isAdmin = useRole('admin');
const canCreateChannel = usePermission('create-c');
const canCreateGroup = usePermission('create-p');
const canChangeType = getCanChangeType(room, canCreateChannel, canCreateGroup, isAdmin);
const canSetRo = usePermission('set-readonly', room._id);
const canSetReactWhenRo = usePermission('set-react-when-readonly', room._id);
const canEditPrivilegedSetting = usePermission('edit-privileged-setting', room._id);
const canArchiveOrUnarchive = useAtLeastOnePermission(useMemo(() => ['archive-room', 'unarchive-room'], []));
const canDelete = usePermission(`delete-${ room.t }`);
const changeArchivation = archived !== !!room.archived;
const archiveSelector = room.archived ? 'unarchive' : 'archive';
const archiveMessage = room.archived ? 'Room_has_been_unarchived' : 'Room_has_been_archived';
const saveAction = useEndpointActionExperimental('POST', 'rooms.saveRoomSettings', t('Room_updated_successfully'));
const archiveAction = useEndpointActionExperimental('POST', 'rooms.changeArchivationState', t(archiveMessage));
const handleSave = useMutableCallback(async () => {
const { joinCodeRequired, hideSysMes, ...data } = saveData.current;
const save = () => saveAction({
rid: room._id,
...data,
...joinCode && { joinCode: joinCodeRequired ? joinCode : '' },
...(data.systemMessages || !hideSysMes) && { systemMessages: hideSysMes ? systemMessages : [] },
});
const archive = () => archiveAction({ rid: room._id, action: archiveSelector });
await Promise.all([hasUnsavedChanges && save(), changeArchivation && archive()].filter(Boolean));
saveData.current = {};
commit();
});
const deleteRoom = useMethod('eraseRoom');
const handleDelete = useMutableCallback(() => {
const onCancel = () => setModal(undefined);
const onConfirm = async () => {
await deleteRoom(room._id);
onCancel();
};
setModal(<DeleteChannelWarning onConfirm={onConfirm} onCancel={onCancel} />);
});
const changeRoomType = useMutableCallback(() => {
handleRoomType(roomType === 'p' ? 'c' : 'p');
});
const onChangeMaxAge = useMutableCallback((e) => {
handleRetentionMaxAge(Math.max(1, Number(e.currentTarget.value)));
});
return <VerticalBar.ScrollableContent p='0' is='form' onSubmit={useMutableCallback((e) => e.preventDefault())} >
<RoomAvatarEditor room={room} roomAvatar={roomAvatar} onChangeAvatar={handleRoomAvatar}/>
<Field>
<Field.Label>{t('Name')}</Field.Label>
<Field.Row>
<TextInput disabled={!canViewName} value={roomName} onChange={handleRoomName} flexGrow={1}/>
</Field.Row>
</Field>
{canViewDescription && <Field>
<Field.Label>{t('Description')}</Field.Label>
<Field.Row>
<TextAreaInput rows={4} value={roomDescription} onChange={handleRoomDescription} flexGrow={1}/>
</Field.Row>
</Field>}
{canViewAnnouncement && <Field>
<Field.Label>{t('Announcement')}</Field.Label>
<Field.Row>
<TextAreaInput rows={4} value={roomAnnouncement} onChange={handleRoomAnnouncement} flexGrow={1}/>
</Field.Row>
</Field>}
{canViewTopic && <Field>
<Field.Label>{t('Topic')}</Field.Label>
<Field.Row>
<TextAreaInput rows={4} value={roomTopic} onChange={handleRoomTopic} flexGrow={1}/>
</Field.Row>
</Field>}
{canViewType && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Private')}</Field.Label>
<Field.Row>
<ToggleSwitch disabled={!canChangeType} checked={roomType === 'p'} onChange={changeRoomType}/>
</Field.Row>
</Box>
<Field.Hint>{t('Just_invited_people_can_access_this_channel')}</Field.Hint>
</Field>}
{canViewReadOnly && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Read_only')}</Field.Label>
<Field.Row>
<ToggleSwitch disabled={!canSetRo} checked={readOnly} onChange={handleReadOnly}/>
</Field.Row>
</Box>
<Field.Hint>{t('Only_authorized_users_can_write_new_messages')}</Field.Hint>
</Field>}
{canViewReactWhenReadOnly && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('React_when_read_only')}</Field.Label>
<Field.Row>
<ToggleSwitch disabled={!canSetReactWhenRo} checked={reactWhenReadOnly} onChange={handleReactWhenReadOnly}/>
</Field.Row>
</Box>
<Field.Hint>{t('Only_authorized_users_can_write_new_messages')}</Field.Hint>
</Field>}
{canViewArchived && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Archived')}</Field.Label>
<Field.Row>
<ToggleSwitch disabled={!canArchiveOrUnarchive} checked={archived} onChange={handleArchived}/>
</Field.Row>
</Box>
</Field>}
{canViewJoinCode && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Password_to_access')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={joinCodeRequired} onChange={handleJoinCodeRequired}/>
</Field.Row>
</Box>
<Field.Row>
<TextInput disabled={!joinCodeRequired} value={joinCode} onChange={handleJoinCode} placeholder={t('Reset_password')} flexGrow={1}/>
</Field.Row>
</Field>}
{canViewHideSysMes && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Hide_System_Messages')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={hideSysMes} onChange={handleHideSysMes}/>
</Field.Row>
</Box>
<Field.Row>
<MultiSelect options={sysMesOptions} disabled={!hideSysMes} value={systemMessages} onChange={handleSystemMessages} placeholder={t('Select_an_option')} flexGrow={1}/>
</Field.Row>
</Field>}
{canViewEncrypted && <Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Encrypted')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={encrypted} onChange={handleEncrypted}/>
</Field.Row>
</Box>
</Field>}
{retentionPolicyEnabled && <Accordion>
<Accordion.Item title={t('Prune')}>
<FieldGroup>
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('RetentionPolicyRoom_Enabled')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={retentionEnabled} onChange={handleRetentionEnabled}/>
</Field.Row>
</Box>
</Field>
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('RetentionPolicyRoom_OverrideGlobal')}</Field.Label>
<Field.Row>
<ToggleSwitch disabled={!retentionEnabled || !canEditPrivilegedSetting} checked={retentionOverrideGlobal} onChange={handleRetentionOverrideGlobal}/>
</Field.Row>
</Box>
</Field>
{retentionOverrideGlobal && <>
<Callout type='danger'>
<RawText>{t('RetentionPolicyRoom_ReadTheDocs')}</RawText>
</Callout>
<Field>
<Field.Label>{t('RetentionPolicyRoom_MaxAge', { max: maxAgeDefault })}</Field.Label>
<Field.Row>
<NumberInput value={retentionMaxAge} onChange={onChangeMaxAge} flexGrow={1}/>
</Field.Row>
</Field>
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('RetentionPolicyRoom_ExcludePinned')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={retentionExcludePinned} onChange={handleRetentionExcludePinned}/>
</Field.Row>
</Box>
</Field>
<Field>
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('RetentionPolicyRoom_FilesOnly')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={retentionFilesOnly} onChange={handleRetentionFilesOnly}/>
</Field.Row>
</Box>
</Field>
</>}
</FieldGroup>
</Accordion.Item>
</Accordion>}
<Field>
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' w='full'>
<ButtonGroup stretch flexGrow={1}>
<Button type='reset' disabled={!hasUnsavedChanges} onClick={reset}>{t('Reset')}</Button>
<Button flexGrow={1} disabled={!hasUnsavedChanges} onClick={handleSave}>{t('Save')}</Button>
</ButtonGroup>
</Box>
</Field.Row>
</Field>
<Field>
<Field.Row>
<Button flexGrow={1} primary danger disabled={!canDelete} onClick={handleDelete} ><Icon name='trash' size='x16' />{t('Delete')}</Button>
</Field.Row>
</Field>
</VerticalBar.ScrollableContent>;
}
export default EditChannelWithData;

@ -0,0 +1,27 @@
import React from 'react';
import { Button, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage';
import { useTranslation } from '../contexts/TranslationContext';
const DeleteChannelWarning = ({ onConfirm, onCancel, ...props }) => {
const t = useTranslation();
return <Modal {...props}>
<Modal.Header>
<Icon color='danger' name='modal-warning' size={20}/>
<Modal.Title>{t('Are_you_sure')}</Modal.Title>
<Modal.Close onClick={onCancel}/>
</Modal.Header>
<Modal.Content fontScale='p1'>
{t('Delete_Room_Warning')}
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
<Button onClick={onCancel}>{t('Cancel')}</Button>
<Button primary danger onClick={onConfirm}>{t('Yes_delete_it')}</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>;
};
export default DeleteChannelWarning;

@ -8,7 +8,7 @@ function BaseAvatar(props) {
return <Skeleton variant='rect' {...props} />;
}
return <Avatar onError={setError} {...props}/>;
return <Avatar onError={setError} loading='lazy' {...props}/>;
}
export default BaseAvatar;

@ -3,10 +3,9 @@ import React from 'react';
import { roomTypes } from '../../../../app/utils/client';
import BaseAvatar from './BaseAvatar';
function RoomAvatar({ room: { type, ...room }, ...props }) {
const avatarUrl = roomTypes.getConfig(type).getAvatarPath({ type, ...room });
return <BaseAvatar url={avatarUrl} title={avatarUrl} {...props}/>;
function RoomAvatar({ room: { type, ...room }, ...rest }) {
const { url = roomTypes.getConfig(type).getAvatarPath({ username: room._id, ...room }), ...props } = rest;
return <BaseAvatar url={url} {...props}/>;
}
export default RoomAvatar;

@ -0,0 +1,51 @@
import React, { useEffect } from 'react';
import { Box, Button, ButtonGroup, Icon } from '@rocket.chat/fuselage';
import { css } from '@rocket.chat/css-in-js';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import RoomAvatar from './RoomAvatar';
import { useFileInput } from '../../../hooks/useFileInput';
import { useTranslation } from '../../../contexts/TranslationContext';
import { getAvatarURL } from '../../../../app/utils/lib/getAvatarURL';
const RoomAvatarEditor = ({ room, roomAvatar, onChangeAvatar = () => {}, ...props }) => {
const t = useTranslation();
const handleChangeAvatar = useMutableCallback((file) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
onChangeAvatar(reader.result);
};
});
const [clickUpload, reset] = useFileInput(handleChangeAvatar);
const clickReset = useMutableCallback(() => {
reset();
onChangeAvatar(null);
});
useEffect(() => {
!roomAvatar && reset();
}, [roomAvatar, reset]);
const defaultUrl = room.prid ? getAvatarURL({ roomId: room.prid }) : getAvatarURL({ username: `@${ room.name }` }); // Discussions inherit avatars from the parent room
return <Box borderRadius='x2' maxWidth='x332' w='full' position='relative' {...props}>
<RoomAvatar { ...roomAvatar !== undefined && { url: roomAvatar === null ? defaultUrl : roomAvatar } } room={room} size='332px' maxWidth='100%'/>
<Box className={[css`bottom: 0; right: 0;`]} position='absolute' m='x12'>
<ButtonGroup>
<Button small title={t('Upload_user_avatar')} onClick={clickUpload}>
<Icon name='upload' size='x16' />
{t('Upload')}
</Button>
<Button primary small danger title={t('Accounts_SetDefaultAvatar')} disabled={roomAvatar === null } onClick={clickReset}>
<Icon name='trash' size='x16' />
</Button>
</ButtonGroup>
</Box>
</Box>;
};
export default RoomAvatarEditor;

@ -28,7 +28,7 @@ export function UserAvatarEditor({ username, setAvatarObj, suggestions, disabled
setNewAvatarSource(URL.createObjectURL(file));
}, [setAvatarObj]);
const clickUpload = useFileInput(setUploadedPreview);
const [clickUpload] = useFileInput(setUploadedPreview);
const clickUrl = () => {
setNewAvatarSource(avatarFromUrl);

@ -1,27 +1,32 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useState, useEffect } from 'react';
import { useRef, useEffect } from 'react';
export const useFileInput = (onSetFile, fileType = 'image') => {
const [openInput, setOpenInput] = useState(() => () => {});
const handleSetFile = useMutableCallback(onSetFile);
export const useFileInput = (onSetFile, fileType = 'image/*') => {
const ref = useRef();
useEffect(() => {
const fileInput = document.createElement('input');
const formData = new FormData();
fileInput.setAttribute('type', 'file');
fileInput.setAttribute('accept', fileType);
fileInput.setAttribute('style', 'display: none');
document.body.appendChild(fileInput);
ref.current = fileInput;
const handleFiles = () => {
formData.append(fileType, fileInput.files[0]);
handleSetFile(fileInput.files[0], formData);
onSetFile(fileInput.files[0], formData);
};
fileInput.addEventListener('change', handleFiles, false);
setOpenInput(() => () => fileInput.click());
return () => {
fileInput.parentNode.removeChild(fileInput);
};
}, [fileType, handleSetFile]);
}, [fileType, onSetFile]);
return openInput;
const onClick = useMutableCallback(() => ref.current.click());
const reset = useMutableCallback(() => {
ref.current.value = '';
});
return [onClick, reset];
};

@ -5,6 +5,6 @@ import { Notifications } from '../../app/notifications';
Meteor.startup(function() {
Notifications.onLogged('updateAvatar', function(data) {
const { username, etag } = data;
Meteor.users.update({ username }, { $set: { avatarETag: etag } });
username && Meteor.users.update({ username }, { $set: { avatarETag: etag } });
});
});

@ -2741,6 +2741,7 @@
"Page_URL": "Page URL",
"Parent_channel_doesnt_exist": "Channel does not exist.",
"Password": "Password",
"Password_to_access": "Password to access",
"Password_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of passwords",
"Password_Changed_Description": "You may use the following placeholders: <br/><ul><li>[password] for the temporary password.</li><li>[name], [fname], [lname] for the user's full name, first name or last name, respectively.</li><li>[email] for the user's email.</li><li>[Site_Name] and [Site_URL] for the Application Name and URL respectively.</li></ul>",
"Password_Changed_Email_Subject": "[Site_Name] - Password Changed",

@ -30,6 +30,7 @@ export const fields = {
lastMessage: 1,
retention: 1,
prid: 1,
avatarETag: 1,
usersCount: 1,
// @TODO create an API to register this fields based on room type

@ -6,35 +6,61 @@ import {
wasFallbackModified,
setCacheAndDispositionHeaders,
} from './utils';
import { Rooms } from '../../../app/models/server';
import { FileUpload } from '../../../app/file-upload';
import { Rooms, Avatars } from '../../../app/models/server';
import { roomTypes } from '../../../app/utils';
const getRoom = (roomId) => {
const getRoomAvatar = (roomId) => {
const room = Rooms.findOneById(roomId, { fields: { t: 1, prid: 1, name: 1, fname: 1 } });
if (!room) {
return {};
}
const file = Avatars.findOneByRoomId(room._id);
// if it is a discussion, returns the parent room
if (room && room.prid) {
return Rooms.findOneById(room.prid, { fields: { t: 1, name: 1, fname: 1 } });
// if it is a discussion that doesn't have it's own avatar, returns the parent's room avatar
if (room.prid && !file) {
return getRoomAvatar(room.prid);
}
return room;
return { room, file };
};
export const roomAvatar = Meteor.bindEnvironment(function(req, res/* , next*/) {
const roomId = req.url.substr(1);
const room = getRoom(roomId);
const roomId = decodeURIComponent(req.url.substr(1).replace(/\?.*$/, ''));
const { room, file } = getRoomAvatar(roomId);
if (!room) {
res.writeHead(404);
res.end();
return;
}
const reqModifiedHeader = req.headers['if-modified-since'];
if (file) {
res.setHeader('Content-Security-Policy', 'default-src \'none\'');
if (reqModifiedHeader && reqModifiedHeader === file.uploadedAt?.toUTCString()) {
res.setHeader('Last-Modified', reqModifiedHeader);
res.writeHead(304);
res.end();
return;
}
if (file.uploadedAt) {
res.setHeader('Last-Modified', file.uploadedAt.toUTCString());
}
res.setHeader('Content-Type', file.type);
res.setHeader('Content-Length', file.size);
return FileUpload.get(file, req, res);
}
const roomName = roomTypes.getConfig(room.t).roomName(room);
setCacheAndDispositionHeaders(req, res);
const reqModifiedHeader = req.headers['if-modified-since'];
if (!wasFallbackModified(reqModifiedHeader, res)) {
res.writeHead(304);
res.end();

@ -31,13 +31,16 @@ Meteor.startup(function() {
addUserRoles('rocket.cat', 'bot');
const rs = RocketChatFile.bufferToStream(new Buffer(Assets.getBinary('avatars/rocketcat.png'), 'utf8'));
const buffer = Buffer.from(Assets.getBinary('avatars/rocketcat.png'));
const rs = RocketChatFile.bufferToStream(buffer, 'utf8');
const fileStore = FileUpload.getStore('Avatars');
fileStore.deleteByName('rocket.cat');
const file = {
userId: 'rocket.cat',
type: 'image/png',
size: buffer.length,
};
Meteor.runAsUser('rocket.cat', () => {

@ -199,4 +199,5 @@ import './v199';
import './v200';
import './v201';
import './v202';
import './v203';
import './xrun';

@ -0,0 +1,10 @@
import { Migrations } from '../../../app/migrations/server';
import { Avatars } from '../../../app/models/server';
Migrations.add({
version: 203,
up() {
Avatars.tryDropIndex({ name: 1 });
Avatars.tryEnsureIndex({ name: 1 }, { sparse: true });
},
});
Loading…
Cancel
Save