Skip to content
Snippets Groups Projects
Unverified Commit ce46e9b4 authored by Tasso Evangelista's avatar Tasso Evangelista Committed by GitHub
Browse files

refactor(client): Reconditioning of `/app/ui-*` modules (#28620)

parent f10aaa67
No related merge requests found
Showing
with 238 additions and 219 deletions
......@@ -4,7 +4,7 @@ import { Random } from '@rocket.chat/random';
import { settings } from '../../settings/client';
const openCenteredPopup = function (url, width, height) {
const openCenteredPopup = (url: string, width: number, height: number) => {
const screenX = typeof window.screenX !== 'undefined' ? window.screenX : window.screenLeft;
const screenY = typeof window.screenY !== 'undefined' ? window.screenY : window.screenTop;
const outerWidth = typeof window.outerWidth !== 'undefined' ? window.outerWidth : document.body.clientWidth;
......@@ -18,31 +18,27 @@ const openCenteredPopup = function (url, width, height) {
const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`;
const newwindow = window.open(url, 'Login', features);
if (newwindow.focus) {
newwindow.focus();
}
newwindow?.focus();
return newwindow;
};
Meteor.loginWithCas = function (options, callback) {
options = options || {};
(Meteor as any).loginWithCas = (_?: unknown, callback?: () => void) => {
const credentialToken = Random.id();
const login_url = settings.get('CAS_login_url');
const popup_width = settings.get('CAS_popup_width');
const popup_height = settings.get('CAS_popup_height');
const loginUrl = settings.get('CAS_login_url');
const popupWidth = settings.get('CAS_popup_width') || 800;
const popupHeight = settings.get('CAS_popup_height') || 600;
if (!login_url) {
if (!loginUrl) {
return;
}
const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX;
// check if the provided CAS URL already has some parameters
const delim = login_url.split('?').length > 1 ? '&' : '?';
const loginUrl = `${login_url}${delim}service=${appUrl}/_cas/${credentialToken}`;
const delim = loginUrl.split('?').length > 1 ? '&' : '?';
const popupUrl = `${loginUrl}${delim}service=${appUrl}/_cas/${credentialToken}`;
const popup = openCenteredPopup(loginUrl, popup_width || 800, popup_height || 600);
const popup = openCenteredPopup(popupUrl, popupWidth, popupHeight);
const checkPopupOpen = setInterval(function () {
let popupClosed;
......@@ -50,7 +46,7 @@ Meteor.loginWithCas = function (options, callback) {
// Fix for #328 - added a second test criteria (popup.closed === undefined)
// to humour this Android quirk:
// http://code.google.com/p/android/issues/detail?id=21061
popupClosed = popup.closed || popup.closed === undefined;
popupClosed = popup?.closed === undefined;
} catch (e) {
// For some unknown reason, IE9 (and others?) sometimes (when
// the popup closes too quickly?) throws "SCRIPT16386: No such
......
......@@ -19,12 +19,12 @@ window.addEventListener('message', (e) => {
case 'try-iframe-login':
iframeLogin.tryLogin((error) => {
if (error) {
e.source.postMessage(
e.source?.postMessage(
{
event: 'login-error',
response: error.message,
},
e.origin,
{ targetOrigin: e.origin },
);
}
});
......@@ -33,12 +33,12 @@ window.addEventListener('message', (e) => {
case 'login-with-token':
iframeLogin.loginWithToken(e.data, (error) => {
if (error) {
e.source.postMessage(
e.source?.postMessage(
{
event: 'login-error',
response: error.message,
},
e.origin,
{ targetOrigin: e.origin },
);
}
});
......
......@@ -11,7 +11,7 @@ class ImportersContainer {
* overwrite the previous one.
*
* @param {ImporterInfo} info The information related to the importer.
* @param {*} importer The class for the importer, will be undefined on the client.
* @param {*} [importer] The class for the importer, will be undefined on the client.
*/
add(info, importer) {
if (!(info instanceof ImporterInfo)) {
......
......@@ -12,14 +12,14 @@ class Settings extends SettingsBase {
dict = new ReactiveDict('settings');
get(_id: string | RegExp, ...args: []): any {
get<TValue = any>(_id: string | RegExp, ...args: []): TValue {
if (_id instanceof RegExp) {
throw new Error('RegExp Settings.get(RegExp)');
}
if (args.length > 0) {
throw new Error('settings.get(String, callback) only works on backend');
}
return this.dict.get(_id);
return this.dict.get(_id) as TValue;
}
private _storeSettingValue(record: { _id: string; value: SettingValue }, initialLoad: boolean): void {
......
import './popup/messagePopupSlashCommandPreview';
import './popup/messagePopupSlashCommandPreview.ts';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { Tracker } from 'meteor/tracker';
import { Messages } from '../../../models/client';
import { asReactiveSource } from '../../../../client/lib/tracker';
import { RoomManager } from '../../../../client/lib/RoomManager';
export const usersFromRoomMessages = new Mongo.Collection(null);
Tracker.autorun(() => {
const rid = asReactiveSource(
(cb) => RoomManager.on('changed', cb),
() => RoomManager.opened,
);
const user = Meteor.userId() && Meteor.users.findOne(Meteor.userId(), { fields: { username: 1 } });
if (!rid || !user) {
return;
}
usersFromRoomMessages.remove({});
const uniqueMessageUsersControl = {};
Messages.find(
{
rid,
'u.username': { $ne: user.username },
't': { $exists: false },
},
{
fields: {
'u.username': 1,
'u.name': 1,
'ts': 1,
},
sort: { ts: -1 },
},
)
.fetch()
.filter(({ u: { username } }) => {
const notMapped = !uniqueMessageUsersControl[username];
uniqueMessageUsersControl[username] = true;
return notMapped;
})
.forEach(({ u: { username, name }, ts }) =>
usersFromRoomMessages.upsert(username, {
_id: username,
username,
name,
ts,
}),
);
});
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { Tracker } from 'meteor/tracker';
import type { IUser } from '@rocket.chat/core-typings';
import { Messages } from '../../../models/client';
import { asReactiveSource } from '../../../../client/lib/tracker';
import { RoomManager } from '../../../../client/lib/RoomManager';
export const usersFromRoomMessages = new Mongo.Collection<{
_id: string;
username: string;
name: string | undefined;
ts: Date;
suggestion?: boolean;
}>(null);
Meteor.startup(() => {
Tracker.autorun(() => {
const uid = Meteor.userId();
const rid = asReactiveSource(
(cb) => RoomManager.on('changed', cb),
() => RoomManager.opened,
);
const user = uid ? (Meteor.users.findOne(uid, { fields: { username: 1 } }) as IUser | undefined) : undefined;
if (!rid || !user) {
return;
}
usersFromRoomMessages.remove({});
const uniqueMessageUsersControl: Record<string, boolean> = {};
Messages.find(
{
rid,
'u.username': { $ne: user.username },
't': { $exists: false },
},
{
fields: {
'u.username': 1,
'u.name': 1,
'ts': 1,
},
sort: { ts: -1 },
},
)
.fetch()
.filter(({ u: { username } }) => {
const notMapped = !uniqueMessageUsersControl[username];
uniqueMessageUsersControl[username] = true;
return notMapped;
})
.forEach(({ u: { username, name }, ts }) =>
usersFromRoomMessages.upsert(username, {
_id: username,
username,
name,
ts,
}),
);
});
});
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import type { SlashCommandPreviews } from '@rocket.chat/core-typings';
import { slashCommands } from '../../../utils/client';
import { hasAtLeastOnePermission } from '../../../authorization/client';
import { withDebouncing } from '../../../../lib/utils/highOrderFunctions';
import './messagePopupSlashCommandPreview.html';
const keys = {
......@@ -17,28 +19,15 @@ const keys = {
ARROW_DOWN: 40,
};
function getCursorPosition(input) {
if (!input) {
return;
}
if (input.selectionStart) {
return input.selectionStart;
}
if (document.selection) {
input.focus();
const sel = document.selection.createRange();
const selLen = document.selection.createRange().text.length;
sel.moveStart('character', -input.value.length);
return sel.text.length - selLen;
}
}
const getCursorPosition = (input: HTMLInputElement | HTMLTextAreaElement | null | undefined): number | undefined => {
return input?.selectionStart ?? undefined;
};
Template.messagePopupSlashCommandPreview.onCreated(function () {
this.open = new ReactiveVar(false);
this.isLoading = new ReactiveVar(true);
this.preview = new ReactiveVar();
this.selectedItem = new ReactiveVar();
this.preview = new ReactiveVar<SlashCommandPreviews | undefined>(undefined);
this.selectedItem = new ReactiveVar(undefined);
this.commandName = new ReactiveVar('');
this.commandArgs = new ReactiveVar('');
......@@ -50,37 +39,40 @@ Template.messagePopupSlashCommandPreview.onCreated(function () {
this.dragging = false;
const template = this;
template.fetchPreviews = withDebouncing({ wait: 500 })(function _previewFetcher(cmd, args) {
this.fetchPreviews = withDebouncing({ wait: 500 })((cmd, args) => {
const command = cmd;
const params = args;
const { rid, tmid } = template.data;
Meteor.call('getSlashCommandPreviews', { cmd, params, msg: { rid, tmid } }, function (err, preview) {
if (err) {
return;
}
if (!preview || !Array.isArray(preview.items)) {
template.preview.set({
i18nTitle: 'No_results_found_for',
items: [],
const { rid, tmid } = this.data;
Meteor.call(
'getSlashCommandPreviews',
{ cmd, params, msg: { rid, tmid } },
(err: Meteor.Error | Meteor.TypedError | Error | null | undefined, preview?: SlashCommandPreviews) => {
if (err) {
return;
}
if (!preview || !Array.isArray(preview.items)) {
this.preview.set({
i18nTitle: 'No_results_found_for',
items: [],
});
} else {
this.preview.set(preview);
}
this.commandName.set(command);
this.commandArgs.set(params);
this.isLoading.set(false);
Meteor.defer(() => {
this.verifySelection();
});
} else {
template.preview.set(preview);
}
template.commandName.set(command);
template.commandArgs.set(params);
template.isLoading.set(false);
Meteor.defer(function () {
template.verifySelection();
});
});
},
);
});
template.enterKeyAction = () => {
const current = template.find('.popup-item.selected');
this.enterKeyAction = () => {
const current = this.find('.popup-item.selected');
if (!current) {
return;
......@@ -92,51 +84,62 @@ Template.messagePopupSlashCommandPreview.onCreated(function () {
return;
}
const cmd = template.commandName.curValue;
const params = template.commandArgs.curValue;
const cmd = Tracker.nonreactive(() => this.commandName.get());
const params = Tracker.nonreactive(() => this.commandArgs.get());
if (!cmd || !params) {
return;
}
const item = template.preview.curValue.items.find((i) => i.id === selectedId);
const item = Tracker.nonreactive(() => this.preview.get())?.items.find((i) => i.id === selectedId);
if (!item) {
return;
}
const { rid, tmid } = template.data;
Meteor.call('executeSlashCommandPreview', { cmd, params, msg: { rid, tmid } }, item, function (err) {
if (err) {
console.warn(err);
}
});
template.open.set(false);
template.inputBox.value = '';
template.preview.set();
template.commandName.set('');
template.commandArgs.set('');
const { rid, tmid } = this.data;
Meteor.call(
'executeSlashCommandPreview',
{ cmd, params, msg: { rid, tmid } },
item,
(err: Meteor.Error | Meteor.TypedError | Error | null | undefined) => {
if (err) {
console.warn(err);
}
},
);
this.open.set(false);
if (this.inputBox) this.inputBox.value = '';
this.preview.set(undefined);
this.commandName.set('');
this.commandArgs.set('');
};
template.selectionLogic = () => {
const inputValueAtCursor = template.inputBox.value.substr(0, getCursorPosition(template.inputBox));
this.selectionLogic = () => {
const inputValueAtCursor = this.inputBox?.value.slice(0, getCursorPosition(this.inputBox)) ?? '';
if (!template.matchSelectorRegex.test(inputValueAtCursor)) {
template.open.set(false);
if (!this.matchSelectorRegex.test(inputValueAtCursor)) {
this.open.set(false);
return;
}
const matches = inputValueAtCursor.match(this.selectorRegex);
if (!matches) {
this.open.set(false);
return;
}
const matches = inputValueAtCursor.match(template.selectorRegex);
const cmd = matches[1].replace('/', '').trim().toLowerCase();
const command = slashCommands.commands[cmd];
// Ensure the command they're typing actually exists
// And it provides a command preview
// And if it provides a permission to check, they have permission to run the command
const { rid } = template.data;
if (!command || !command.providesPreview || (command.permission && !hasAtLeastOnePermission(command.permission, rid))) {
template.open.set(false);
const { rid } = this.data;
if (!command?.providesPreview || (command.permission && !hasAtLeastOnePermission(command.permission, rid))) {
this.open.set(false);
return;
}
......@@ -145,29 +148,33 @@ Template.messagePopupSlashCommandPreview.onCreated(function () {
// Check to verify there are no additional arguments passed,
// Because we we don't care about what it if there isn't
if (!args) {
template.open.set(false);
this.open.set(false);
return;
}
// If they haven't changed a thing, show what we already got
if (template.commandName.curValue === cmd && template.commandArgs.curValue === args && template.preview.curValue) {
template.isLoading.set(false);
template.open.set(true);
if (
Tracker.nonreactive(() => this.commandName.get()) === cmd &&
Tracker.nonreactive(() => this.commandArgs.get()) === args &&
Tracker.nonreactive(() => this.preview.get())
) {
this.isLoading.set(false);
this.open.set(true);
return;
}
template.isLoading.set(true);
template.open.set(true);
this.isLoading.set(true);
this.open.set(true);
// Fetch and display them
template.fetchPreviews(cmd, args);
this.fetchPreviews(cmd, args);
};
template.verifySelection = () => {
const current = template.find('.popup-item.selected');
this.verifySelection = () => {
const current = this.find('.popup-item.selected');
if (!current) {
const first = template.find('.popup-item');
const first = this.find('.popup-item');
if (first) {
first.className += ' selected sidebar-item__popup-active';
......@@ -176,9 +183,9 @@ Template.messagePopupSlashCommandPreview.onCreated(function () {
};
// Typing data
template.onInputKeyup = (event) => {
if (template.open.curValue === true && event.which === keys.ESC) {
template.open.set(false);
this.onInputKeyup = (event) => {
if (Tracker.nonreactive(() => this.open.get()) === true && event.which === keys.ESC) {
this.open.set(false);
event.preventDefault();
event.stopPropagation();
return;
......@@ -189,72 +196,72 @@ Template.messagePopupSlashCommandPreview.onCreated(function () {
return;
}
template.selectionLogic();
this.selectionLogic();
};
// Using the keyboard to navigate the options
template.onInputKeydown = (event) => {
if (!template.open.curValue || template.isLoading.curValue) {
this.onInputKeydown = (event) => {
if (!Tracker.nonreactive(() => this.open.get()) || Tracker.nonreactive(() => this.isLoading.get())) {
return;
}
if (event.which === keys.ENTER) {
// || event.which === keys.TAB) { <-- does using tab to select make sense?
template.enterKeyAction();
this.enterKeyAction();
event.preventDefault();
event.stopPropagation();
return;
}
if (event.which === keys.ARROW_UP) {
template.up();
this.up();
event.preventDefault();
event.stopPropagation();
return;
}
if (event.which === keys.ARROW_DOWN) {
template.down();
this.down();
event.preventDefault();
event.stopPropagation();
}
};
template.up = () => {
const current = template.find('.popup-item.selected');
const previous = $(current).prev('.popup-item')[0] || template.find('.popup-item:last-child');
this.up = () => {
const current = this.find('.popup-item.selected');
const previous = $(current).prev('.popup-item')[0] || this.find('.popup-item:last-child');
if (previous != null) {
if (previous) {
current.className = current.className.replace(/\sselected/, '').replace('sidebar-item__popup-active', '');
previous.className += ' selected sidebar-item__popup-active';
}
};
template.down = () => {
const current = template.find('.popup-item.selected');
const next = $(current).next('.popup-item')[0] || template.find('.popup-item');
this.down = () => {
const current = this.find('.popup-item.selected');
const next = $(current).next('.popup-item')[0] || this.find('.popup-item');
if (next && next.classList.contains('popup-item')) {
if (next?.classList.contains('popup-item')) {
current.className = current.className.replace(/\sselected/, '').replace('sidebar-item__popup-active', '');
next.className += ' selected sidebar-item__popup-active';
}
};
template.onFocus = () => {
template.clickingItem = false;
if (template.open.curValue) {
this.onFocus = () => {
this.clickingItem = false;
if (Tracker.nonreactive(() => this.open.get())) {
return;
}
template.selectionLogic();
this.selectionLogic();
};
template.onBlur = () => {
if (template.clickingItem) {
this.onBlur = () => {
if (this.clickingItem) {
return;
}
return template.open.set(false);
return this.open.set(false);
};
});
......@@ -264,18 +271,26 @@ Template.messagePopupSlashCommandPreview.onRendered(function _messagePopupSlashC
}
this.inputBox = this.data.getInput();
if (!this.inputBox) {
throw Error('Somethign wrong happened.');
}
$(this.inputBox).on('keyup', this.onInputKeyup);
$(this.inputBox).on('keydown', this.onInputKeydown);
$(this.inputBox).on('focus', this.onFocus);
$(this.inputBox).on('blur', this.onBlur);
const self = this;
self.autorun(() => {
setTimeout(self.selectionLogic, 500);
this.autorun(() => {
setTimeout(this.selectionLogic, 500);
});
});
Template.messagePopupSlashCommandPreview.onDestroyed(function () {
if (!this.inputBox) {
throw Error('Somethign wrong happened.');
}
$(this.inputBox).off('keyup', this.onInputKeyup);
$(this.inputBox).off('keydown', this.onInputKeydown);
$(this.inputBox).off('focus', this.onFocus);
......@@ -288,7 +303,7 @@ Template.messagePopupSlashCommandPreview.events({
return;
}
const template = Template.instance();
const template = Template.instance<'messagePopupSlashCommandPreview'>();
const current = template.find('.popup-item.selected');
if (current) {
current.className = current.className.replace(/\sselected/, '').replace('sidebar-item__popup-active', '');
......@@ -297,11 +312,11 @@ Template.messagePopupSlashCommandPreview.events({
e.currentTarget.className += ' selected sidebar-item__popup-active';
},
'mousedown .popup-item, touchstart .popup-item'() {
const template = Template.instance();
const template = Template.instance<'messagePopupSlashCommandPreview'>();
template.clickingItem = true;
},
'mouseup .popup-item, touchend .popup-item'() {
const template = Template.instance();
const template = Template.instance<'messagePopupSlashCommandPreview'>();
if (template.dragging) {
template.dragging = false;
return;
......@@ -312,25 +327,25 @@ Template.messagePopupSlashCommandPreview.events({
},
'touchmove .popup-item'(e) {
e.stopPropagation();
const template = Template.instance();
const template = Template.instance<'messagePopupSlashCommandPreview'>();
template.dragging = true;
},
});
Template.messagePopupSlashCommandPreview.helpers({
isOpen() {
return Template.instance().open.get(); // && ((Template.instance().hasData.get() || (Template.instance().data.emptyTemplate != null)) || !Template.instance().parentTemplate(1).subscriptionsReady());
return Template.instance<'messagePopupSlashCommandPreview'>().open.get();
},
getArgs() {
return Template.instance().commandArgs.get();
return Template.instance<'messagePopupSlashCommandPreview'>().commandArgs.get();
},
isLoading() {
return Template.instance().isLoading.get();
return Template.instance<'messagePopupSlashCommandPreview'>().isLoading.get();
},
isType(actual, expected) {
isType(actual: unknown, expected: unknown) {
return actual === expected;
},
preview() {
return Template.instance().preview.get();
return Template.instance<'messagePopupSlashCommandPreview'>().preview.get();
},
});
......@@ -32,7 +32,7 @@ export const isAppAccountBoxItem = (item: IAppAccountBoxItem | AccountBoxItem):
class AccountBoxBase {
private items = new ReactiveVar<IAppAccountBoxItem[]>([]);
public setStatus(status: UserStatus, statusText: string): any {
public setStatus(status: UserStatus, statusText?: string): any {
return APIClient.post('/v1/users.setStatus', { status, message: statusText });
}
......
......@@ -8,14 +8,19 @@ import { HTTP } from 'meteor/http';
import { settings } from '../../../settings/client';
export class IframeLogin {
constructor() {
this.enabled = false;
this.reactiveIframeUrl = new ReactiveVar();
this.reactiveEnabled = new ReactiveVar();
this.iframeUrl = undefined;
this.apiUrl = undefined;
this.apiMethod = undefined;
private enabled = false;
public reactiveEnabled = new ReactiveVar<boolean>(false);
public reactiveIframeUrl = new ReactiveVar<string | undefined>(undefined);
private iframeUrl: string | undefined;
private apiUrl: string | undefined;
private apiMethod: string | undefined;
constructor() {
Tracker.autorun((c) => {
this.enabled = settings.get('Accounts_iframe_enabled');
this.reactiveEnabled.set(this.enabled);
......@@ -31,13 +36,13 @@ export class IframeLogin {
if (this.enabled === true && this.iframeUrl && this.apiUrl && this.apiMethod) {
c.stop();
if (!Accounts._storedLoginToken()) {
this.tryLogin(() => {});
this.tryLogin();
}
}
});
}
tryLogin(callback) {
tryLogin(callback?: (error: Meteor.Error | Meteor.TypedError | Error | null | undefined, result: unknown) => void) {
if (!this.enabled) {
return;
}
......@@ -48,7 +53,7 @@ export class IframeLogin {
console.log('tryLogin');
const options = {
beforeSend: (xhr) => {
beforeSend: (xhr: XMLHttpRequest) => {
xhr.withCredentials = true;
},
};
......@@ -65,23 +70,26 @@ export class IframeLogin {
HTTP.call(this.apiMethod, this.apiUrl, options, (error, result) => {
console.log(error, result);
if (result && result.data && (result.data.token || result.data.loginToken)) {
this.loginWithToken(result.data, (error, result) => {
if (result?.data && (result.data.token || result.data.loginToken)) {
this.loginWithToken(result.data, (error: Meteor.Error | Meteor.TypedError | Error | null | undefined) => {
if (error) {
this.reactiveIframeUrl.set(iframeUrl);
} else {
this.reactiveIframeUrl.set();
this.reactiveIframeUrl.set(undefined);
}
callback(error, result);
callback?.(error, result);
});
} else {
this.reactiveIframeUrl.set(iframeUrl);
callback(error, result);
callback?.(error, result);
}
});
}
loginWithToken(tokenData, callback) {
loginWithToken(
tokenData: string | { loginToken: string } | { token: string },
callback?: (error: Meteor.Error | Meteor.TypedError | Error | null | undefined) => void,
) {
if (!this.enabled) {
return;
}
......@@ -94,7 +102,7 @@ export class IframeLogin {
console.log('loginWithToken');
if (tokenData.loginToken) {
if ('loginToken' in tokenData) {
return Meteor.loginWithToken(tokenData.loginToken, callback);
}
......
import './lib/accounts';
import './lib/collections';
import './lib/iframeCommands';
import './lib/parentTemplate';
import './lib/codeMirror';
import './views/app/roomSearch.html';
import './views/app/roomSearch';
import './views/app/photoswipeContent.ts'; // without the *.ts extension, *.html gets loaded first
export { UserAction, USER_ACTIVITIES } from './lib/UserAction';
export { KonchatNotification } from './lib/notification';
export { Button } from './lib/rocket';
export { AudioRecorder } from './lib/recorderjs/AudioRecorder';
export { VideoRecorder } from './lib/recorderjs/videoRecorder';
export * from './lib/userPopoverStatus';
......@@ -13,7 +13,7 @@ import { processMessageEditing } from '../../../../client/lib/chats/flows/proces
import { processTooLongMessage } from '../../../../client/lib/chats/flows/processTooLongMessage';
import { processSetReaction } from '../../../../client/lib/chats/flows/processSetReaction';
import { sendMessage } from '../../../../client/lib/chats/flows/sendMessage';
import { UserAction } from '..';
import { UserAction } from './UserAction';
import { replyBroadcast } from '../../../../client/lib/chats/flows/replyBroadcast';
import { createDataAPI } from '../../../../client/lib/chats/data';
import { createUploadsAPI } from '../../../../client/lib/chats/uploads';
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment