From 3483c8b4ef8ed8eac309eca73dba35952fbc593c Mon Sep 17 00:00:00 2001 From: "lingohub[bot]" <69908207+lingohub[bot]@users.noreply.github.com> Date: Mon, 11 Jan 2021 10:56:19 -0300 Subject: [PATCH 01/98] =?UTF-8?q?Language=20update=20from=20LingoHub=20?= =?UTF-8?q?=F0=9F=A4=96=20on=202021-01-11Z=20(#20146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Language update from LingoHub 🤖 Project Name: Rocket.Chat Project Link: https://translate.lingohub.com/rocketchat/dashboard/rocket-dot-chat User: Robot LingoHub Easy language translations with LingoHub 🚀 * Update zh-TW.i18n.json Co-authored-by: Robot LingoHub Co-authored-by: Diego Sampaio --- packages/rocketchat-i18n/i18n/ca.i18n.json | 2 +- packages/rocketchat-i18n/i18n/en.i18n.json | 2 +- packages/rocketchat-i18n/i18n/fr.i18n.json | 19 +++++++++++++++++-- packages/rocketchat-i18n/i18n/hu.i18n.json | 2 +- packages/rocketchat-i18n/i18n/nl.i18n.json | 18 +++++++++++++++--- packages/rocketchat-i18n/i18n/zh-TW.i18n.json | 2 +- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/rocketchat-i18n/i18n/ca.i18n.json b/packages/rocketchat-i18n/i18n/ca.i18n.json index 506fb1f53e25..fd6ba314abd0 100644 --- a/packages/rocketchat-i18n/i18n/ca.i18n.json +++ b/packages/rocketchat-i18n/i18n/ca.i18n.json @@ -1134,8 +1134,8 @@ "Edit_Department": "Edita departament", "Edit_previous_message": "`%s` - Edita el missatge anterior", "Edit_Priority": "Edita la prioritat", - "Edit_Tag": "Edita l’etiqueta", "Edit_Status": "Edita l'estat", + "Edit_Tag": "Edita l’etiqueta", "Edit_Trigger": "Edita disparador", "Edit_Unit": "Edita la unitat", "edit-message": "Editar missatge", diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 5b6039e82e73..b819a65b4f52 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -4169,4 +4169,4 @@ "Your_temporary_password_is_password": "Your temporary password is [password].", "Your_TOTP_has_been_reset": "Your Two Factor TOTP has been reset.", "Your_workspace_is_ready": "Your workspace is ready to use 🎉" -} +} \ No newline at end of file diff --git a/packages/rocketchat-i18n/i18n/fr.i18n.json b/packages/rocketchat-i18n/i18n/fr.i18n.json index 946932914ccb..c7b17dc61f25 100644 --- a/packages/rocketchat-i18n/i18n/fr.i18n.json +++ b/packages/rocketchat-i18n/i18n/fr.i18n.json @@ -704,13 +704,17 @@ "Cloud_Service_Agree_PrivacyTerms": "Accord sur les conditions de confidentialité du service cloud", "Cloud_Service_Agree_PrivacyTerms_Description": "Je suis d'accord avec les conditions et la politique de confidentialité", "Cloud_Service_Agree_PrivacyTerms_Login_Disabled_Warning": "Vous devez accepter les conditions de confidentialité du cloud (Assistant de configuration > Infos sur le cloud > Accord sur les conditions de confidentialité du service cloud) pour vous connecter à votre espace de travail cloud", + "Cloud_status_page_description": "Si un service cloud particulier rencontre des problèmes, vous pouvez vérifier les problèmes connus sur notre page d'état à l'adresse", "Cloud_troubleshooting": "Dépannage", "Cloud_update_email": "Mettre à jour l'e-mail", "Cloud_what_is_it": "Qu'est-ce que c'est?", "Cloud_what_is_it_additional": "De plus, vous serez en mesure de gérer les licences, la facturation et l'assistance depuis la console Rocket.Chat Cloud.", "Cloud_what_is_it_description": "Rocket.Chat Cloud Connect vous permet de connecter votre espace de travail Rocket.Chat auto-hébergé aux services que nous fournissons dans notre Cloud.", "Cloud_what_is_it_services_like": "Des services comme:", + "Cloud_workspace_connected": "Votre espace de travail est connecté à Rocket.Chat Cloud. La connexion à votre compte Rocket.Chat Cloud ici, vous permettra d'interagir avec certains services comme le marketplace.", "Cloud_workspace_connected_plus_account": "Votre espace de travail est maintenant connecté à Rocket.Chat Cloud et un compte est associé.", + "Cloud_workspace_disconnect": "Si vous ne souhaitez plus utiliser les services cloud, vous pouvez déconnecter votre espace de travail de Rocket.Chat Cloud.", + "Cloud_workspace_support": "Si vous rencontrez des problèmes avec un service cloud, essayez d'abord de synchroniser. Si le problème persiste, veuillez ouvrir un ticket d'assistance dans la console Cloud.", "Collapse_Embedded_Media_By_Default": "Réduire tous les médias intégrés par défaut", "color": "Couleur", "Color": "Couleur", @@ -1117,7 +1121,10 @@ "Desktop_Notifications_Not_Enabled": "Les notifications du bureau ne sont pas activées", "Details": "Détails", "Different_Style_For_User_Mentions": "Style différent pour les mentions utilisateurs", + "Direct_Message": "Message privé", + "Direct_message_creation_description": "Vous êtes sur le point de créer une discussion avec plusieurs utilisateurs. Ajoutez ceux avec qui vous souhaitez discuter, tous au même endroit, en utilisant des messages directs.", "Direct_message_someone": "Envoyer un message privé à quelqu'un", + "Direct_message_you_have_joined": "Vous avez rejoint un nouveau message privé avec", "Direct_Messages": "Messages Privés", "Direct_Reply": "Réponse directe", "Direct_Reply_Advice": "Vous pouvez directement répondre à cet email. Ne modifiez pas les emails précédents dans le fil.", @@ -1536,6 +1543,7 @@ "General": "Général", "Generate_New_Link": "Générer un nouveau lien", "Get_link": "Copier le lien", + "get-password-policy-mustContainAtLeastOneNumber": "Le mot de passe doit contenir au moins un chiffre", "github_no_public_email": "Vous n'avez pas d'adresse e-mail publique associée à votre compte GitHub", "Give_a_unique_name_for_the_custom_oauth": "Indiquez un nom unique pour l'OAuth personnalisé", "Give_the_application_a_name_This_will_be_seen_by_your_users": "Donnez un nom à l'application. Il sera visible par les utilisateurs.", @@ -1797,6 +1805,7 @@ "It_works": "Ça marche", "italic": "italique", "italics": "italique", + "Items_per_page:": "Objets par page :", "Jitsi_Chrome_Extension": "Chrome Extension Id", "Jitsi_Enable_Channels": "Activer dans les canaux", "Jitsi_Enabled_TokenAuth": "Activer l'authentification JWT", @@ -1811,6 +1820,7 @@ "join-without-join-code": "Rejoindre sans code de participation", "join-without-join-code_description": "Autorisation d'ignorer le code de jointure dans les canaux avec le code de joint activé", "Joined": "A rejoint", + "Joined_at": "Rejoint le", "Jump": "Sauter", "Jump_to_first_unread": "Aller au premier non lu", "Jump_to_message": "Aller au message", @@ -2670,6 +2680,7 @@ "Reset_E2E_Key": "Réinitialiser la clé de chiffrement", "Reset_password": "Réinitialiser le mot de passe", "Reset_section_settings": "Réinitialiser les paramètres de la section", + "Responding": "Répondre", "Restart": "Redémarrer", "Restart_the_server": "Redémarrer le serveur", "Retail": "Vente au détail", @@ -2704,6 +2715,8 @@ "RetentionPolicyRoom_OverrideGlobal": "Remplacer la stratégie de rétention globale", "RetentionPolicyRoom_ReadTheDocs": "Fais attention! Le fait de peaufiner ces paramètres sans le moindre soin peut détruire tout l'historique des messages. Veuillez lire la documentation avant d'activer la fonctionnalité ici.", "Retry_Count": "Réessayer compte", + "Return_to_home": "Retourner à la page d'accueil", + "Return_to_previous_page": "Retourner à la page précédente", "Robot_Instructions_File_Content": "Contenu du fichier Robots.txt", "Role": "Rôle", "Role_Editing": "Édition des rôles", @@ -2737,6 +2750,7 @@ "Room_type_changed_successfully": "Type du salon modifié avec succès", "Room_type_of_default_rooms_cant_be_changed": "C'est un salon par défaut et le type ne peut être modifié, merci de contacter un administrateur.", "Room_unarchived": "Salon désarchivé", + "Room_updated_successfully": "Salon mis à jour avec succès !", "Room_uploaded_file_list": "Liste des fichiers", "Room_uploaded_file_list_empty": "Aucun fichier disponible.", "Rooms": "Salons", @@ -2851,6 +2865,7 @@ "Set_as_leader": "Définir comme leader", "Set_as_moderator": "Définir comme modérateur", "Set_as_owner": "Définir comme propriétaire", + "Set_random_password_and_send_by_email": "Définir un mot de passe aléatoire et l'envoyer par e-mail", "set-leader": "Définir comme leader", "set-moderator": "Définir le modérateur", "set-moderator_description": "Autorisation de définir d'autres utilisateurs en tant que modérateur d'une chaîne", @@ -2940,7 +2955,7 @@ "snippet-message_description": "Autorisation de créer un fragment de code", "Snippeted_a_message": "Créer un extrait __snippetLink__", "Social_Network": "Réseau social", - "Sorry_page_you_requested_does_not_exist_or_was_deleted": "Désolé, la page que vous avez demandée n'existe pas ou a été supprimée!", + "Sorry_page_you_requested_does_not_exist_or_was_deleted": "Désolé, la page que vous avez demandée n'existe pas ou a été supprimée !", "Sort": "Trier", "Sort_By": "Trier par", "Sort_by_activity": "Trier par activité", @@ -3348,7 +3363,7 @@ "Users_and_rooms": "Utilisateurs et salles", "Users_in_role": "Utilisateurs ayant ce rôle", "Uses": "Utilisations", - "Uses_left": "Utilisatiions restantes", + "Uses_left": "Utilisations restantes", "UTF8_Names_Slugify": "Utiliser un slug (texte court) pour les noms UTF-8", "UTF8_Names_Validation": "Validation UTF8 des Noms", "UTF8_Names_Validation_Description": "Expression régulière utilisée pour valider les noms des utilisateurs et des canaux", diff --git a/packages/rocketchat-i18n/i18n/hu.i18n.json b/packages/rocketchat-i18n/i18n/hu.i18n.json index c5f10cf85c93..165bd1077d50 100644 --- a/packages/rocketchat-i18n/i18n/hu.i18n.json +++ b/packages/rocketchat-i18n/i18n/hu.i18n.json @@ -3403,4 +3403,4 @@ "Your_question": "Kérdésed", "Your_server_link": "A szerver linkje", "Your_workspace_is_ready": "A munkaterület készen áll a 🎉 használatára" -} +} \ No newline at end of file diff --git a/packages/rocketchat-i18n/i18n/nl.i18n.json b/packages/rocketchat-i18n/i18n/nl.i18n.json index 29f2fb5cb2f9..fd85e4c42d7c 100644 --- a/packages/rocketchat-i18n/i18n/nl.i18n.json +++ b/packages/rocketchat-i18n/i18n/nl.i18n.json @@ -704,16 +704,17 @@ "Cloud_Service_Agree_PrivacyTerms": "Overeenkomst voor privacyvoorwaarden voor cloudservice", "Cloud_Service_Agree_PrivacyTerms_Description": "Ik ga akkoord met de voorwaarden & het privacybeleid", "Cloud_Service_Agree_PrivacyTerms_Login_Disabled_Warning": "U moet de privacyvoorwaarden van de cloud aanvaarden (Installatiewizard > Cloud-informatie > Overeenkomst voor privacyvoorwaarden voor cloudservice) om de verbinding te maken met uw cloudwerkruimte", + "Cloud_status_page_description": "Als een bepaalde Cloud Service problemen heeft, dan kunt u gekende problemen controleren op onze statuspagina op", "Cloud_update_email": "Update e-mail", "Cloud_what_is_it": "Wat is dit?", "Cloud_what_is_it_additional": "Daarnaast kunt u licenties, facturering en ondersteuning beheren vanuit de Rocket.Chat Cloud Console.", "Cloud_what_is_it_description": "Met Rocket.Chat Cloud Connect kunt u uw zelf gehoste Rocket.Chat-werkruimte verbinden met services die we in onze Cloud aanbieden.", "Cloud_what_is_it_services_like": "Diensten zoals:", - "Cloud_workspace_connected": "Je werkruimte is verbonden met Rocket.Chat Cloud. Als u zich aanmeldt bij uw Rocket.Chat Cloud-account, kunt u communiceren met bepaalde diensten zoals marktplaats.", + "Cloud_workspace_connected": "Je werkruimte is verbonden met Rocket.Chat Cloud. Als je hier inlogt op je Rocket.Chat Cloud-account, kun je communiceren met bepaalde diensten zoals marketplace.", "Cloud_workspace_connected_plus_account": "Uw werkruimte is nu verbonden met de Rocket.Chat Cloud en er is een account aan gekoppeld.", "Cloud_workspace_connected_without_account": "Uw werkruimte is nu verbonden met de Rocket.Chat Cloud. Als u wilt, kunt u inloggen op de Rocket.Chat Cloud en uw werkruimte koppelen aan uw Cloud-account.", "Cloud_workspace_disconnect": "Als u geen gebruik meer wilt maken van cloudservices, kunt u uw werkruimte loskoppelen van Rocket.Chat Cloud.", - "Cloud_workspace_support": "Als u problemen ondervindt met een cloudservice, probeer dan eerst te synchroniseren. Als het probleem aanhoudt, open dan een supportticket in de Cloud Console.", + "Cloud_workspace_support": "Als u problemen ondervindt met een cloudservice, probeer dan eerst te synchroniseren. Mocht het probleem aanhouden, open dan een supportticket in de Cloud Console.", "Collaborative": "Samenwerkend", "Collapse_Embedded_Media_By_Default": "Klap ingebedde media standaard in", "color": "Kleur", @@ -1115,7 +1116,10 @@ "Desktop_Notifications_Enabled": "Desktopmeldingen zijn ingeschakeld", "Details": "Details", "Different_Style_For_User_Mentions": "Verschillende stijl voor gebruikersvermeldingen", + "Direct_Message": "Privébericht", + "Direct_message_creation_description": "U staat op het punt een chat te starten met meerdere gebruikers. Voeg degene toe met wie u wilt praten, iedereen op dezelfde plaats, via directe berichten.", "Direct_message_someone": "Stuur iemand een direct bericht", + "Direct_message_you_have_joined": "Je hebt je aangesloten voor een nieuw privébericht met", "Direct_Messages": "Directe berichten", "Direct_Reply": "Direct antwoord", "Direct_Reply_Advice": "U kunt deze e-mail direct beantwoorden. Wijzig eerdere e-mails in deze conversatie niet.", @@ -1224,7 +1228,7 @@ "Email_Placeholder": "Vul alstublieft uw e-mail adres in...", "Email_Placeholder_any": "Voer e-mailadressen in ...", "Email_subject": "Onderwerp", - "Email_verified": "E-mail geverifiëerd", + "Email_verified": "E-mailadres geverifieerd", "Emoji": "Emoji", "Emoji_provided_by_JoyPixels": "Emoji geleverd door JoyPixels", "EmojiCustomFilesystem": "Aangepast Emoji-bestandssysteem", @@ -1504,6 +1508,7 @@ "Gaming": "gaming", "General": "Algemeen", "Generate_New_Link": "Genereer een nieuwe link", + "get-password-policy-mustContainAtLeastOneNumber": "Het wachtwoord moet minstens één cijfer bevatten", "github_no_public_email": "U heeft geen openbaar e-mail adres in uw GitHub toegang", "Give_a_unique_name_for_the_custom_oauth": "Geef een unieke naam voor de aangepaste OAuth", "Give_the_application_a_name_This_will_be_seen_by_your_users": "Geef de toepassing een naam. Dit zal worden gezien door de gebruiker.", @@ -1755,6 +1760,7 @@ "IssueLinks_LinkTemplate_Description": "Sjabloon voor verbindingslinks; %s wordt vervangen door het probleemnummer.", "It_works": "Het werkt", "italics": "cursief", + "Items_per_page:": "Items per pagina:", "Jitsi_Chrome_Extension": "Chrome Extension Id", "Jitsi_Enable_Channels": "Schakelen in Channels", "Job_Title": "Functietitel", @@ -1768,6 +1774,7 @@ "join-without-join-code": "Word lid zonder code", "join-without-join-code_description": "Toestemming om de join-code te omzeilen in kanalen waarop joincode is ingeschakeld", "Joined": "Geregistreerd", + "Joined_at": "Toegetreden op", "Jump": "Springen", "Jump_to_first_unread": "Naar eerste ongelezen", "Jump_to_message": "Ga naar bericht", @@ -2577,6 +2584,7 @@ "Reset_password": "Reset Wachtwoord", "Reset_section_settings": "Reset sectie-instellingen", "reset-other-user-e2e-key": "Reset andere gebruiker E2E-sleutel", + "Responding": "Reageren", "Restart": "Herstart", "Restart_the_server": "Herstart de server", "Retail": "Kleinhandel", @@ -2611,6 +2619,8 @@ "RetentionPolicyRoom_OverrideGlobal": "Negeer het wereldwijde retentiebeleid", "RetentionPolicyRoom_ReadTheDocs": "Kijk uit! Als u deze instellingen zonder de grootste zorg aanpast, kan dit alle berichthistorie vernietigen. Lees de documentatie voordat u de functie inschakelt hier.", "Retry_Count": "Retry Count", + "Return_to_home": "Terugkeren naar homepagina", + "Return_to_previous_page": "Terugkeren naar de vorige pagina", "Robot_Instructions_File_Content": "Robots.txt-bestandsinhoud", "Role": "Rol", "Role_Editing": "Rol bewerken", @@ -2644,6 +2654,7 @@ "Room_type_changed_successfully": "Kamertype met succes gewijzigd", "Room_type_of_default_rooms_cant_be_changed": "Dit is een standaardkamer en het type kan niet worden gewijzigd, raadpleeg uw beheerder.", "Room_unarchived": "Kamer uit meer gearchiveerd", + "Room_updated_successfully": "Kamer succesvol bijgewerkt!", "Room_uploaded_file_list": "Bestandslijst", "Room_uploaded_file_list_empty": "Geen bestanden beschikbaar.", "Rooms": "Kamers", @@ -2762,6 +2773,7 @@ "Set_as_leader": "Instellen als leider", "Set_as_moderator": "Stel in als moderator", "Set_as_owner": "Maak eigenaar", + "Set_random_password_and_send_by_email": "Stel een willekeurig wachtwoord in en stuur het per e-mail", "set-leader": "Leider instellen", "set-moderator": "Stel Moderator in", "set-moderator_description": "Toestemming om andere gebruikers in te stellen als moderator van een kanaal", diff --git a/packages/rocketchat-i18n/i18n/zh-TW.i18n.json b/packages/rocketchat-i18n/i18n/zh-TW.i18n.json index ca54dad10dc6..13fc99bac0ee 100644 --- a/packages/rocketchat-i18n/i18n/zh-TW.i18n.json +++ b/packages/rocketchat-i18n/i18n/zh-TW.i18n.json @@ -3186,7 +3186,7 @@ "Room_archivation_state_true": "封存", "Room_archived": "Room 已封存", "room_changed_announcement": "房間公告更改為:__room_announcement____user_by__", - "room_changed_avatar": null, + "room_changed_avatar": "Room 大頭貼由 __user_by__ 更改", "room_changed_description": "客房描述更改為:__room_description____user_by__", "room_changed_privacy": "通過__user_by__ __room_type__: Room 類型變更為", "room_changed_topic": "Room 話題改為:__room_topic____user_by__", From c444e5d9ca48449f983e3b44ecf8610991f44082 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Mon, 11 Jan 2021 22:52:50 -0300 Subject: [PATCH 02/98] [IMPROVE] Rewrite User Dropdown and Kebab menu. (#20070) Co-authored-by: Guilherme Gazzo Co-authored-by: Guilherme Gazzo --- app/livechat/client/ui.js | 2 +- client/sidebar/header/UserAvatarButton.js | 125 ++----------- client/sidebar/header/UserDropdown.js | 171 ++++++++++++++++++ client/sidebar/header/actions/Menu.js | 80 -------- client/sidebar/header/index.js | 2 - .../sidebar/hooks/useSidebarPaletteColor.js | 25 ++- client/sidebar/search/SearchList.js | 7 +- ee/app/auditing/client/index.js | 4 +- package-lock.json | 38 ++-- package.json | 5 +- packages/rocketchat-i18n/i18n/en.i18n.json | 1 + 11 files changed, 239 insertions(+), 221 deletions(-) create mode 100644 client/sidebar/header/UserDropdown.js delete mode 100644 client/sidebar/header/actions/Menu.js diff --git a/app/livechat/client/ui.js b/app/livechat/client/ui.js index 3f534e1ddb66..069254af7023 100644 --- a/app/livechat/client/ui.js +++ b/app/livechat/client/ui.js @@ -15,7 +15,7 @@ Tracker.autorun((c) => { AccountBox.addItem({ name: 'Omnichannel', - icon: 'omnichannel', + icon: 'headset', href: '/omnichannel/current', sideNav: 'omnichannelFlex', condition: () => settings.get('Livechat_enabled') && hasAllPermission('view-livechat-manager'), diff --git a/client/sidebar/header/UserAvatarButton.js b/client/sidebar/header/UserAvatarButton.js index 1fd647a9c169..76e2a8f29a4d 100644 --- a/client/sidebar/header/UserAvatarButton.js +++ b/client/sidebar/header/UserAvatarButton.js @@ -1,131 +1,32 @@ import React from 'react'; import { Meteor } from 'meteor/meteor'; -import { FlowRouter } from 'meteor/kadira:flow-router'; import { Box } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { css } from '@rocket.chat/css-in-js'; -import { popover, modal, AccountBox } from '../../../app/ui-utils'; +import { popover } from '../../../app/ui-utils'; import { useSetting } from '../../contexts/SettingsContext'; -import { useTranslation } from '../../contexts/TranslationContext'; import { UserStatus } from '../../components/UserStatus'; -import { userStatus } from '../../../app/user-status'; -import { callbacks } from '../../../app/callbacks'; +import { createTemplateForComponent } from '../../reactAdapters'; import UserAvatar from '../../components/avatar/UserAvatar'; -const setStatus = (status, statusText) => { - AccountBox.setStatus(status, statusText); - callbacks.run('userStatusManuallySet', status); - popover.close(); -}; +const UserDropdown = createTemplateForComponent('UserDropdown', () => import('./UserDropdown')); -const onClick = (e, t, allowAnonymousRead) => { +const openDropdown = (e, user, onClose, allowAnonymousRead) => { if (!(Meteor.userId() == null && allowAnonymousRead)) { - const user = Meteor.user(); - const STATUS_MAP = [ - 'offline', - 'online', - 'away', - 'busy', - ]; - const userStatusList = Object.keys(userStatus.list).map((key) => { - const status = userStatus.list[key]; - const name = status.localizeName ? t(status.name) : status.name; - const modifier = status.statusType || user.status; - const defaultStatus = STATUS_MAP.includes(status.id); - const statusText = defaultStatus ? null : name; - - return { - icon: 'circle', - name, - modifier, - action: () => setStatus(status.statusType, statusText), - }; - }); - - const statusText = user.statusText || t(user.status); - - userStatusList.push({ - icon: 'edit', - name: t('Edit_Status'), - type: 'open', - action: (e) => { - e.preventDefault(); - modal.open({ - title: t('Edit_Status'), - content: 'editStatus', - data: { - onSave() { - modal.close(); - }, - }, - modalClass: 'modal', - showConfirmButton: false, - showCancelButton: false, - confirmOnEnter: false, - }); - }, - }); - - const config = { - popoverClass: 'sidebar-header', - columns: [ - { - groups: [ - { - title: user.name, - items: [{ - icon: 'circle', - name: statusText, - modifier: user.status, - }], - }, - { - title: t('User'), - items: userStatusList, - }, - { - items: [ - { - icon: 'user', - name: t('My_Account'), - type: 'open', - id: 'account', - action: () => { - FlowRouter.go('account'); - popover.close(); - }, - }, - { - icon: 'sign-out', - name: t('Logout'), - type: 'open', - id: 'logout', - action: () => { - Meteor.logout(() => { - callbacks.run('afterLogoutCleanUp', user); - Meteor.call('logoutCleanUp', user); - FlowRouter.go('home'); - popover.close(); - }); - }, - }, - ], - }, - ], - }, - ], + popover.open({ + template: UserDropdown, currentTarget: e.currentTarget, + data: { + user, + onClose, + }, offsetVertical: e.currentTarget.clientHeight + 10, - }; - - popover.open(config); + }); } }; export default React.memo(({ user = {} }) => { - const t = useTranslation(); - const { _id: uid, status = !uid && 'online', @@ -135,7 +36,9 @@ export default React.memo(({ user = {} }) => { const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead'); - const handleClick = useMutableCallback((e) => uid && onClick(e, t, allowAnonymousRead)); + const onClose = useMutableCallback(() => popover.close()); + + const handleClick = useMutableCallback((e) => uid && openDropdown(e, user, onClose, allowAnonymousRead)); return diff --git a/client/sidebar/header/UserDropdown.js b/client/sidebar/header/UserDropdown.js new file mode 100644 index 000000000000..a93dc1556bc4 --- /dev/null +++ b/client/sidebar/header/UserDropdown.js @@ -0,0 +1,171 @@ +import React from 'react'; +import { Box, Margins, Divider, Option } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { Meteor } from 'meteor/meteor'; +import { FlowRouter } from 'meteor/kadira:flow-router'; + +import UserAvatar from '../../components/avatar/UserAvatar'; +import { UserStatus } from '../../components/UserStatus'; +import { useSetting } from '../../contexts/SettingsContext'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useRoute } from '../../contexts/RouterContext'; +import { useReactiveValue } from '../../hooks/useReactiveValue'; +import { useAtLeastOnePermission } from '../../contexts/AuthorizationContext'; +import { userStatus } from '../../../app/user-status'; +import { callbacks } from '../../../app/callbacks'; +import { popover, AccountBox, modal, SideNav } from '../../../app/ui-utils'; + +const ADMIN_PERMISSIONS = [ + 'view-logs', + 'manage-emoji', + 'manage-sounds', + 'view-statistics', + 'manage-oauth-apps', + 'view-privileged-setting', + 'manage-selected-settings', + 'view-room-administration', + 'view-user-administration', + 'access-setting-permissions', + 'manage-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-outgoing-integrations', + 'manage-own-incoming-integrations', +]; + +const style = { + marginInline: '-16px', +}; + +const setStatus = (status, statusText) => { + AccountBox.setStatus(status, statusText); + callbacks.run('userStatusManuallySet', status); +}; + +const getItems = () => AccountBox.getItems(); + +const UserDropdown = ({ user, onClose }) => { + const t = useTranslation(); + const homeRoute = useRoute('home'); + const accountRoute = useRoute('account'); + const adminRoute = useRoute('admin'); + + const { + name, + username, + avatarETag, + status, + statusText, + } = user; + + const useRealName = useSetting('UI_Use_Real_Name'); + const showAdmin = useAtLeastOnePermission(ADMIN_PERMISSIONS); + + + const handleCustomStatus = useMutableCallback((e) => { + e.preventDefault(); + modal.open({ + title: t('Edit_Status'), + content: 'editStatus', + data: { + onSave() { + modal.close(); + }, + }, + modalClass: 'modal', + showConfirmButton: false, + showCancelButton: false, + confirmOnEnter: false, + }); + onClose(); + }); + + const handleLogout = useMutableCallback(() => { + Meteor.logout(() => { + callbacks.run('afterLogoutCleanUp', user); + Meteor.call('logoutCleanUp', user); + homeRoute.push({}); + popover.close(); + }); + }); + + const handleMyAccount = useMutableCallback(() => { + accountRoute.push({}); + popover.close(); + }); + + const handleAdmin = useMutableCallback(() => { + adminRoute.push({ group: 'info' }); + popover.close(); + }); + + const accountBoxItems = useReactiveValue(getItems); + + return + + + + + + + + {useRealName ? name || username : username} + + + + {statusText || t(status)} + + + + + +
+ {t('Status')} + {Object.keys(userStatus.list).map((key) => { + const status = userStatus.list[key]; + const name = status.localizeName ? t(status.name) : status.name; + const modifier = status.statusType || user.status; + + return ; + })} +
+ + {(accountBoxItems.length || showAdmin) && <> + +
+ {showAdmin &&
+ } + + +
+
+ +
; +}; + +export default UserDropdown; diff --git a/client/sidebar/header/actions/Menu.js b/client/sidebar/header/actions/Menu.js deleted file mode 100644 index 67c4c03b3258..000000000000 --- a/client/sidebar/header/actions/Menu.js +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { Sidebar } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { FlowRouter } from 'meteor/kadira:flow-router'; - -import { popover, AccountBox, SideNav } from '../../../../app/ui-utils'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { useAtLeastOnePermission } from '../../../contexts/AuthorizationContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; - -const ADMIN_PERMISSIONS = ['manage-emoji', 'manage-oauth-apps', 'manage-outgoing-integrations', 'manage-incoming-integrations', 'manage-own-outgoing-integrations', 'manage-own-incoming-integrations', 'manage-selected-settings', 'manage-sounds', 'view-logs', 'view-privileged-setting', 'view-room-administration', 'view-statistics', 'view-user-administration', 'access-setting-permissions']; - -const openPopover = (e, accountBoxItems, t, adminOption) => popover.open({ - popoverClass: 'sidebar-header', - columns: [ - { - groups: [ - { - items: accountBoxItems.map((item) => { - let action; - - if (item.href || item.sideNav) { - action = () => { - if (item.href) { - FlowRouter.go(item.href); - popover.close(); - } - if (item.sideNav) { - SideNav.setFlex(item.sideNav); - SideNav.openFlex(); - popover.close(); - } - }; - } - - return { - icon: item.icon, - name: t(item.name), - type: 'open', - id: item.name, - href: item.href, - sideNav: item.sideNav, - action, - }; - }).concat([adminOption]), - }, - ], - }, - ], - currentTarget: e.currentTarget, - offsetVertical: e.currentTarget.clientHeight + 10, -}); - -const getItems = () => AccountBox.getItems(); - -const adminOption = (showAdmin, t) => (showAdmin ? { - icon: 'customize', - name: t('Administration'), - type: 'open', - id: 'administration', - action: () => { - FlowRouter.go('admin', { group: 'info' }); - popover.close(); - }, -} : undefined); - -const Menu = (props) => { - const t = useTranslation(); - const showAdmin = useAtLeastOnePermission(ADMIN_PERMISSIONS); - - const accountBoxItems = useReactiveValue(getItems); - - const onClick = useMutableCallback((e) => openPopover(e, accountBoxItems, t, adminOption(showAdmin, t))); - - const showMenu = accountBoxItems?.length > 0; - - return showAdmin || showMenu ? : null; -}; - -export default Menu; diff --git a/client/sidebar/header/index.js b/client/sidebar/header/index.js index d5d549a659da..2d0d595680b7 100644 --- a/client/sidebar/header/index.js +++ b/client/sidebar/header/index.js @@ -6,7 +6,6 @@ import Search from './actions/Search'; import Directory from './actions/Directory'; import Sort from './actions/Sort'; import CreateRoom from './actions/CreateRoom'; -import Menu from './actions/Menu'; import Login from './actions/Login'; import UserAvatarButton from './UserAvatarButton'; import { useUser } from '../../contexts/UserContext'; @@ -25,7 +24,6 @@ const HeaderWithData = () => { - } {!user && } diff --git a/client/sidebar/hooks/useSidebarPaletteColor.js b/client/sidebar/hooks/useSidebarPaletteColor.js index c6c845c9bba2..cdf4a31f0690 100644 --- a/client/sidebar/hooks/useSidebarPaletteColor.js +++ b/client/sidebar/hooks/useSidebarPaletteColor.js @@ -145,14 +145,24 @@ const getStyle = ((selector) => (colors) => ` --rcx-color-primary-700: ${ toVar(colors.b300) }; --rcx-color-primary-800: ${ toVar(colors.b200) }; --rcx-color-primary-900: ${ toVar(colors.b100) }; + + --rcx-button-colors-ghost-active-border-color: ${ toVar(colors.n900) }; + --rcx-button-colors-ghost-active-background-color: ${ toVar(colors.n800) }; + --rcx-button-colors-ghost-color: ${ toVar(colors.n600) }; + --rcx-button-colors-ghost-border-color: ${ toVar(colors.sibebarSurface) }; + --rcx-button-colors-ghost-background-color: ${ toVar(colors.sibebarSurface) }; + --rcx-button-colors-ghost-hover-background-color: ${ toVar(colors.n900) }; + --rcx-button-colors-ghost-hover-border-color: ${ toVar(colors.n900) }; + + --rcx-button-colors-ghost-success-active-border-color: ${ toVar(colors.n900) }; + --rcx-button-colors-ghost-success-active-background-color: ${ toVar(colors.n800) }; + --rcx-button-colors-ghost-success-color: ${ toVar(colors.n600) }; + --rcx-button-colors-ghost-success-border-color: ${ toVar(colors.sibebarSurface) }; + --rcx-button-colors-ghost-success-background-color: ${ toVar(colors.sibebarSurface) }; + --rcx-button-colors-ghost-success-hover-background-color: ${ toVar(colors.n900) }; + --rcx-button-colors-ghost-success-hover-border-color: ${ toVar(colors.n900) }; + - --rcx-button-colors-secondary-active-border-color: ${ toVar(colors.n900) }; - --rcx-button-colors-secondary-active-background-color: ${ toVar(colors.n800) }; - --rcx-button-colors-secondary-color: ${ toVar(colors.n600) }; - --rcx-button-colors-secondary-border-color: ${ toVar(colors.n800) }; - --rcx-button-colors-secondary-background-color: ${ toVar(colors.n800) }; - --rcx-button-colors-secondary-hover-background-color: ${ toVar(colors.n900) }; - --rcx-button-colors-secondary-hover-border-color: ${ toVar(colors.n900) }; --rcx-sidebar-item-background-color-hover: ${ toVar(colors.n900) }; --rcx-sidebar-item-background-color-selected: ${ h2r(toVar(colors.n700 || colors.n800), 0.3) }; --rcx-badge-colors-ghost-background-color: ${ toVar(colors.n700) }; @@ -162,7 +172,6 @@ const getStyle = ((selector) => (colors) => ` --rcx-divider-color: ${ h2r(toVar(colors.n900), 0.4) }; --rcx-color-foreground-alternative: ${ toVar(colors.n100) }; --rcx-color-foreground-hint: ${ toVar(colors.n600) }; - } .rcx-sidebar { background-color: ${ toVar(colors.sibebarSurface) }; diff --git a/client/sidebar/search/SearchList.js b/client/sidebar/search/SearchList.js index 428f85ea5ebd..208e8bc56389 100644 --- a/client/sidebar/search/SearchList.js +++ b/client/sidebar/search/SearchList.js @@ -133,8 +133,8 @@ const useSearchItems = (filterText) => { const resultsFromServer = []; const filterUsersUnique = ({ _id }, index, arr) => index === arr.findIndex((user) => _id === user._id); - const roomFilter = (room) => !localRooms.find((item) => (room.t === 'd' && room.uids.length > 1 && room.uids.includes(item._id)) || [item.rid, item._id].includes(room._id)); - const usersfilter = (user) => !localRooms.find((room) => room.t === 'd' && (room.uids.length === 2 && room.uids.includes(user._id))); + const roomFilter = (room) => !localRooms.find((item) => (room.t === 'd' && room.uids?.length > 1 && room.uids.includes(item._id)) || [item.rid, item._id].includes(room._id)); + const usersfilter = (user) => !localRooms.find((room) => room.t === 'd' && (room.uids?.length === 2 && room.uids.includes(user._id))); const userMap = (user) => ({ _id: user._id, @@ -272,7 +272,8 @@ const SearchList = React.forwardRef(function SearchList({ onClose }, ref) { return () => { unsubscribe(); }; - }, [autofocus, changeSelection, items.length, onClose, resetCursor, setFilterValue]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autofocus, changeSelection, items?.length, onClose, resetCursor, setFilterValue]); return diff --git a/ee/app/auditing/client/index.js b/ee/app/auditing/client/index.js index e0be4fdb4a62..04c81f9ffbe7 100644 --- a/ee/app/auditing/client/index.js +++ b/ee/app/auditing/client/index.js @@ -13,14 +13,14 @@ hasLicense('auditing').then((enabled) => { AccountBox.addItem({ href: 'audit-home', name: 'Message_auditing', - icon: 'clipboard', + icon: 'document-eye', condition: () => hasAllPermission('can-audit'), }); AccountBox.addItem({ href: 'audit-log', name: 'Message_auditing_log', - icon: 'clipboard', + icon: 'document-eye', condition: () => hasAllPermission('can-audit-log'), }); }).catch((error) => { diff --git a/package-lock.json b/package-lock.json index 79225fe5f7a1..5c490e4c6b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5957,14 +5957,21 @@ } }, "@rocket.chat/fuselage": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.20.1.tgz", - "integrity": "sha512-a5qeYW60Z9/ZNpMLlSRIpD3utnaK1XV413yjLU2diHGTzPB6FVKBI5piqJmy4o5YTIcxyDtMZVDPWhH5ZixvEg==", + "version": "0.6.3-dev.164", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.6.3-dev.164.tgz", + "integrity": "sha512-e6sbkVm4R+E6Dq7xWbNoyhr/1cTjk7duMiZdLvZ1qa9AasBhTpXtYXgtN0SA+qSNH3l907YpoKUJU+qVWbXl6w==", "requires": { "@rocket.chat/css-in-js": "^0.20.1", "@rocket.chat/fuselage-tokens": "^0.20.1", "invariant": "^2.2.4", "react-keyed-flatten-children": "^1.2.0" + }, + "dependencies": { + "@rocket.chat/fuselage-tokens": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.20.1.tgz", + "integrity": "sha512-tRzWNvdb9T7nU3U9ZbMue84yvs21aP44tVsV/Gz+UNjWG1cEmpnB73dIj+52orzM+VcU7YSJ+Tv+K8Z87fHCeA==" + } } }, "@rocket.chat/fuselage-hooks": { @@ -5974,6 +5981,13 @@ "requires": { "@rocket.chat/fuselage-tokens": "^0.20.1", "use-subscription": "^1.4.1" + }, + "dependencies": { + "@rocket.chat/fuselage-tokens": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.20.1.tgz", + "integrity": "sha512-tRzWNvdb9T7nU3U9ZbMue84yvs21aP44tVsV/Gz+UNjWG1cEmpnB73dIj+52orzM+VcU7YSJ+Tv+K8Z87fHCeA==" + } } }, "@rocket.chat/fuselage-polyfills": { @@ -5989,9 +6003,9 @@ } }, "@rocket.chat/fuselage-tokens": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.20.1.tgz", - "integrity": "sha512-tRzWNvdb9T7nU3U9ZbMue84yvs21aP44tVsV/Gz+UNjWG1cEmpnB73dIj+52orzM+VcU7YSJ+Tv+K8Z87fHCeA==" + "version": "0.6.3-dev.161", + "resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.6.3-dev.161.tgz", + "integrity": "sha512-qHOiBuvquTaiv6qc9yDUbmFdRQf32BJ4SdAG7sQjNk+Qpvl3vuYYXID3zIRrhSpHHEKbspIimU8pdJu+kWl2UA==" }, "@rocket.chat/fuselage-ui-kit": { "version": "0.20.1", @@ -6002,9 +6016,9 @@ } }, "@rocket.chat/icons": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@rocket.chat/icons/-/icons-0.20.1.tgz", - "integrity": "sha512-izbaupM6jAc/VVIYVzcLB/y4E6TX6skDWIFr8SDzU+FFcIv/oI98qYjLBa4qOH5S0IBi75hrgwdY/wvKhcEvzw==" + "version": "0.6.3-dev.162", + "resolved": "https://registry.npmjs.org/@rocket.chat/icons/-/icons-0.6.3-dev.162.tgz", + "integrity": "sha512-7dgIIf6G7mL7LBjUdaKOvDbp2yV385M1TrFSK+aeqZXwF4a8IauYE698JCuQZVWL95LrSwofdCXzly9UWy5S8w==" }, "@rocket.chat/livechat": { "version": "1.7.6", @@ -20038,7 +20052,7 @@ }, "minimist": { "version": "0.0.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true @@ -20066,7 +20080,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -20239,7 +20253,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true diff --git a/package.json b/package.json index 3d1e52dc778b..9a7cf4e1f476 100644 --- a/package.json +++ b/package.json @@ -138,11 +138,12 @@ "@rocket.chat/apps-engine": "1.21.0-alpha.4235", "@rocket.chat/css-in-js": "^0.20.1", "@rocket.chat/emitter": "^0.20.1", - "@rocket.chat/fuselage": "^0.20.1", + "@rocket.chat/fuselage": "^0.6.3-dev.164", "@rocket.chat/fuselage-hooks": "^0.20.1", "@rocket.chat/fuselage-polyfills": "^0.20.1", + "@rocket.chat/fuselage-tokens": "^0.6.3-dev.161", "@rocket.chat/fuselage-ui-kit": "^0.20.1", - "@rocket.chat/icons": "^0.20.1", + "@rocket.chat/icons": "^0.6.3-dev.162", "@rocket.chat/mp3-encoder": "^0.20.1", "@rocket.chat/ui-kit": "^0.20.1", "@slack/client": "^4.12.0", diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index b819a65b4f52..42835d7d0b8b 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1178,6 +1178,7 @@ "Custom_Sound_Info": "Custom Sound Info", "Custom_Sound_Saved_Successfully": "Custom sound saved successfully", "Custom_Sounds": "Custom Sounds", + "Custom_Status": "Custom Status", "Custom_Translations": "Custom Translations", "Custom_Translations_Description": "Should be a valid JSON where keys are languages containing a dictionary of key and translations. Example:
{\n \"en\": {\n \"Channels\": \"Rooms\"\n },\n \"pt\": {\n \"Channels\": \"Salas\"\n }\n} ", "Custom_User_Status": "Custom User Status", From 2e84ba379c5c1c65ea33e2143ab7fa6b642fc1c8 Mon Sep 17 00:00:00 2001 From: tlskinneriv Date: Tue, 12 Jan 2021 13:42:13 +0000 Subject: [PATCH 03/98] [IMPROVE] Add extra SAML settings to update room subs and add private room subs (#19489) --- .../server/definition/ISAMLGlobalSettings.ts | 2 ++ app/meteor-accounts-saml/server/lib/SAML.ts | 26 ++++++++++++++----- app/meteor-accounts-saml/server/lib/Utils.ts | 4 +++ .../server/lib/settings.ts | 16 ++++++++++++ packages/rocketchat-i18n/i18n/en.i18n.json | 4 +++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts b/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts index b1b3f468ff51..126a300eea07 100644 --- a/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts +++ b/app/meteor-accounts-saml/server/definition/ISAMLGlobalSettings.ts @@ -8,4 +8,6 @@ export interface ISAMLGlobalSettings { roleAttributeSync: boolean; userDataFieldMap: string; usernameNormalize: string; + channelsAttributeUpdate: boolean; + includePrivateChannelsInUpdate: boolean; } diff --git a/app/meteor-accounts-saml/server/lib/SAML.ts b/app/meteor-accounts-saml/server/lib/SAML.ts index eed3cd628eeb..277084414a55 100644 --- a/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/app/meteor-accounts-saml/server/lib/SAML.ts @@ -72,7 +72,7 @@ export class SAML { } public static insertOrUpdateSAMLUser(userObject: ISAMLUser): {userId: string; token: string} { - const { roleAttributeSync, generateUsername, immutableProperty, nameOverwrite, mailOverwrite } = SAMLUtils.globalSettings; + const { roleAttributeSync, generateUsername, immutableProperty, nameOverwrite, mailOverwrite, channelsAttributeUpdate } = SAMLUtils.globalSettings; let customIdentifierMatch = false; let customIdentifierAttributeName: string | null = null; @@ -144,7 +144,7 @@ export class SAML { const userId = Accounts.insertUserDoc({}, newUser); user = Users.findOne(userId); - if (userObject.channels) { + if (userObject.channels && channelsAttributeUpdate !== true) { SAML.subscribeToSAMLChannels(userObject.channels, user); } } @@ -186,6 +186,10 @@ export class SAML { updateData.roles = globalRoles; } + if (userObject.channels && channelsAttributeUpdate === true) { + SAML.subscribeToSAMLChannels(userObject.channels, user); + } + Users.update({ _id: user._id, }, { @@ -444,6 +448,7 @@ export class SAML { } private static subscribeToSAMLChannels(channels: Array, user: IUser): void { + const { includePrivateChannelsInUpdate } = SAMLUtils.globalSettings; try { for (let roomName of channels) { roomName = roomName.trim(); @@ -452,15 +457,24 @@ export class SAML { } const room = Rooms.findOneByNameAndType(roomName, 'c', {}); - if (!room) { + const privRoom = Rooms.findOneByNameAndType(roomName, 'p', {}); + + if (privRoom && includePrivateChannelsInUpdate === true) { + addUserToRoom(privRoom._id, user); + continue; + } + + if (room) { + addUserToRoom(room._id, user); + continue; + } + + if (!room && !privRoom) { // If the user doesn't have an username yet, we can't create new rooms for them if (user.username) { createRoom('c', roomName, user.username); } - continue; } - - addUserToRoom(room._id, user); } } catch (err) { console.error(err); diff --git a/app/meteor-accounts-saml/server/lib/Utils.ts b/app/meteor-accounts-saml/server/lib/Utils.ts index d31421d45c2b..86aa5c7cf310 100644 --- a/app/meteor-accounts-saml/server/lib/Utils.ts +++ b/app/meteor-accounts-saml/server/lib/Utils.ts @@ -27,6 +27,8 @@ const globalSettings: ISAMLGlobalSettings = { roleAttributeSync: false, userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}', usernameNormalize: 'None', + channelsAttributeUpdate: false, + includePrivateChannelsInUpdate: false, }; export class SAMLUtils { @@ -73,6 +75,8 @@ export class SAMLUtils { globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite); globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite); globalSettings.roleAttributeSync = Boolean(samlConfigs.roleAttributeSync); + globalSettings.channelsAttributeUpdate = Boolean(samlConfigs.channelsAttributeUpdate); + globalSettings.includePrivateChannelsInUpdate = Boolean(samlConfigs.includePrivateChannelsInUpdate); if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') { globalSettings.immutableProperty = samlConfigs.immutableProperty; diff --git a/app/meteor-accounts-saml/server/lib/settings.ts b/app/meteor-accounts-saml/server/lib/settings.ts index 1b217b0a1546..b18b509c772d 100644 --- a/app/meteor-accounts-saml/server/lib/settings.ts +++ b/app/meteor-accounts-saml/server/lib/settings.ts @@ -57,6 +57,8 @@ export const getSamlConfigs = function(service: string): Record { logoutRequestTemplate: settings.get(`${ service }_LogoutRequest_template`), metadataCertificateTemplate: settings.get(`${ service }_MetadataCertificate_template`), metadataTemplate: settings.get(`${ service }_Metadata_template`), + channelsAttributeUpdate: settings.get(`${ service }_channels_update`), + includePrivateChannelsInUpdate: settings.get(`${ service }_include_private_channels_update`), }; }; @@ -271,6 +273,20 @@ export const addSettings = function(name: string): void { section: 'SAML_Section_3_Behavior', i18nLabel: 'SAML_Custom_Logout_Behaviour', }); + settings.add(`SAML_Custom_${ name }_channels_update`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_channels_update', + i18nDescription: 'SAML_Custom_channels_update_description', + }); + settings.add(`SAML_Custom_${ name }_include_private_channels_update`, false, { + type: 'boolean', + group: 'SAML', + section: 'SAML_Section_3_Behavior', + i18nLabel: 'SAML_Custom_include_private_channels_update', + i18nDescription: 'SAML_Custom_include_private_channels_update_description', + }); // Roles Settings settings.add(`SAML_Custom_${ name }_default_user_role`, 'user', { diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 42835d7d0b8b..0782891940ee 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3290,6 +3290,10 @@ "SAML_Section_4_Roles": "Roles", "SAML_Section_5_Mapping": "Mapping", "SAML_Section_6_Advanced": "Advanced", + "SAML_Custom_channels_update": "Update Room Subscriptions on Each Login", + "SAML_Custom_channels_update_description": "Ensures user is a member of all channels in SAML assertion on every login.", + "SAML_Custom_include_private_channels_update": "Include Private Rooms in Room Subscription", + "SAML_Custom_include_private_channels_update_description": "Adds user to any private rooms that exist in the SAML assertion.", "Saturday": "Saturday", "Save": "Save", "Save_changes": "Save changes", From 638045b17a58c49da42e458408e78c46f6436011 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 12 Jan 2021 11:13:11 -0300 Subject: [PATCH 04/98] [FIX] minWidth in FileIcon to prevent layout to broke (#19942) --- .../RoomFiles/components/FileItemIcon.js | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/client/views/room/contextualBar/RoomFiles/components/FileItemIcon.js b/client/views/room/contextualBar/RoomFiles/components/FileItemIcon.js index 3ea8c11d009d..1bc017b05dba 100644 --- a/client/views/room/contextualBar/RoomFiles/components/FileItemIcon.js +++ b/client/views/room/contextualBar/RoomFiles/components/FileItemIcon.js @@ -3,20 +3,29 @@ import React from 'react'; export default React.memo(({ type, }) => { + let icon; switch (type) { case 'application/vnd.ms-excel': - return ; + icon = ; + break; case 'application/msword': - return ; + icon = ; + break; case 'audio': - return ; + icon = ; + break; case 'video': - return ; + icon = ; + break; case 'application/pdf': - return ; + icon = ; + break; case 'application/x-zip-compressed': - return ; + icon = ; + break; default: - return ; + icon = ; } + + return {icon}; }); From 7eb376fadc2e5ca60aa392eccdc551b4a415b2e1 Mon Sep 17 00:00:00 2001 From: Tiago Evangelista Pinto Date: Tue, 12 Jan 2021 11:15:07 -0300 Subject: [PATCH 05/98] [FIX] Normalize messages for users in endpoint chat.getStarredMessages (#19962) Co-authored-by: Tasso Evangelista --- app/api/server/v1/chat.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js index 447c10bd5d57..a4ed9765ebe4 100644 --- a/app/api/server/v1/chat.js +++ b/app/api/server/v1/chat.js @@ -655,6 +655,9 @@ API.v1.addRoute('chat.getStarredMessages', { authRequired: true }, { sort, }, })); + + messages.messages = normalizeMessagesForUser(messages.messages, this.userId); + return API.v1.success(messages); }, }); From 839583e4feaa77ba30bc2eab9a30e1f75272716f Mon Sep 17 00:00:00 2001 From: yash-rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Tue, 12 Jan 2021 19:46:58 +0530 Subject: [PATCH 06/98] [FIX] Status circle in profile section (#20016) --- client/components/UserStatusMenu.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/components/UserStatusMenu.js b/client/components/UserStatusMenu.js index b1ddc6fcd720..a8acf1f15967 100644 --- a/client/components/UserStatusMenu.js +++ b/client/components/UserStatusMenu.js @@ -63,6 +63,7 @@ const UserStatusMenu = ({ - - + + {suggestions && }
From 14946314ef23ea3316145079f5ef2f465a418762 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Tue, 12 Jan 2021 13:45:10 -0300 Subject: [PATCH 12/98] [NEW] Server Info page (#19517) --- app/statistics/server/lib/statistics.js | 4 +- client/components/Card/Card.js | 35 ++++ client/components/Card/Card.stories.js | 84 +++++++++ client/components/DotLeader.stories.js | 15 ++ client/components/DotLeader.tsx | 20 ++ .../admin/info/BuildEnvironmentSection.js | 25 --- .../info/BuildEnvironmentSection.stories.js | 24 --- client/views/admin/info/CommitSection.js | 24 --- .../views/admin/info/CommitSection.stories.js | 24 --- client/views/admin/info/DeploymentCard.js | 72 ++++++++ .../admin/info/InformationPage.stories.js | 173 ------------------ client/views/admin/info/InformationRoute.js | 5 +- client/views/admin/info/InstancesCard.js | 37 ++++ client/views/admin/info/InstancesModal.js | 47 +++++ client/views/admin/info/InstancesSection.js | 33 ---- .../admin/info/InstancesSection.stories.js | 32 ---- client/views/admin/info/LicenseCard.js | 98 ++++++++++ ...formationPage.js => NewInformationPage.js} | 34 ++-- .../views/admin/info/OfflineLicenseModal.js | 106 +++++++++++ .../admin/info/OfflineLicenseModal.stories.js | 10 + client/views/admin/info/PushCard.js | 38 ++++ client/views/admin/info/RocketChatSection.js | 36 ---- .../admin/info/RocketChatSection.stories.js | 36 ---- .../admin/info/RuntimeEnvironmentSection.js | 45 ----- .../info/RuntimeEnvironmentSection.stories.js | 34 ---- client/views/admin/info/UsageCard.js | 160 ++++++++++++++++ client/views/admin/info/UsagePieGraph.js | 52 ++++++ client/views/admin/info/UsageSection.js | 54 ------ .../views/admin/info/UsageSection.stories.js | 54 ------ packages/rocketchat-i18n/i18n/en.i18n.json | 19 +- 30 files changed, 819 insertions(+), 611 deletions(-) create mode 100644 client/components/Card/Card.js create mode 100644 client/components/Card/Card.stories.js create mode 100644 client/components/DotLeader.stories.js create mode 100644 client/components/DotLeader.tsx delete mode 100644 client/views/admin/info/BuildEnvironmentSection.js delete mode 100644 client/views/admin/info/BuildEnvironmentSection.stories.js delete mode 100644 client/views/admin/info/CommitSection.js delete mode 100644 client/views/admin/info/CommitSection.stories.js create mode 100644 client/views/admin/info/DeploymentCard.js delete mode 100644 client/views/admin/info/InformationPage.stories.js create mode 100644 client/views/admin/info/InstancesCard.js create mode 100644 client/views/admin/info/InstancesModal.js delete mode 100644 client/views/admin/info/InstancesSection.js delete mode 100644 client/views/admin/info/InstancesSection.stories.js create mode 100644 client/views/admin/info/LicenseCard.js rename client/views/admin/info/{InformationPage.js => NewInformationPage.js} (61%) create mode 100644 client/views/admin/info/OfflineLicenseModal.js create mode 100644 client/views/admin/info/OfflineLicenseModal.stories.js create mode 100644 client/views/admin/info/PushCard.js delete mode 100644 client/views/admin/info/RocketChatSection.js delete mode 100644 client/views/admin/info/RocketChatSection.stories.js delete mode 100644 client/views/admin/info/RuntimeEnvironmentSection.js delete mode 100644 client/views/admin/info/RuntimeEnvironmentSection.stories.js create mode 100644 client/views/admin/info/UsageCard.js create mode 100644 client/views/admin/info/UsagePieGraph.js delete mode 100644 client/views/admin/info/UsageSection.js delete mode 100644 client/views/admin/info/UsageSection.stories.js diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index f92dbe7ecf60..3189328ce56e 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -71,8 +71,10 @@ export const statistics = { statistics.appUsers = Users.find({ type: 'app' }).count(); statistics.onlineUsers = Meteor.users.find({ statusConnection: 'online' }).count(); statistics.awayUsers = Meteor.users.find({ statusConnection: 'away' }).count(); + // TODO: Get statuses from the `status` property. + statistics.busyUsers = Meteor.users.find({ statusConnection: 'busy' }).count(); statistics.totalConnectedUsers = statistics.onlineUsers + statistics.awayUsers; - statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers; + statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers - statistics.busyUsers; // Room statistics statistics.totalRooms = Rooms.find().count(); diff --git a/client/components/Card/Card.js b/client/components/Card/Card.js new file mode 100644 index 000000000000..01200cfc13c3 --- /dev/null +++ b/client/components/Card/Card.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { Box, Divider } from '@rocket.chat/fuselage'; + +export const DOUBLE_COLUMN_CARD_WIDTH = 552; + +const Title = ({ children }) => {children}; + +const Footer = ({ children }) => {children}; + +const Body = ({ children, flexDirection = 'row' }) => {children}; + +const Col = ({ children }) => {children}; + +const ColSection = ({ children }) => {children}; + +const ColTitle = ({ children }) => {children}; + +const CardDivider = () => ; + +const Card = ({ children, ...props }) => {children}; + +Object.assign(Col, { + Title: ColTitle, + Section: ColSection, +}); + +Object.assign(Card, { + Title, + Body, + Col, + Footer, + Divider: CardDivider, +}); + +export default Card; diff --git a/client/components/Card/Card.stories.js b/client/components/Card/Card.stories.js new file mode 100644 index 000000000000..c950beba69e3 --- /dev/null +++ b/client/components/Card/Card.stories.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; + +import Card from './Card'; + +export default { + title: 'components/basic/Card', + component: Card, +}; + +export const Single = () => + + A card + + + + A Section +
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
+ + Another Section +
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
+
+
+ + + + + +
+
; + +export const Double = () => + + A card + + + + A Section +
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
+ + Another Section +
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
+
+ + + + A Section +
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
+ + Another Section +
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
A bunch of stuff
+
+
+
+ + + + + +
+
; diff --git a/client/components/DotLeader.stories.js b/client/components/DotLeader.stories.js new file mode 100644 index 000000000000..229b2dee6933 --- /dev/null +++ b/client/components/DotLeader.stories.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +import DotLeader from './DotLeader'; + +export default { + title: 'components/basic/DotLeader', + component: DotLeader, +}; + +export const Default = () => + Label + + 12345 +; diff --git a/client/components/DotLeader.tsx b/client/components/DotLeader.tsx new file mode 100644 index 000000000000..98815e27ec86 --- /dev/null +++ b/client/components/DotLeader.tsx @@ -0,0 +1,20 @@ +import React, { FC, CSSProperties } from 'react'; +import { Box } from '@rocket.chat/fuselage'; + + +type DotLeaderProps = { + color: CSSProperties['borderColor']; + dotSize: CSSProperties['borderBlockEndWidth']; +} + +const DotLeader: FC = ({ color = 'neutral-300', dotSize = 'x2' }) => ; + +export default DotLeader; diff --git a/client/views/admin/info/BuildEnvironmentSection.js b/client/views/admin/info/BuildEnvironmentSection.js deleted file mode 100644 index d4c83e690da2..000000000000 --- a/client/views/admin/info/BuildEnvironmentSection.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -import Subtitle from '../../../components/Subtitle'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; -import DescriptionList from './DescriptionList'; - -const BuildEnvironmentSection = React.memo(function BuildEnvironmentSection({ info }) { - const t = useTranslation(); - const formatDateAndTime = useFormatDateAndTime(); - const build = info && (info.compile || info.build); - - return {t('Build_Environment')}} - > - {build.platform} - {build.arch} - {build.osRelease} - {build.nodeVersion} - {formatDateAndTime(build.date)} - ; -}); - -export default BuildEnvironmentSection; diff --git a/client/views/admin/info/BuildEnvironmentSection.stories.js b/client/views/admin/info/BuildEnvironmentSection.stories.js deleted file mode 100644 index 2746fc634a04..000000000000 --- a/client/views/admin/info/BuildEnvironmentSection.stories.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import { dummyDate } from '../../../../.storybook/helpers'; -import BuildEnvironmentSection from './BuildEnvironmentSection'; - -export default { - title: 'admin/info/BuildEnvironmentSection', - component: BuildEnvironmentSection, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const info = { - compile: { - platform: 'info.compile.platform', - arch: 'info.compile.arch', - osRelease: 'info.compile.osRelease', - nodeVersion: 'info.compile.nodeVersion', - date: dummyDate, - }, -}; - -export const _default = () => ; diff --git a/client/views/admin/info/CommitSection.js b/client/views/admin/info/CommitSection.js deleted file mode 100644 index 29fc46783a1b..000000000000 --- a/client/views/admin/info/CommitSection.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import Subtitle from '../../../components/Subtitle'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import DescriptionList from './DescriptionList'; - -const CommitSection = React.memo(function CommitSection({ info }) { - const t = useTranslation(); - const { commit = {} } = info; - - return {t('Commit')}} - > - {commit.hash} - {commit.date} - {commit.branch} - {commit.tag} - {commit.author} - {commit.subject} - ; -}); - -export default CommitSection; diff --git a/client/views/admin/info/CommitSection.stories.js b/client/views/admin/info/CommitSection.stories.js deleted file mode 100644 index d8b0de4df48d..000000000000 --- a/client/views/admin/info/CommitSection.stories.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import CommitSection from './CommitSection'; - -export default { - title: 'admin/info/CommitSection', - component: CommitSection, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const info = { - commit: { - hash: 'info.commit.hash', - date: 'info.commit.date', - branch: 'info.commit.branch', - tag: 'info.commit.tag', - author: 'info.commit.author', - subject: 'info.commit.subject', - }, -}; - -export const _default = () => ; diff --git a/client/views/admin/info/DeploymentCard.js b/client/views/admin/info/DeploymentCard.js new file mode 100644 index 000000000000..0d19ea44a639 --- /dev/null +++ b/client/views/admin/info/DeploymentCard.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { Skeleton, ButtonGroup, Button } from '@rocket.chat/fuselage'; + +import Card from '../../../components/Card/Card'; +import InstancesModal from './InstancesModal'; +import { useSetModal } from '../../../contexts/ModalContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; + +const DeploymentCard = React.memo(function DeploymentCard({ info, statistics, instances, isLoading }) { + const t = useTranslation(); + const formatDateAndTime = useFormatDateAndTime(); + const setModal = useSetModal(); + + const { commit = {} } = info; + + const s = (fn) => (isLoading ? : fn()); + + const appsEngineVersion = info && info.marketplaceApiVersion; + + const handleInstancesModal = useMutableCallback(() => { setModal( setModal()}/>); }); + + return + {t('Deployment')} + + + + {t('Version')} + {s(() => statistics.version)} + + + {t('Deployment_ID')} + {s(() => statistics.uniqueId)} + + {appsEngineVersion && + {t('Apps_Engine_Version')} + {appsEngineVersion} + } + + {t('Node_version')} + {s(() => statistics.process.nodeVersion)} + + + {t('DB_Migration')} + {s(() => `${ statistics.migration.version } (${ formatDateAndTime(statistics.migration.lockedAt) }`)} + + + {t('MongoDB')} + {s(() => `${ statistics.mongoVersion } / ${ statistics.mongoStorageEngine } (oplog ${ statistics.oplogEnabled ? t('Enabled') : t('Disabled') })`)} + + + {t('Commit_details')} + {t('HEAD')}: ({s(() => commit.hash.slice(0, 9))})
+ {t('Branch')}: {s(() => commit.branch)} +
+ + {t('PID')} + {s(() => statistics.process.pid)} + +
+
+ + {!!instances.length && + + + + } +
; +}); + +export default DeploymentCard; diff --git a/client/views/admin/info/InformationPage.stories.js b/client/views/admin/info/InformationPage.stories.js deleted file mode 100644 index f85776c51a39..000000000000 --- a/client/views/admin/info/InformationPage.stories.js +++ /dev/null @@ -1,173 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import React from 'react'; - -import { dummyDate } from '../../../../.storybook/helpers'; -import InformationPage from './InformationPage'; - -export default { - title: 'admin/info/InformationPage', - component: InformationPage, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const info = { - marketplaceApiVersion: 'info.marketplaceApiVersion', - commit: { - hash: 'info.commit.hash', - date: 'info.commit.date', - branch: 'info.commit.branch', - tag: 'info.commit.tag', - author: 'info.commit.author', - subject: 'info.commit.subject', - }, - compile: { - platform: 'info.compile.platform', - arch: 'info.compile.arch', - osRelease: 'info.compile.osRelease', - nodeVersion: 'info.compile.nodeVersion', - date: dummyDate, - }, -}; - -const statistics = { - version: 'statistics.version', - migration: { - version: 'statistics.migration.version', - lockedAt: dummyDate, - }, - installedAt: dummyDate, - process: { - nodeVersion: 'statistics.process.nodeVersion', - uptime: 10 * 24 * 60 * 60, - pid: 'statistics.process.pid', - }, - uniqueId: 'statistics.uniqueId', - instanceCount: 1, - oplogEnabled: true, - os: { - type: 'statistics.os.type', - platform: 'statistics.os.platform', - arch: 'statistics.os.arch', - release: 'statistics.os.release', - uptime: 10 * 24 * 60 * 60, - loadavg: [1.1, 1.5, 1.15], - totalmem: 1024, - freemem: 1024, - cpus: [{}], - }, - mongoVersion: 'statistics.mongoVersion', - mongoStorageEngine: 'statistics.mongoStorageEngine', - totalUsers: 'statistics.totalUsers', - nonActiveUsers: 'nonActiveUsers', - activeUsers: 'statistics.activeUsers', - totalConnectedUsers: 'statistics.totalConnectedUsers', - onlineUsers: 'statistics.onlineUsers', - awayUsers: 'statistics.awayUsers', - offlineUsers: 'statistics.offlineUsers', - totalRooms: 'statistics.totalRooms', - totalChannels: 'statistics.totalChannels', - totalPrivateGroups: 'statistics.totalPrivateGroups', - totalDirect: 'statistics.totalDirect', - totalLivechat: 'statistics.totalLivechat', - totalDiscussions: 'statistics.totalDiscussions', - totalThreads: 'statistics.totalThreads', - totalMessages: 'statistics.totalMessages', - totalChannelMessages: 'statistics.totalChannelMessages', - totalPrivateGroupMessages: 'statistics.totalPrivateGroupMessages', - totalDirectMessages: 'statistics.totalDirectMessages', - totalLivechatMessages: 'statistics.totalLivechatMessages', - uploadsTotal: 'statistics.uploadsTotal', - uploadsTotalSize: 1024, - integrations: { - totalIntegrations: 'statistics.integrations.totalIntegrations', - totalIncoming: 'statistics.integrations.totalIncoming', - totalIncomingActive: 'statistics.integrations.totalIncomingActive', - totalOutgoing: 'statistics.integrations.totalOutgoing', - totalOutgoingActive: 'statistics.integrations.totalOutgoingActive', - totalWithScriptEnabled: 'statistics.integrations.totalWithScriptEnabled', - }, -}; - -const exampleInstance = { - address: 'instances[].address', - broadcastAuth: 'instances[].broadcastAuth', - currentStatus: { - connected: 'instances[].currentStatus.connected', - retryCount: 'instances[].currentStatus.retryCount', - status: 'instances[].currentStatus.status', - }, - instanceRecord: { - _id: 'instances[].instanceRecord._id', - pid: 'instances[].instanceRecord.pid', - _createdAt: dummyDate, - _updatedAt: dummyDate, - }, -}; - -export const _default = () => - ; - -export const withoutCanViewStatisticsPermission = () => - ; - -export const loading = () => - ; - -export const withStatistics = () => - ; - -export const withOneInstance = () => - ; - -export const withTwoInstances = () => - ; - -export const withTwoInstancesAndDisabledOplog = () => - ; diff --git a/client/views/admin/info/InformationRoute.js b/client/views/admin/info/InformationRoute.js index b5993f0723f8..bcc2f565ddb4 100644 --- a/client/views/admin/info/InformationRoute.js +++ b/client/views/admin/info/InformationRoute.js @@ -4,7 +4,7 @@ import { usePermission } from '../../../contexts/AuthorizationContext'; import NotAuthorizedPage from '../../../components/NotAuthorizedPage'; import { useMethod, useServerInformation, useEndpoint } from '../../../contexts/ServerContext'; import { downloadJsonAs } from '../../../lib/download'; -import InformationPage from './InformationPage'; +import NewInformationPage from './NewInformationPage'; const InformationRoute = React.memo(function InformationRoute() { const canViewStatistics = usePermission('view-statistics'); @@ -49,6 +49,7 @@ const InformationRoute = React.memo(function InformationRoute() { const info = useServerInformation(); + const handleClickRefreshButton = () => { if (isLoading) { return; @@ -65,7 +66,7 @@ const InformationRoute = React.memo(function InformationRoute() { }; if (canViewStatistics) { - return { + const t = useTranslation(); + + const setModal = useSetModal(); + + const handleModal = useMutableCallback(() => { setModal( setModal()}/>); }); + + return + {t('Instances')} + + + + + + + + + + + + + + + ; +}; + +export default InstancesCard; diff --git a/client/views/admin/info/InstancesModal.js b/client/views/admin/info/InstancesModal.js new file mode 100644 index 000000000000..d5791de65cef --- /dev/null +++ b/client/views/admin/info/InstancesModal.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { Modal, ButtonGroup, Button, Accordion } from '@rocket.chat/fuselage'; + +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; +import DescriptionList from './DescriptionList'; + +const InstancesModal = ({ instances = [], onClose }) => { + const t = useTranslation(); + + const formatDateAndTime = useFormatDateAndTime(); + + return + + {t('Instances')} + + + + + { + instances.map(({ address, broadcastAuth, currentStatus, instanceRecord }) => + + + {address} + {broadcastAuth ? 'true' : 'false'} + {t('Current_Status')} > {t('Connected')}}>{currentStatus.connected ? 'true' : 'false'} + {t('Current_Status')} > {t('Retry_Count')}}>{currentStatus.retryCount} + {t('Current_Status')} > {t('Status')}}>{currentStatus.status} + {t('Instance_Record')} > {t('ID')}}>{instanceRecord._id} + {t('Instance_Record')} > {t('PID')}}>{instanceRecord.pid} + {t('Instance_Record')} > {t('Created_at')}}>{formatDateAndTime(instanceRecord._createdAt)} + {t('Instance_Record')} > {t('Updated_at')}}>{formatDateAndTime(instanceRecord._updatedAt)} + + , + ) + } + + + + + + + + ; +}; + +export default InstancesModal; diff --git a/client/views/admin/info/InstancesSection.js b/client/views/admin/info/InstancesSection.js deleted file mode 100644 index 5990ec940fc4..000000000000 --- a/client/views/admin/info/InstancesSection.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import Subtitle from '../../../components/Subtitle'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; -import DescriptionList from './DescriptionList'; - -function InstancesSection({ instances }) { - const t = useTranslation(); - const formatDateAndTime = useFormatDateAndTime(); - - if (!instances || !instances.length) { - return null; - } - - return <> - {instances.map(({ address, broadcastAuth, currentStatus, instanceRecord }, i) => - {t('Broadcast_Connected_Instances')}}> - {address} - {broadcastAuth ? 'true' : 'false'} - {t('Current_Status')} > {t('Connected')}}>{currentStatus.connected ? 'true' : 'false'} - {t('Current_Status')} > {t('Retry_Count')}}>{currentStatus.retryCount} - {t('Current_Status')} > {t('Status')}}>{currentStatus.status} - {t('Instance_Record')} > {t('ID')}}>{instanceRecord._id} - {t('Instance_Record')} > {t('PID')}}>{instanceRecord.pid} - {t('Instance_Record')} > {t('Created_at')}}>{formatDateAndTime(instanceRecord._createdAt)} - {t('Instance_Record')} > {t('Updated_at')}}>{formatDateAndTime(instanceRecord._updatedAt)} - , - )} - ; -} - -export default InstancesSection; diff --git a/client/views/admin/info/InstancesSection.stories.js b/client/views/admin/info/InstancesSection.stories.js deleted file mode 100644 index 3b3288dfeff6..000000000000 --- a/client/views/admin/info/InstancesSection.stories.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; - -import { dummyDate } from '../../../../.storybook/helpers'; -import InstancesSection from './InstancesSection'; - -export default { - title: 'admin/info/InstancesSection', - component: InstancesSection, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const instances = [ - { - address: 'instances[].address', - broadcastAuth: 'instances[].broadcastAuth', - currentStatus: { - connected: 'instances[].currentStatus.connected', - retryCount: 'instances[].currentStatus.retryCount', - status: 'instances[].currentStatus.status', - }, - instanceRecord: { - _id: 'instances[].instanceRecord._id', - pid: 'instances[].instanceRecord.pid', - _createdAt: dummyDate, - _updatedAt: dummyDate, - }, - }, -]; - -export const _default = () => ; diff --git a/client/views/admin/info/LicenseCard.js b/client/views/admin/info/LicenseCard.js new file mode 100644 index 000000000000..12feeea07325 --- /dev/null +++ b/client/views/admin/info/LicenseCard.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { Box, Icon, ButtonGroup, Button, Skeleton, Margins } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import PlanTag from '../../../components/PlanTag'; +import Card from '../../../components/Card/Card'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useEndpointData } from '../../../hooks/useEndpointData'; +import { AsyncStatePhase } from '../../../hooks/useAsyncState'; +import { useSetting } from '../../../contexts/SettingsContext'; +import { useSetModal } from '../../../contexts/ModalContext'; +import UsagePieGraph from './UsagePieGraph'; +import OfflineLicenseModal from './OfflineLicenseModal'; + +const Feature = ({ label, enabled }) => + + {label} +; + +const LicenseCard = ({ statistics, isLoading }) => { + const t = useTranslation(); + + const setModal = useSetModal(); + + const currentLicense = useSetting('Enterprise_License'); + const licenseStatus = useSetting('Enterprise_License_Status'); + + const isAirGapped = true; + + const { value, phase, error } = useEndpointData('licenses.get'); + const endpointLoading = phase === AsyncStatePhase.LOADING; + + const { maxActiveUsers = 0, modules = [] } = endpointLoading || error ? {} : value.licenses[0]; + + const hasEngagement = modules.includes('engagement-dashboard'); + const hasOmnichannel = modules.includes('livechat-enterprise'); + const hasAuditing = modules.includes('auditing'); + const hasCannedResponses = modules.includes('canned-responses'); + + const handleApplyLicense = useMutableCallback(() => setModal( { setModal(); }} license={currentLicense} licenseStatus={licenseStatus}/>)); + + return + {t('License')} + + + + + + + {t('Features')} + + { + endpointLoading + ? <> + + + + + + : <> + + + + + + } + + + + {t('Usage')} + + { + isLoading + ? + : + } + + + + + + + {isAirGapped + ? + : + } + + + ; +}; + +export default LicenseCard; diff --git a/client/views/admin/info/InformationPage.js b/client/views/admin/info/NewInformationPage.js similarity index 61% rename from client/views/admin/info/InformationPage.js rename to client/views/admin/info/NewInformationPage.js index 99a93231c58f..730c35b5a3a7 100644 --- a/client/views/admin/info/InformationPage.js +++ b/client/views/admin/info/NewInformationPage.js @@ -1,14 +1,15 @@ -import { Box, Button, ButtonGroup, Callout, Icon } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Callout, Icon, Margins } from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; import React from 'react'; import Page from '../../../components/Page'; +import DeploymentCard from './DeploymentCard'; +import UsageCard from './UsageCard'; +import LicenseCard from './LicenseCard'; +// import InstancesCard from './InstancesCard'; +// import PushCard from './PushCard'; +import { DOUBLE_COLUMN_CARD_WIDTH } from '../../../components/Card/Card'; import { useTranslation } from '../../../contexts/TranslationContext'; -import RocketChatSection from './RocketChatSection'; -import CommitSection from './CommitSection'; -import RuntimeEnvironmentSection from './RuntimeEnvironmentSection'; -import BuildEnvironmentSection from './BuildEnvironmentSection'; -import UsageSection from './UsageSection'; -import InstancesSection from './InstancesSection'; const InformationPage = React.memo(function InformationPage({ canViewStatistics, @@ -21,6 +22,10 @@ const InformationPage = React.memo(function InformationPage({ }) { const t = useTranslation(); + const { ref, contentBoxSize: { inlineSize = DOUBLE_COLUMN_CARD_WIDTH } = {} } = useResizeObserver(); + + const isSmall = inlineSize < DOUBLE_COLUMN_CARD_WIDTH; + if (!info) { return null; } @@ -63,12 +68,15 @@ const InformationPage = React.memo(function InformationPage({
} - {canViewStatistics && } - - {canViewStatistics && } - - {canViewStatistics && } - + + + + + + {/* {!!instances.length && } */} + {/* */} + + ; diff --git a/client/views/admin/info/OfflineLicenseModal.js b/client/views/admin/info/OfflineLicenseModal.js new file mode 100644 index 000000000000..c9a1dd373259 --- /dev/null +++ b/client/views/admin/info/OfflineLicenseModal.js @@ -0,0 +1,106 @@ +import { Modal, Box, ButtonGroup, Button, Scrollable, Callout, Margins, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { useState } from 'react'; + +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; +import { useEndpointActionExperimental } from '../../../hooks/useEndpointAction'; + +const OfflineLicenseModal = ({ onClose, license, licenseStatus, ...props }) => { + const t = useTranslation(); + + const dispatchToastMessage = useToastMessageDispatch(); + + const [newLicense, setNewLicense] = useState(license); + const [isUpdating, setIsUpdating] = useState(false); + const [status, setStatus] = useState(licenseStatus); + const [lastSetLicense, setLastSetLicense] = useState(license); + + const handleNewLicense = (e) => { + setNewLicense(e.currentTarget.value); + }; + + const hasChanges = lastSetLicense !== newLicense; + + const addLicense = useEndpointActionExperimental('POST', 'licenses.add', t('Cloud_License_applied_successfully')); + + const handlePaste = useMutableCallback(async () => { + try { + const text = await navigator.clipboard.readText(); + setNewLicense(text); + } catch (error) { + dispatchToastMessage({ type: 'error', message: `${ t('Paste_error') }: ${ error }` }); + } + }); + + const handleApplyLicense = useMutableCallback(async () => { + setIsUpdating(true); + setLastSetLicense(newLicense); + const data = await addLicense({ license: newLicense }); + if (data.success) { + onClose(); + return; + } + setIsUpdating(false); + setStatus('invalid'); + }); + + return + + {t('Cloud_Apply_Offline_License')} + + + + +

{t('Cloud_register_offline_finish_helper')}

+
+ + + + + + + + + + + {status === 'invalid' && {t('Cloud_Invalid_license')}} +
+ + + + + +
; +}; + +export default OfflineLicenseModal; diff --git a/client/views/admin/info/OfflineLicenseModal.stories.js b/client/views/admin/info/OfflineLicenseModal.stories.js new file mode 100644 index 000000000000..90aeaeabd38c --- /dev/null +++ b/client/views/admin/info/OfflineLicenseModal.stories.js @@ -0,0 +1,10 @@ +import React from 'react'; + +import OfflineLicenseModal from './OfflineLicenseModal'; + +export default { + title: 'admin/info/OfflineLicenseModal', + component: OfflineLicenseModal, +}; + +export const _default = () => {}} />; diff --git a/client/views/admin/info/PushCard.js b/client/views/admin/info/PushCard.js new file mode 100644 index 000000000000..fb0345c5d2d6 --- /dev/null +++ b/client/views/admin/info/PushCard.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, ButtonGroup, Button } from '@rocket.chat/fuselage'; +// import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import Card from '../../../components/Card/Card'; +import { useTranslation } from '../../../contexts/TranslationContext'; +// import { useSetModal } from '../../contexts/ModalContext'; +import UsagePieGraph from './UsagePieGraph'; +// import PlanTag from '../../components/basic/PlanTag'; +// import { useSetting } from '../../contexts/SettingsContext'; +// import { useHasLicense } from '../../../ee/client/hooks/useHasLicense'; +// import OfflineLicenseModal from './OfflineLicenseModal'; + +const PushCard = () => { + const t = useTranslation(); + + // const setModal = useSetModal(); + + return + {t('Push_Notifications')} + + + + + + + + + + + + + + + ; +}; + +export default PushCard; diff --git a/client/views/admin/info/RocketChatSection.js b/client/views/admin/info/RocketChatSection.js deleted file mode 100644 index ee53c2916e34..000000000000 --- a/client/views/admin/info/RocketChatSection.js +++ /dev/null @@ -1,36 +0,0 @@ -import { Skeleton } from '@rocket.chat/fuselage'; -import React from 'react'; - -import Subtitle from '../../../components/Subtitle'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; -import { useFormatDuration } from '../../../hooks/useFormatDuration'; -import DescriptionList from './DescriptionList'; - -const RocketChatSection = React.memo(function RocketChatSection({ info, statistics, isLoading }) { - const t = useTranslation(); - const formatDateAndTime = useFormatDateAndTime(); - const formatDuration = useFormatDuration(); - - const s = (fn) => (isLoading ? : fn()); - - const appsEngineVersion = info && info.marketplaceApiVersion; - - return {t('Rocket.Chat')}} - > - {s(() => statistics.version)} - {appsEngineVersion && {appsEngineVersion}} - {s(() => statistics.migration.version)} - {s(() => formatDateAndTime(statistics.migration.lockedAt))} - {s(() => formatDateAndTime(statistics.installedAt))} - {s(() => formatDuration(statistics.process.uptime))} - {s(() => statistics.uniqueId)} - {s(() => statistics.process.pid)} - {s(() => statistics.instanceCount)} - {s(() => (statistics.oplogEnabled ? t('Enabled') : t('Disabled')))} - ; -}); - -export default RocketChatSection; diff --git a/client/views/admin/info/RocketChatSection.stories.js b/client/views/admin/info/RocketChatSection.stories.js deleted file mode 100644 index aa0ec5d212e0..000000000000 --- a/client/views/admin/info/RocketChatSection.stories.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; - -import { dummyDate } from '../../../../.storybook/helpers'; -import RocketChatSection from './RocketChatSection'; - -export default { - title: 'admin/info/RocketChatSection', - component: RocketChatSection, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const info = { - marketplaceApiVersion: 'info.marketplaceApiVersion', -}; - -const statistics = { - version: 'statistics.version', - migration: { - version: 'statistics.migration.version', - lockedAt: dummyDate, - }, - installedAt: dummyDate, - process: { - uptime: 10 * 24 * 60 * 60, - pid: 'statistics.process.pid', - }, - uniqueId: 'statistics.uniqueId', - instanceCount: 1, - oplogEnabled: true, -}; - -export const _default = () => ; - -export const loading = () => ; diff --git a/client/views/admin/info/RuntimeEnvironmentSection.js b/client/views/admin/info/RuntimeEnvironmentSection.js deleted file mode 100644 index 26934b2f4a12..000000000000 --- a/client/views/admin/info/RuntimeEnvironmentSection.js +++ /dev/null @@ -1,45 +0,0 @@ -import { Skeleton } from '@rocket.chat/fuselage'; -import React from 'react'; -import s from 'underscore.string'; - -import Subtitle from '../../../components/Subtitle'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import { useFormatMemorySize } from '../../../hooks/useFormatMemorySize'; -import { useFormatDuration } from '../../../hooks/useFormatDuration'; -import DescriptionList from './DescriptionList'; - -const formatCPULoad = (load) => { - if (!load) { - return null; - } - - const [oneMinute, fiveMinutes, fifteenMinutes] = load; - return `${ s.numberFormat(oneMinute, 2) }, ${ s.numberFormat(fiveMinutes, 2) }, ${ s.numberFormat(fifteenMinutes, 2) }`; -}; - -const RuntimeEnvironmentSection = React.memo(function RuntimeEnvironmentSection({ statistics, isLoading }) { - const s = (fn) => (isLoading ? : fn()); - const t = useTranslation(); - const formatMemorySize = useFormatMemorySize(); - const formatDuration = useFormatDuration(); - - return {t('Runtime_Environment')}} - > - {s(() => statistics.os.type)} - {s(() => statistics.os.platform)} - {s(() => statistics.os.arch)} - {s(() => statistics.os.release)} - {s(() => statistics.process.nodeVersion)} - {s(() => statistics.mongoVersion)} - {s(() => statistics.mongoStorageEngine)} - {s(() => formatDuration(statistics.os.uptime))} - {s(() => formatCPULoad(statistics.os.loadavg))} - {s(() => formatMemorySize(statistics.os.totalmem))} - {s(() => formatMemorySize(statistics.os.freemem))} - {s(() => statistics.os.cpus.length)} - ; -}); - -export default RuntimeEnvironmentSection; diff --git a/client/views/admin/info/RuntimeEnvironmentSection.stories.js b/client/views/admin/info/RuntimeEnvironmentSection.stories.js deleted file mode 100644 index 641644c4d6df..000000000000 --- a/client/views/admin/info/RuntimeEnvironmentSection.stories.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import RuntimeEnvironmentSection from './RuntimeEnvironmentSection'; - -export default { - title: 'admin/info/RuntimeEnvironmentSection', - component: RuntimeEnvironmentSection, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const statistics = { - os: { - type: 'statistics.os.type', - platform: 'statistics.os.platform', - arch: 'statistics.os.arch', - release: 'statistics.os.release', - uptime: 10 * 24 * 60 * 60, - loadavg: [1.1, 1.5, 1.15], - totalmem: 1024, - freemem: 1024, - cpus: [{}], - }, - process: { - nodeVersion: 'statistics.process.nodeVersion', - }, - mongoVersion: 'statistics.mongoVersion', - mongoStorageEngine: 'statistics.mongoStorageEngine', -}; - -export const _default = () => ; - -export const loading = () => ; diff --git a/client/views/admin/info/UsageCard.js b/client/views/admin/info/UsageCard.js new file mode 100644 index 000000000000..d9c494c1f806 --- /dev/null +++ b/client/views/admin/info/UsageCard.js @@ -0,0 +1,160 @@ +import React from 'react'; +import { Box, Skeleton, Icon, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import DotLeader from '../../../components/DotLeader'; +import Card from '../../../components/Card/Card'; +import { UserStatus } from '../../../components/UserStatus'; +import { useFormatMemorySize } from '../../../hooks/useFormatMemorySize'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { useRoute } from '../../../contexts/RouterContext'; +import { useHasLicense } from '../../../../ee/client/hooks/useHasLicense'; + +const TextSeparator = ({ label, value }) => + {label} + + {value} +; + +const UsageCard = React.memo(function UsageCard({ statistics, isLoading, vertical }) { + const s = (fn) => (isLoading ? : fn()); + const t = useTranslation(); + const formatMemorySize = useFormatMemorySize(); + + const router = useRoute('engagement-dashboard'); + + const handleEngagement = useMutableCallback(() => { + router.push(); + }); + + const canViewEngagement = useHasLicense('engagement-dashboard'); + + return + {t('Usage')} + + + + {t('Users')} + {t('Total')}} + value={s(() => statistics.totalUsers)} + /> + {t('Online')}} + value={s(() => statistics.onlineUsers)} + /> + {t('Busy')}} + value={s(() => statistics.busyUsers)} + /> + {t('Away')}} + value={s(() => statistics.awayUsers)} + /> + {t('Offline')}} + value={s(() => statistics.offlineUsers)} + /> + + + {t('Types_and_Distribution')} + statistics.totalConnectedUsers)} + /> + statistics.activeUsers)} + /> + statistics.activeGuests)} + /> + statistics.nonActiveUsers)} + /> + statistics.appUsers)} + /> + + + {t('Uploads')} + statistics.uploadsTotal)} + /> + formatMemorySize(statistics.uploadsTotalSize))} + /> + + + + + + {t('Rooms')} + {t('Stats_Total_Rooms')}} + value={s(() => statistics.totalRooms)} + /> + {t('Stats_Total_Channels')}} + value={s(() => statistics.totalChannels)} + /> + {t('Stats_Total_Private_Groups')}} + value={s(() => statistics.totalPrivateGroups)} + /> + {t('Stats_Total_Direct_Messages')}} + value={s(() => statistics.totalDirect)} + /> + {t('Total_Discussions')}} + value={s(() => statistics.totalDiscussions)} + /> + {t('Stats_Total_Livechat_Rooms')}} + value={s(() => statistics.totalLivechat)} + /> + + + {t('Messages')} + {t('Stats_Total_Messages')}} + value={s(() => statistics.totalMessages)} + /> + {t('Total_Threads')}} + value={s(() => statistics.totalThreads)} + /> + {t('Stats_Total_Messages_Channel')}} + value={s(() => statistics.totalChannelMessages)} + /> + {t('Stats_Total_Messages_PrivateGroup')}} + value={s(() => statistics.totalPrivateGroupMessages)} + /> + {t('Stats_Total_Messages_Direct')}} + value={s(() => statistics.totalDirectMessages)} + /> + {t('Stats_Total_Messages_Livechat')}} + value={s(() => statistics.totalLivechatMessages)} + /> + + + + + + + + + ; +}); + +export default UsageCard; diff --git a/client/views/admin/info/UsagePieGraph.js b/client/views/admin/info/UsagePieGraph.js new file mode 100644 index 000000000000..7c64474cc652 --- /dev/null +++ b/client/views/admin/info/UsagePieGraph.js @@ -0,0 +1,52 @@ +import React, { useMemo, useCallback } from 'react'; +import { Box } from '@rocket.chat/fuselage'; +import { Pie } from '@nivo/pie'; +import colors from '@rocket.chat/fuselage-tokens/colors'; + +const graphColors = (color) => ({ used: color || colors.b500, free: colors.n300 }); + +const UsageGraph = ({ used = 0, total = 0, label, color, size }) => { + const parsedData = useMemo(() => [{ + id: 'used', + label: 'used', + value: used, + }, { + id: 'free', + label: 'free', + value: total - used, + }], [total, used]); + + const getColor = useCallback((data) => graphColors(color)[data.id], [color]); + + return + + + + + {Number((100 / total) * used).toFixed(2)}% + + + + {used} / {total} + {label} + ; +}; + +export default UsageGraph; diff --git a/client/views/admin/info/UsageSection.js b/client/views/admin/info/UsageSection.js deleted file mode 100644 index d9463c0d1a6d..000000000000 --- a/client/views/admin/info/UsageSection.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Skeleton } from '@rocket.chat/fuselage'; -import React from 'react'; - -import Subtitle from '../../../components/Subtitle'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import { useFormatMemorySize } from '../../../hooks/useFormatMemorySize'; -import DescriptionList from './DescriptionList'; - -const UsageSection = React.memo(function UsageSection({ statistics, isLoading }) { - const s = (fn) => (isLoading ? : fn()); - const formatMemorySize = useFormatMemorySize(); - const t = useTranslation(); - - return {t('Usage')}} - > - {s(() => statistics.totalUsers)} - {s(() => statistics.activeUsers)} - {s(() => statistics.activeGuests)} - {s(() => statistics.appUsers)} - {s(() => statistics.nonActiveUsers)} - {s(() => statistics.totalConnectedUsers)} - {s(() => statistics.onlineUsers)} - {s(() => statistics.awayUsers)} - {s(() => statistics.offlineUsers)} - {s(() => statistics.totalRooms)} - {s(() => statistics.totalChannels)} - {s(() => statistics.totalPrivateGroups)} - {s(() => statistics.totalDirect)} - {s(() => statistics.totalLivechat)} - {s(() => statistics.totalDiscussions)} - {s(() => statistics.totalThreads)} - {s(() => statistics.totalMessages)} - {s(() => statistics.totalChannelMessages)} - {s(() => statistics.totalPrivateGroupMessages)} - {s(() => statistics.totalDirectMessages)} - {s(() => statistics.totalLivechatMessages)} - {s(() => statistics.uploadsTotal)} - {s(() => formatMemorySize(statistics.uploadsTotalSize))} - {statistics && statistics.apps && <> - {statistics.apps.totalInstalled} - {statistics.apps.totalActive} - } - {s(() => statistics.integrations.totalIntegrations)} - {s(() => statistics.integrations.totalIncoming)} - {s(() => statistics.integrations.totalIncomingActive)} - {s(() => statistics.integrations.totalOutgoing)} - {s(() => statistics.integrations.totalOutgoingActive)} - {s(() => statistics.integrations.totalWithScriptEnabled)} - ; -}); - -export default UsageSection; diff --git a/client/views/admin/info/UsageSection.stories.js b/client/views/admin/info/UsageSection.stories.js deleted file mode 100644 index 5f7c6c205dd2..000000000000 --- a/client/views/admin/info/UsageSection.stories.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; - -import UsageSection from './UsageSection'; - -export default { - title: 'admin/info/UsageSection', - component: UsageSection, - decorators: [ - (fn) =>
{fn()}
, - ], -}; - -const statistics = { - totalUsers: 'statistics.totalUsers', - nonActiveUsers: 'nonActiveUsers', - activeUsers: 'statistics.activeUsers', - totalConnectedUsers: 'statistics.totalConnectedUsers', - onlineUsers: 'statistics.onlineUsers', - awayUsers: 'statistics.awayUsers', - offlineUsers: 'statistics.offlineUsers', - totalRooms: 'statistics.totalRooms', - totalChannels: 'statistics.totalChannels', - totalPrivateGroups: 'statistics.totalPrivateGroups', - totalDirect: 'statistics.totalDirect', - totalLivechat: 'statistics.totalLivechat', - totalDiscussions: 'statistics.totalDiscussions', - totalThreads: 'statistics.totalThreads', - totalMessages: 'statistics.totalMessages', - totalChannelMessages: 'statistics.totalChannelMessages', - totalPrivateGroupMessages: 'statistics.totalPrivateGroupMessages', - totalDirectMessages: 'statistics.totalDirectMessages', - totalLivechatMessages: 'statistics.totalLivechatMessages', - uploadsTotal: 'statistics.uploadsTotal', - uploadsTotalSize: 1024, - integrations: { - totalIntegrations: 'statistics.integrations.totalIntegrations', - totalIncoming: 'statistics.integrations.totalIncoming', - totalIncomingActive: 'statistics.integrations.totalIncomingActive', - totalOutgoing: 'statistics.integrations.totalOutgoing', - totalOutgoingActive: 'statistics.integrations.totalOutgoingActive', - totalWithScriptEnabled: 'statistics.integrations.totalWithScriptEnabled', - }, -}; - -const apps = { - totalInstalled: 'statistics.apps.totalInstalled', - totalActive: 'statistics.apps.totalActive', -}; - -export const _default = () => ; - -export const withApps = () => ; - -export const loading = () => ; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index ce9e87a7e394..9432bd3c3907 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -782,6 +782,12 @@ "Closing_chat": "Closing chat", "Closing_chat_message": "Closing chat message", "Cloud": "Cloud", + "Cloud_Apply_Offline_License": "Apply Offline License", + "Cloud_Change_Offline_License": "Change Offline License", + "Cloud_License_applied_successfully": "License applied successfully!", + "Cloud_Invalid_license": "Invalid license!", + "Cloud_Apply_license": "Apply license", + "Cloud_connectivity": "Cloud Connectivity", "Cloud_address_to_send_registration_to": "The address to send your Cloud registration email to.", "Cloud_click_here": "After copy the text, go to [cloud console (click here)](__cloudConsoleUrl__).", "Cloud_console": "Cloud Console", @@ -828,9 +834,10 @@ "Common_Access": "Common Access", "Community": "Community", "Compact": "Compact", + "Condensed": "Condensed", + "Commit_details": "Commit Details", "Completed": "Completed", "Computer": "Computer", - "Condensed": "Condensed", "Confirm_new_encryption_password": "Confirm new encryption password", "Confirm_new_password": "Confirm New Password", "Confirm_New_Password_Placeholder": "Please re-enter new password...", @@ -1423,6 +1430,7 @@ "Emoji_provided_by_JoyPixels": "Emoji provided by JoyPixels", "EmojiCustomFilesystem": "Custom Emoji Filesystem", "Empty_title": "Empty title", + "See_on_Engagement_Dashboard": "See on Engagement Dashboard", "Enable": "Enable", "Enable_Auto_Away": "Enable Auto Away", "Enable_Desktop_Notifications": "Enable Desktop Notifications", @@ -1648,6 +1656,7 @@ "Favorite_Rooms": "Enable Favorite Rooms", "Favorites": "Favorites", "Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled": "This feature depends on \"Send Visitor Navigation History as a Message\" to be enabled.", + "Features": "Features", "Features_Enabled": "Features Enabled", "Federation_Dashboard": "Federation Dashboard", "FEDERATION_Discovery_Method": "Discovery Method", @@ -1993,6 +2002,8 @@ "Installed": "Installed", "Installed_at": "Installed at", "Instance": "Instance", + "Instances": "Instances", + "Instances_health": "Instances Health", "Instance_Record": "Instance Record", "Instructions": "Instructions", "Instructions_to_your_visitor_fill_the_form_to_send_a_message": "Instructions to your visitor fill the form to send a message", @@ -2319,6 +2330,7 @@ "List_of_departments_for_forward_description": "Allow to set a restricted list of departments that can receive chats from this department", "List_of_departments_to_apply_this_business_hour": "List of departments to apply this business hour", "List_of_Direct_Messages": "List of Direct Messages", + "Omnichannel": "Omnichannel", "Livechat": "Livechat", "Livechat_abandoned_rooms_closed_custom_message": "Custom message when room is automatically closed by visitor inactivity", "Livechat_agents": "Omnichannel agents", @@ -2899,6 +2911,8 @@ "Passwords_do_not_match": "Passwords do not match", "Past_Chats": "Past Chats", "Paste_here": "Paste here...", + "Paste": "Paste", + "Paste_error": "Error reading from clipboard", "Payload": "Payload", "Peer_Password": "Peer Password", "People": "People", @@ -3013,6 +3027,7 @@ "Purchase_for_price": "Purchase for $%s", "Purchased": "Purchased", "Push": "Push", + "Push_Notifications": "Push Notifications", "Push_apn_cert": "APN Cert", "Push_apn_dev_cert": "APN Dev Cert", "Push_apn_dev_key": "APN Dev Key", @@ -3409,6 +3424,7 @@ "Setup_Wizard": "Setup Wizard", "Setup_Wizard_Info": "We'll guide you through setting up your first admin user, configuring your organisation and registering your server to receive free push notifications and more.", "Share_Location_Title": "Share Location?", + "Canned_responses": "Canned responses", "Shared_Location": "Shared Location", "Shared_Secret": "Shared Secret", "Shortcut": "Shortcut", @@ -3787,6 +3803,7 @@ "Two-factor_authentication_is_currently_disabled": "Two-factor authentication via TOTP is currently disabled", "Two-factor_authentication_native_mobile_app_warning": "WARNING: Once you enable this, you will not be able to login on the native mobile apps (Rocket.Chat+) using your password until they implement the 2FA.", "Type": "Type", + "Types_and_Distribution": "Types and Distribution", "Type_your_email": "Type your email", "Type_your_job_title": "Type your job title", "Type_your_message": "Type your message", From ea90303e51d5a8fc92be19b8082fbeb289492a92 Mon Sep 17 00:00:00 2001 From: Gal Shiff Date: Tue, 12 Jan 2021 18:52:34 +0200 Subject: [PATCH 13/98] [FIX] Video call message not translated (#18722) Co-authored-by: Guilherme Gazzo --- app/videobridge/server/methods/jitsiSetTimeout.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/videobridge/server/methods/jitsiSetTimeout.js b/app/videobridge/server/methods/jitsiSetTimeout.js index 4d160ba4868f..f0767579eccb 100644 --- a/app/videobridge/server/methods/jitsiSetTimeout.js +++ b/app/videobridge/server/methods/jitsiSetTimeout.js @@ -41,7 +41,7 @@ Meteor.methods({ const message = Messages.createWithTypeRoomIdMessageAndUser('jitsi_call_started', rid, '', Meteor.user(), { actionLinks: [ - { icon: 'icon-videocam', label: TAPi18n.__('Click_to_join'), method_id: 'joinJitsiCall', params: '' }, + { icon: 'icon-videocam', label: TAPi18n.__('Click_to_join'), i18nLabel: 'Click_to_join', method_id: 'joinJitsiCall', params: '' }, ], }); message.msg = TAPi18n.__('Started_a_video_call'); From 843ef85ef45cb57a13aa38aa3e49ed1a3c269804 Mon Sep 17 00:00:00 2001 From: Bhavay Anand Date: Tue, 12 Jan 2021 22:22:59 +0530 Subject: [PATCH 14/98] [FIX] Unable to reset password by Email if upper case character is present (#19643) --- server/methods/sendForgotPasswordEmail.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/methods/sendForgotPasswordEmail.js b/server/methods/sendForgotPasswordEmail.js index c648455249aa..a17931918b59 100644 --- a/server/methods/sendForgotPasswordEmail.js +++ b/server/methods/sendForgotPasswordEmail.js @@ -9,7 +9,7 @@ Meteor.methods({ sendForgotPasswordEmail(to) { check(to, String); - const email = to.trim(); + const email = to.trim().toLowerCase(); const user = Users.findOneByEmailAddress(email, { fields: { _id: 1 } }); From 186d74f76a8ed62ecc06eef345517f46ede571a4 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 12 Jan 2021 13:53:57 -0300 Subject: [PATCH 15/98] [FIX] Initial values update on Account Preferences (#19938) Co-authored-by: Tasso Evangelista --- .../preferences/AccountPreferencesPage.js | 16 +++++++++------- .../preferences/PreferencesGlobalSection.js | 6 ++++-- .../preferences/PreferencesHighlightsSection.js | 6 ++++-- .../PreferencesLocalizationSection.js | 6 ++++-- .../preferences/PreferencesMessagesSection.js | 6 ++++-- .../PreferencesNotificationsSection.js | 6 ++++-- .../preferences/PreferencesSoundSection.js | 5 +++-- .../PreferencesUserPresenceSection.js | 6 ++++-- 8 files changed, 36 insertions(+), 21 deletions(-) diff --git a/client/views/account/preferences/AccountPreferencesPage.js b/client/views/account/preferences/AccountPreferencesPage.js index ab39a97ec359..5c21a6d15de2 100644 --- a/client/views/account/preferences/AccountPreferencesPage.js +++ b/client/views/account/preferences/AccountPreferencesPage.js @@ -22,6 +22,7 @@ const AccountPreferencesPage = () => { const [hasAnyChange, setHasAnyChange] = useState(false); const saveData = useRef({}); + const commitRef = useRef({}); const dataDownloadEnabled = useSetting('UserData_EnableDownload'); @@ -56,6 +57,7 @@ const AccountPreferencesPage = () => { await saveFn(data); saveData.current = {}; setHasAnyChange(false); + Object.values(commitRef.current).forEach((fn) => fn()); dispatchToastMessage({ type: 'success', message: t('Preferences_saved') }); } catch (e) { @@ -74,13 +76,13 @@ const AccountPreferencesPage = () => { - - - - - - - + + + + + + + {dataDownloadEnabled && } diff --git a/client/views/account/preferences/PreferencesGlobalSection.js b/client/views/account/preferences/PreferencesGlobalSection.js index 71bbd021d5f2..20a6d474a9af 100644 --- a/client/views/account/preferences/PreferencesGlobalSection.js +++ b/client/views/account/preferences/PreferencesGlobalSection.js @@ -5,7 +5,7 @@ import { useTranslation } from '../../../contexts/TranslationContext'; import { useUserPreference } from '../../../contexts/UserContext'; import { useForm } from '../../../hooks/useForm'; -const PreferencesGlobalSection = ({ onChange, ...props }) => { +const PreferencesGlobalSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const userDontAskAgainList = useUserPreference('dontAskAgainList'); @@ -14,12 +14,14 @@ const PreferencesGlobalSection = ({ onChange, ...props }) => { const selectedOptions = options.map(([action]) => action); - const { values, handlers } = useForm({ dontAskAgainList: selectedOptions }, onChange); + const { values, handlers, commit } = useForm({ dontAskAgainList: selectedOptions }, onChange); const { dontAskAgainList } = values; const { handleDontAskAgainList } = handlers; + commitRef.current.global = commit; + return diff --git a/client/views/account/preferences/PreferencesHighlightsSection.js b/client/views/account/preferences/PreferencesHighlightsSection.js index b6903c4f6ac7..74c13a992d0e 100644 --- a/client/views/account/preferences/PreferencesHighlightsSection.js +++ b/client/views/account/preferences/PreferencesHighlightsSection.js @@ -5,17 +5,19 @@ import { useTranslation } from '../../../contexts/TranslationContext'; import { useUserPreference } from '../../../contexts/UserContext'; import { useForm } from '../../../hooks/useForm'; -const PreferencesHighlightsSection = ({ onChange, ...props }) => { +const PreferencesHighlightsSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const userHighlights = useUserPreference('highlights')?.join(',\n') ?? ''; - const { values, handlers } = useForm({ highlights: userHighlights }, onChange); + const { values, handlers, commit } = useForm({ highlights: userHighlights }, onChange); const { highlights } = values; const { handleHighlights } = handlers; + commitRef.current.highlights = commit; + return diff --git a/client/views/account/preferences/PreferencesLocalizationSection.js b/client/views/account/preferences/PreferencesLocalizationSection.js index 124c85b1504f..76c8a63c1c34 100644 --- a/client/views/account/preferences/PreferencesLocalizationSection.js +++ b/client/views/account/preferences/PreferencesLocalizationSection.js @@ -5,18 +5,20 @@ import { useLanguages, useTranslation } from '../../../contexts/TranslationConte import { useUserPreference } from '../../../contexts/UserContext'; import { useForm } from '../../../hooks/useForm'; -const PreferencesLocalizationSection = ({ onChange, ...props }) => { +const PreferencesLocalizationSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const userLanguage = useUserPreference('language') || ''; const languages = useLanguages(); const languageOptions = useMemo(() => languages.map(({ key, name }) => [key, name]).sort(([a], [b]) => a - b), [languages]); - const { values, handlers } = useForm({ language: userLanguage }, onChange); + const { values, handlers, commit } = useForm({ language: userLanguage }, onChange); const { language } = values; const { handleLanguage } = handlers; + commitRef.current.localization = commit; + return diff --git a/client/views/account/preferences/PreferencesMessagesSection.js b/client/views/account/preferences/PreferencesMessagesSection.js index 5fb833601232..4e4193f97bdf 100644 --- a/client/views/account/preferences/PreferencesMessagesSection.js +++ b/client/views/account/preferences/PreferencesMessagesSection.js @@ -6,7 +6,7 @@ import { useUserPreference } from '../../../contexts/UserContext'; import { useSetting } from '../../../contexts/SettingsContext'; import { useForm } from '../../../hooks/useForm'; -const PreferencesMessagesSection = ({ onChange, ...props }) => { +const PreferencesMessagesSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const showRoles = useSetting('UI_DisplayRoles'); @@ -28,7 +28,7 @@ const PreferencesMessagesSection = ({ onChange, ...props }) => { messageViewMode: useUserPreference('messageViewMode'), }; - const { values, handlers } = useForm(settings, onChange); + const { values, handlers, commit } = useForm(settings, onChange); const { unreadAlert, @@ -82,6 +82,8 @@ const PreferencesMessagesSection = ({ onChange, ...props }) => { [2, t('Compact')], ], [t]); + commitRef.current.messages = commit; + // TODO: Weird behaviour when saving clock mode, and then changing it. return diff --git a/client/views/account/preferences/PreferencesNotificationsSection.js b/client/views/account/preferences/PreferencesNotificationsSection.js index 7c08004bc0fa..7c78e82fdc2c 100644 --- a/client/views/account/preferences/PreferencesNotificationsSection.js +++ b/client/views/account/preferences/PreferencesNotificationsSection.js @@ -18,7 +18,7 @@ const emailNotificationOptionsLabelMap = { nothing: 'Email_Notification_Mode_Disabled', }; -const PreferencesNotificationsSection = ({ onChange, ...props }) => { +const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const [notificationsPermission, setNotificationsPermission] = useState(); @@ -34,7 +34,7 @@ const PreferencesNotificationsSection = ({ onChange, ...props }) => { const defaultMobileNotifications = useSetting('Accounts_Default_User_Preferences_mobileNotifications'); const canChangeEmailNotification = useSetting('Accounts_AllowEmailNotifications'); - const { values, handlers } = useForm({ + const { values, handlers, commit } = useForm({ desktopNotificationRequireInteraction: userDesktopNotificationRequireInteraction, desktopNotifications: userDesktopNotifications, mobileNotifications: userMobileNotifications, @@ -60,6 +60,8 @@ const PreferencesNotificationsSection = ({ onChange, ...props }) => { useEffect(() => setNotificationsPermission(window.Notification && Notification.permission), []); + commitRef.current.notifications = commit; + const onSendNotification = useCallback(() => { KonchatNotification.notify({ payload: { sender: { username: 'rocket.cat' } }, diff --git a/client/views/account/preferences/PreferencesSoundSection.js b/client/views/account/preferences/PreferencesSoundSection.js index 84dc4bf2af3e..bda586d99be8 100644 --- a/client/views/account/preferences/PreferencesSoundSection.js +++ b/client/views/account/preferences/PreferencesSoundSection.js @@ -8,7 +8,7 @@ import { CustomSounds } from '../../../../app/custom-sounds/client'; const useCustomSoundsOptions = () => useMemo(() => CustomSounds && CustomSounds.getList && CustomSounds.getList().map(({ _id, name }) => [_id, name]), []); -const PreferencesSoundSection = ({ onChange, ...props }) => { +const PreferencesSoundSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const soundsList = useCustomSoundsOptions(); @@ -20,7 +20,7 @@ const PreferencesSoundSection = ({ onChange, ...props }) => { notificationsSoundVolume: useUserPreference('notificationsSoundVolume'), }; - const { values, handlers } = useForm(settings, onChange); + const { values, handlers, commit } = useForm(settings, onChange); const { newRoomNotification, @@ -38,6 +38,7 @@ const PreferencesSoundSection = ({ onChange, ...props }) => { const onChangeNotificationsSoundVolume = useCallback((e) => handleNotificationsSoundVolume(Math.max(0, Math.min(Number(e.currentTarget.value), 100))), [handleNotificationsSoundVolume]); + commitRef.current.sound = commit; return diff --git a/client/views/account/preferences/PreferencesUserPresenceSection.js b/client/views/account/preferences/PreferencesUserPresenceSection.js index 20360ac6ee0c..150df5251cf0 100644 --- a/client/views/account/preferences/PreferencesUserPresenceSection.js +++ b/client/views/account/preferences/PreferencesUserPresenceSection.js @@ -5,12 +5,12 @@ import { useTranslation } from '../../../contexts/TranslationContext'; import { useUserPreference } from '../../../contexts/UserContext'; import { useForm } from '../../../hooks/useForm'; -const PreferencesUserPresenceSection = ({ onChange, ...props }) => { +const PreferencesUserPresenceSection = ({ onChange, commitRef, ...props }) => { const t = useTranslation(); const userEnableAutoAway = useUserPreference('enableAutoAway'); const userIdleTimeLimit = useUserPreference('idleTimeLimit'); - const { values, handlers } = useForm({ + const { values, handlers, commit } = useForm({ enableAutoAway: userEnableAutoAway, idleTimeLimit: userIdleTimeLimit, }, onChange); @@ -25,6 +25,8 @@ const PreferencesUserPresenceSection = ({ onChange, ...props }) => { handleIdleTimeLimit, } = handlers; + commitRef.current.userPreference = commit; + const onChangeIdleTimeLimit = useCallback((e) => handleIdleTimeLimit(Number(e.currentTarget.value)), [handleIdleTimeLimit]); return From 9e18df6baa28c2440161bb7b2747dfd573893421 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 12 Jan 2021 15:26:33 -0300 Subject: [PATCH 16/98] [FIX] Change header's favorite icon to filled star (#20174) --- client/views/room/Header/icons/Favorite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/views/room/Header/icons/Favorite.js b/client/views/room/Header/icons/Favorite.js index 90bbe0fbfc34..b6d3ce9f69ff 100644 --- a/client/views/room/Header/icons/Favorite.js +++ b/client/views/room/Header/icons/Favorite.js @@ -18,7 +18,7 @@ const Favorite = ({ room: { _id, f: favorited = false } }) => { toggleFavorite(_id, !favorited); }); const favoriteLabel = favorited ? t('Unfavorite') : t('Favorite'); - return isFavoritesEnabled && ; + return isFavoritesEnabled && ; }; export default memo(Favorite); From 1ae396fd04fe01fe918818e12dfaf167fedaae7c Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 12 Jan 2021 15:58:13 -0300 Subject: [PATCH 17/98] [IMPROVE] Rewrite Prune Messages as React component (#19900) --- app/discussion/client/tabBar.ts | 6 +- app/otr/client/tabBar.ts | 6 +- app/ui-clean-history/client/index.js | 3 - app/ui-clean-history/client/lib/startup.ts | 8 +- .../client/views/cleanHistory.html | 158 -------- .../client/views/cleanHistory.js | 351 ------------------ .../client/views/stylesheets/cleanHistory.css | 59 --- client/views/room/adapters.js | 4 + .../PruneMessages/PruneMessages.js | 319 ++++++++++++++++ .../PruneMessages/PruneMessages.stories.js | 23 ++ .../room/contextualBar/PruneMessages/index.js | 3 + .../views/room/lib/Toolbox/defaultActions.ts | 10 +- client/views/room/lib/Toolbox/index.tsx | 2 +- packages/rocketchat-i18n/i18n/en.i18n.json | 2 + 14 files changed, 370 insertions(+), 584 deletions(-) delete mode 100644 app/ui-clean-history/client/views/cleanHistory.html delete mode 100644 app/ui-clean-history/client/views/cleanHistory.js delete mode 100644 app/ui-clean-history/client/views/stylesheets/cleanHistory.css create mode 100644 client/views/room/contextualBar/PruneMessages/PruneMessages.js create mode 100644 client/views/room/contextualBar/PruneMessages/PruneMessages.stories.js create mode 100644 client/views/room/contextualBar/PruneMessages/index.js diff --git a/app/discussion/client/tabBar.ts b/app/discussion/client/tabBar.ts index 4eedbeed6621..e41c36d8bf65 100644 --- a/app/discussion/client/tabBar.ts +++ b/app/discussion/client/tabBar.ts @@ -1,8 +1,10 @@ -import { useMemo, lazy, LazyExoticComponent, FC } from 'react'; +import { useMemo, lazy } from 'react'; import { addAction } from '../../../client/views/room/lib/Toolbox'; import { useSetting } from '../../../client/contexts/SettingsContext'; +const template = lazy(() => import('../../../client/views/room/contextualBar/Discussions')); + addAction('discussions', () => { const discussionEnabled = useSetting('Discussion_enabled'); @@ -11,7 +13,7 @@ addAction('discussions', () => { id: 'discussions', title: 'Discussions', icon: 'discussion', - template: lazy(() => import('../../../client/views/room/contextualBar/Discussions')) as LazyExoticComponent, + template, full: true, order: 1, } : null), [discussionEnabled]); diff --git a/app/otr/client/tabBar.ts b/app/otr/client/tabBar.ts index 2cfdc6dcdeff..65760ebabc87 100644 --- a/app/otr/client/tabBar.ts +++ b/app/otr/client/tabBar.ts @@ -1,9 +1,11 @@ -import { useMemo, lazy, LazyExoticComponent, FC, useEffect } from 'react'; +import { useMemo, lazy, useEffect } from 'react'; import { OTR } from './rocketchat.otr'; import { useSetting } from '../../../client/contexts/SettingsContext'; import { addAction } from '../../../client/views/room/lib/Toolbox'; +const template = lazy(() => import('../../../client/views/room/contextualBar/OTR')); + addAction('otr', () => { const enabled = useSetting('OTR_Enable'); @@ -24,7 +26,7 @@ addAction('otr', () => { id: 'otr', title: 'OTR', icon: 'key', - template: lazy(() => import('../../../client/views/room/contextualBar/OTR')) as LazyExoticComponent, + template, order: 13, full: true, } : null), [shouldAddAction]); diff --git a/app/ui-clean-history/client/index.js b/app/ui-clean-history/client/index.js index 678ec8d39902..6d726d28bbb1 100644 --- a/app/ui-clean-history/client/index.js +++ b/app/ui-clean-history/client/index.js @@ -1,4 +1 @@ import './lib/startup'; -import './views/cleanHistory.html'; -import './views/cleanHistory'; -import './views/stylesheets/cleanHistory.css'; diff --git a/app/ui-clean-history/client/lib/startup.ts b/app/ui-clean-history/client/lib/startup.ts index 56575338c674..96b9b8343b88 100644 --- a/app/ui-clean-history/client/lib/startup.ts +++ b/app/ui-clean-history/client/lib/startup.ts @@ -1,18 +1,20 @@ -import { useMemo } from 'react'; +import { useMemo, lazy } from 'react'; import { addAction } from '../../../../client/views/room/lib/Toolbox'; import { usePermission } from '../../../../client/contexts/AuthorizationContext'; +const template = lazy(() => import('../../../../client/views/room/contextualBar/PruneMessages')); + addAction('clean-history', ({ room }) => { const hasPermission = usePermission('clean-channel-history', room._id); return useMemo(() => (hasPermission ? { groups: ['channel', 'group', 'direct'], id: 'clean-history', - anonymous: true, + full: true, title: 'Prune_Messages', icon: 'eraser', - template: 'cleanHistory', + template, order: 250, } : null), [hasPermission]); }); diff --git a/app/ui-clean-history/client/views/cleanHistory.html b/app/ui-clean-history/client/views/cleanHistory.html deleted file mode 100644 index 27f9a9a7bc64..000000000000 --- a/app/ui-clean-history/client/views/cleanHistory.html +++ /dev/null @@ -1,158 +0,0 @@ - diff --git a/app/ui-clean-history/client/views/cleanHistory.js b/app/ui-clean-history/client/views/cleanHistory.js deleted file mode 100644 index 3eb7b8960d9b..000000000000 --- a/app/ui-clean-history/client/views/cleanHistory.js +++ /dev/null @@ -1,351 +0,0 @@ -import { Tracker } from 'meteor/tracker'; -import { Blaze } from 'meteor/blaze'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Session } from 'meteor/session'; -import { Template } from 'meteor/templating'; -import moment from 'moment'; - -import { ChatRoom } from '../../../models'; -import { t, roomTypes } from '../../../utils'; -import { settings } from '../../../settings'; -import { modal, call } from '../../../ui-utils'; -import { AutoComplete } from '../../../meteor-autocomplete/client'; - -const getRoomName = function() { - const room = ChatRoom.findOne(Session.get('openedRoom')); - if (!room) { - return; - } - if (room.name) { - return `#${ room.name }`; - } - - return t('conversation_with_s', roomTypes.getRoomName(room.t, room)); -}; - -const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePinned, ignoreDiscussion, filesOnly, fromUsers, ignoreThreads) { - return call('cleanRoomHistory', { - roomId, - latest, - oldest, - inclusive, - limit, - excludePinned, - ignoreDiscussion, - filesOnly, - fromUsers, - ignoreThreads, - }); -}; - - -const getTimeZoneOffset = function() { - const offset = new Date().getTimezoneOffset(); - const absOffset = Math.abs(offset); - return `${ offset < 0 ? '+' : '-' }${ `00${ Math.floor(absOffset / 60) }`.slice(-2) }:${ `00${ absOffset % 60 }`.slice(-2) }`; -}; - - -const filterNames = (old) => { - const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); - return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join(''); -}; - -Template.cleanHistory.helpers({ - roomId() { - const room = ChatRoom.findOne(Session.get('openedRoom')); - return room && room._id; - }, - roomName() { - return getRoomName(); - }, - warningBox() { - return Template.instance().warningBox.get(); - }, - validate() { - return Template.instance().validate.get(); - }, - filesOnly() { - return Template.instance().cleanHistoryFilesOnly.get(); - }, - busy() { - return Template.instance().cleanHistoryBusy.get(); - }, - finished() { - return Template.instance().cleanHistoryFinished.get(); - }, - prunedCount() { - return Template.instance().cleanHistoryPrunedCount.get(); - }, - config() { - const filter = Template.instance().userFilter; - return { - filter: filter.get(), - noMatchTemplate: 'userSearchEmpty', - modifier(text) { - const f = filter.get(); - return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), function(part) { - return `${ part }`; - }) }`; - }, - }; - }, - selectedUsers() { - return Template.instance().selectedUsers.get(); - }, - autocomplete(key) { - const instance = Template.instance(); - const param = instance.ac[key]; - return typeof param === 'function' ? param.apply(instance.ac) : param; - }, - items() { - return Template.instance().ac.filteredList(); - }, - isSingular(prunedCount) { - return prunedCount === 1; - }, -}); - -Template.cleanHistory.onCreated(function() { - this.warningBox = new ReactiveVar(''); - this.validate = new ReactiveVar(''); - this.selectedUsers = new ReactiveVar([]); - this.userFilter = new ReactiveVar(''); - - this.cleanHistoryFromDate = new ReactiveVar(''); - this.cleanHistoryFromTime = new ReactiveVar(''); - this.cleanHistoryToDate = new ReactiveVar(''); - this.cleanHistoryToTime = new ReactiveVar(''); - this.cleanHistorySelectedUsers = new ReactiveVar([]); - this.cleanHistoryInclusive = new ReactiveVar(false); - this.cleanHistoryExcludePinned = new ReactiveVar(false); - this.cleanHistoryFilesOnly = new ReactiveVar(false); - - this.ignoreDiscussion = new ReactiveVar(false); - this.ignoreThreads = new ReactiveVar(false); - - this.cleanHistoryBusy = new ReactiveVar(false); - this.cleanHistoryFinished = new ReactiveVar(false); - this.cleanHistoryPrunedCount = new ReactiveVar(0); - - this.ac = new AutoComplete( - { - selector: { - item: '.rc-popup-list__item', - container: '.rc-popup-list__list', - }, - - limit: 10, - inputDelay: 300, - rules: [ - { - collection: 'UserAndRoom', - endpoint: 'users.autocomplete', - field: 'username', - matchAll: true, - doNotChangeWidth: false, - selector(match) { - return { term: match }; - }, - sort: 'username', - }, - ], - - }); - this.ac.tmplInst = this; -}); - -Template.cleanHistory.onRendered(function() { - const users = this.selectedUsers; - const selUsers = this.cleanHistorySelectedUsers; - - this.ac.element = this.firstNode.parentElement.querySelector('[name="users"]'); - this.ac.$element = $(this.ac.element); - this.ac.$element.on('autocompleteselect', function(e, { item }) { - const usersArr = users.get(); - usersArr.push(item); - users.set(usersArr); - selUsers.set(usersArr); - }); - - Tracker.autorun(() => { - const metaFromDate = this.cleanHistoryFromDate.get(); - const metaFromTime = this.cleanHistoryFromTime.get(); - const metaToDate = this.cleanHistoryToDate.get(); - const metaToTime = this.cleanHistoryToTime.get(); - const metaSelectedUsers = this.cleanHistorySelectedUsers.get(); - const metaCleanHistoryExcludePinned = this.cleanHistoryExcludePinned.get(); - const metaCleanHistoryFilesOnly = this.cleanHistoryFilesOnly.get(); - - let fromDate = new Date('0001-01-01T00:00:00Z'); - let toDate = new Date('9999-12-31T23:59:59Z'); - - if (metaFromDate) { - fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - if (metaToDate) { - toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - const exceptPinned = metaCleanHistoryExcludePinned ? ` ${ t('except_pinned', {}) }` : ''; - const ifFrom = metaSelectedUsers.length ? ` ${ t('if_they_are_from', { - postProcess: 'sprintf', - sprintf: [metaSelectedUsers.map((element) => element.username).join(', ')], - }) }` : ''; - const filesOrMessages = t(metaCleanHistoryFilesOnly ? 'files' : 'messages', {}); - - if (metaFromDate && metaToDate) { - this.warningBox.set(t('Prune_Warning_between', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT'), moment(toDate).format('L LT')], - }) + exceptPinned + ifFrom); - } else if (metaFromDate) { - this.warningBox.set(t('Prune_Warning_after', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName(), moment(fromDate).format('L LT')], - }) + exceptPinned + ifFrom); - } else if (metaToDate) { - this.warningBox.set(t('Prune_Warning_before', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName(), moment(toDate).format('L LT')], - }) + exceptPinned + ifFrom); - } else { - this.warningBox.set(t('Prune_Warning_all', { - postProcess: 'sprintf', - sprintf: [filesOrMessages, getRoomName()], - }) + exceptPinned + ifFrom); - } - - if (fromDate > toDate) { - return this.validate.set(t('Newer_than_may_not_exceed_Older_than', { - postProcess: 'sprintf', - sprintf: [], - })); - } - if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { - return this.validate.set(t('error-invalid-date', { - postProcess: 'sprintf', - sprintf: [], - })); - } - this.validate.set(''); - }); -}); - -Template.cleanHistory.events({ - 'change [name=from__date]'(e, instance) { - instance.cleanHistoryFromDate.set(e.target.value); - }, - 'change [name=from__time]'(e, instance) { - instance.cleanHistoryFromTime.set(e.target.value); - }, - 'change [name=to__date]'(e, instance) { - instance.cleanHistoryToDate.set(e.target.value); - }, - 'change [name=to__time]'(e, instance) { - instance.cleanHistoryToTime.set(e.target.value); - }, - 'change [name=inclusive]'(e, instance) { - instance.cleanHistoryInclusive.set(e.target.checked); - }, - 'change [name=excludePinned]'(e, instance) { - instance.cleanHistoryExcludePinned.set(e.target.checked); - }, - 'change [name=filesOnly]'(e, instance) { - instance.cleanHistoryFilesOnly.set(e.target.checked); - }, - 'change [name=ignoreDiscussion]'(e, instance) { - instance.ignoreDiscussion.set(e.target.checked); - }, - 'change [name=ignoreThreads]'(e, instance) { - instance.ignoreThreads.set(e.target.checked); - }, - 'click .js-prune'(e, instance) { - modal.open({ - title: t('Are_you_sure'), - text: t('Prune_Modal'), - type: 'warning', - showCancelButton: true, - confirmButtonColor: '#DD6B55', - confirmButtonText: t('Yes_prune_them'), - cancelButtonText: t('Cancel'), - closeOnConfirm: true, - html: false, - }, async function() { - instance.cleanHistoryBusy.set(true); - const metaFromDate = instance.cleanHistoryFromDate.get(); - const metaFromTime = instance.cleanHistoryFromTime.get(); - const metaToDate = instance.cleanHistoryToDate.get(); - const metaToTime = instance.cleanHistoryToTime.get(); - const metaSelectedUsers = instance.cleanHistorySelectedUsers.get(); - const metaCleanHistoryInclusive = instance.cleanHistoryInclusive.get(); - const metaCleanHistoryExcludePinned = instance.cleanHistoryExcludePinned.get(); - const metaCleanHistoryFilesOnly = instance.cleanHistoryFilesOnly.get(); - const ignoreDiscussion = instance.ignoreDiscussion.get(); - const ignoreThreads = instance.ignoreThreads.get(); - - let fromDate = new Date('0001-01-01T00:00:00Z'); - let toDate = new Date('9999-12-31T23:59:59Z'); - - if (metaFromDate) { - fromDate = new Date(`${ metaFromDate }T${ metaFromTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - if (metaToDate) { - toDate = new Date(`${ metaToDate }T${ metaToTime || '00:00' }:00${ getTimeZoneOffset() }`); - } - - const roomId = Session.get('openedRoom'); - const users = metaSelectedUsers.map((element) => element.username); - const limit = 2000; - let count = 0; - let result; - do { - result = await purgeWorker(roomId, fromDate, toDate, metaCleanHistoryInclusive, limit, metaCleanHistoryExcludePinned, ignoreDiscussion, metaCleanHistoryFilesOnly, users, ignoreThreads); // eslint-disable-line no-await-in-loop - count += result; - } while (result === limit); - - instance.cleanHistoryPrunedCount.set(count); - instance.cleanHistoryFinished.set(true); - }); - }, - 'click .rc-input--usernames .rc-tags__tag'({ target }, t) { - const { username } = Blaze.getData(target); - t.selectedUsers.set(t.selectedUsers.get().filter((user) => user.username !== username)); - t.cleanHistorySelectedUsers.set(t.selectedUsers.get()); - }, - 'click .rc-popup-list__item'(e, t) { - t.ac.onItemClick(this, e); - }, - 'input [name="users"]'(e, t) { - const input = e.target; - const position = input.selectionEnd || input.selectionStart; - const { length } = input.value; - const modified = filterNames(input.value); - input.value = modified; - document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length); - - t.userFilter.set(modified); - }, - 'keydown [name="users"]'(e, t) { - if ([8, 46].includes(e.keyCode) && e.target.value === '') { - const users = t.selectedUsers; - const usersArr = users.get(); - usersArr.pop(); - t.cleanHistorySelectedUsers.set(usersArr); - return users.set(usersArr); - } - - t.ac.onKeyDown(e); - }, - 'keyup [name="users"]'(e, t) { - t.ac.onKeyUp(e); - }, - 'focus [name="users"]'(e, t) { - t.ac.onFocus(e); - }, - 'blur [name="users"]'(e, t) { - t.ac.onBlur(e); - }, -}); diff --git a/app/ui-clean-history/client/views/stylesheets/cleanHistory.css b/app/ui-clean-history/client/views/stylesheets/cleanHistory.css deleted file mode 100644 index 57048d469b90..000000000000 --- a/app/ui-clean-history/client/views/stylesheets/cleanHistory.css +++ /dev/null @@ -1,59 +0,0 @@ -.rc-datetime__left { - display: inline-block; - - width: 52%; -} - -.rc-datetime__right { - display: inline-block; - - width: calc(48% - 0.3rem); -} - -.rc-user-info__pruning { - position: absolute; - top: 50%; - left: 50%; - - transform: translate(-50%, calc(-50% - 2rem)); -} - -.pruning__header { - text-align: center; - - font-weight: 900; -} - -.pruning-wrapper { - text-align: center; - - color: var(--rc-color-link-active); - - &.prune__finished { - color: #12c212; - } - - & .rc-icon--loading { - width: 16rem; - height: 16rem; - margin: 1rem 0; - - animation: spin 2s linear infinite; - } - - & .rc-icon--check { - font-size: 1rem; - } - - & .pruning__text { - margin-top: -17rem; - - font-size: 3.5em; - - line-height: 16rem; - } - - & .pruning__text-sub { - margin-top: calc(-8rem + 1.5em); - } -} diff --git a/client/views/room/adapters.js b/client/views/room/adapters.js index a9129cb3a9d6..ac4bafe4faa7 100644 --- a/client/views/room/adapters.js +++ b/client/views/room/adapters.js @@ -89,3 +89,7 @@ createTemplateForComponent('UserInfoWithData', () => import('./contextualBar/Use createTemplateForComponent('channelFilesList', () => import('./contextualBar/RoomFiles/RoomFiles'), { renderContainerView: () => HTML.DIV({ class: 'contextual-bar' }), // eslint-disable-line new-cap }); + +createTemplateForComponent('PruneMessages', () => import('./contextualBar/PruneMessages'), { + renderContainerView: () => HTML.DIV({ class: 'contextual-bar' }), // eslint-disable-line new-cap +}); diff --git a/client/views/room/contextualBar/PruneMessages/PruneMessages.js b/client/views/room/contextualBar/PruneMessages/PruneMessages.js new file mode 100644 index 000000000000..22314823f8bd --- /dev/null +++ b/client/views/room/contextualBar/PruneMessages/PruneMessages.js @@ -0,0 +1,319 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Field, ButtonGroup, Button, CheckBox, InputBox, Box, Margins, Callout } from '@rocket.chat/fuselage'; +import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import moment from 'moment'; + +import UserAutoCompleteMultiple from '../../../../../ee/client/audit/UserAutoCompleteMultiple'; +import { useTranslation } from '../../../../contexts/TranslationContext'; +import VerticalBar from '../../../../components/VerticalBar'; +import { useUserRoom } from '../../../../contexts/UserContext'; +import { useToastMessageDispatch } from '../../../../contexts/ToastMessagesContext'; +import { useSetModal } from '../../../../contexts/ModalContext'; +import { useForm } from '../../../../hooks/useForm'; +import { useMethod } from '../../../../contexts/ServerContext'; +import DeleteWarningModal from '../../../../components/DeleteWarningModal'; + +const getTimeZoneOffset = function() { + const offset = new Date().getTimezoneOffset(); + const absOffset = Math.abs(offset); + return `${ offset < 0 ? '+' : '-' }${ `00${ Math.floor(absOffset / 60) }`.slice(-2) }:${ `00${ absOffset % 60 }`.slice(-2) }`; +}; + +export const DialogPruneMessages = ({ children, ...props }) => + {children}; + +export const DateTimeRow = ({ + label, + dateTime, + handleDateTime, +}) => ( + + {label} + + + + + + + +); + +export const PruneMessages = ({ + callOutText, + validateText, + newerDateTime, + handleNewerDateTime, + olderDateTime, + handleOlderDateTime, + users, + inclusive, + pinned, + discussion, + threads, + attached, + handleInclusive, + handlePinned, + handleDiscussion, + handleThreads, + handleAttached, + onClickClose, + onClickPrune, + onChangeUsers, +}) => { + const t = useTranslation(); + + const inclusiveCheckboxId = useUniqueId(); + const pinnedCheckboxId = useUniqueId(); + const discussionCheckboxId = useUniqueId(); + const threadsCheckboxId = useUniqueId(); + const attachedCheckboxId = useUniqueId(); + + return ( + <> + + + {t('Prune_Messages')} + {onClickClose && } + + + + + + + {t('Only_from_users')} + + + + + + + {t('Inclusive')} + + + + + + + {t('RetentionPolicy_DoNotPrunePinned')} + + + + + + + {t('RetentionPolicy_DoNotPruneDiscussion')} + + + + + + + {t('RetentionPolicy_DoNotPruneThreads')} + + + + + + + {t('Files_only')} + + + + {callOutText && !validateText && {callOutText}} + {validateText && {validateText}} + + + + + + + + ); +}; + +const initialValues = { + newerDate: '', + newerTime: '', + olderDate: '', + olderTime: '', + users: [], + inclusive: false, + pinned: false, + discussion: false, + threads: false, + attached: false, +}; + +export default ({ + rid, + tabBar, +}) => { + const t = useTranslation(); + const room = useUserRoom(rid); + room.type = room.t; + room.rid = rid; + const { name } = room; + + const setModal = useSetModal(); + const onClickClose = useMutableCallback(() => tabBar && tabBar.close()); + const closeModal = useCallback(() => setModal(null), [setModal]); + const dispatchToastMessage = useToastMessageDispatch(); + const pruneMessages = useMethod('cleanRoomHistory'); + + const [fromDate, setFromDate] = useState(new Date('0001-01-01T00:00:00Z')); + const [toDate, setToDate] = useState(new Date('9999-12-31T23:59:59Z')); + const [callOutText, setCallOutText] = useState(); + const [validateText, setValidateText] = useState(); + const [count, setCount] = useState(0); + + const { values, handlers, reset } = useForm(initialValues); + const { + newerDate, + newerTime, + olderDate, + olderTime, + users, + inclusive, + pinned, + discussion, + threads, + attached, + } = values; + + const { + handleNewerDate, + handleNewerTime, + handleOlderDate, + handleOlderTime, + handleUsers, + handleInclusive, + handlePinned, + handleDiscussion, + handleThreads, + handleAttached, + } = handlers; + + const onChangeUsers = useMutableCallback((value, action) => { + if (!action) { + if (users.includes(value)) { + return; + } + return handleUsers([...users, value]); + } + handleUsers(users.filter((current) => current !== value)); + }); + + const handlePrune = useMutableCallback(async () => { + const limit = 2000; + let result; + + try { + if (count === limit) { + return; + } + + result = await pruneMessages({ roomId: rid, latest: toDate, oldest: fromDate, inclusive, limit, excludePinned: pinned, ignoreDiscussion: discussion, filesOnly: attached, fromUsers: users, ignoreThreads: threads }); + setCount(result); + + if (result < 1) { + throw new Error(t('No_messages_found_to_prune')); + } + + dispatchToastMessage({ type: 'success', message: `${ result } ${ t('messages_pruned') }` }); + closeModal(); + reset(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + closeModal(); + } + }); + + const handleModal = () => { + setModal({t('Prune_Modal')}); + }; + + useEffect(() => { + if (newerDate) { + setFromDate(new Date(`${ newerDate }T${ newerTime || '00:00' }:00${ getTimeZoneOffset() }`)); + } + + if (olderDate) { + setToDate(new Date(`${ olderDate }T${ olderTime || '24:00' }:00${ getTimeZoneOffset() }`)); + } + }, [newerDate, newerTime, olderDate, olderTime]); + + useEffect(() => { + const exceptPinned = pinned ? ` ${ t('except_pinned', {}) }` : ''; + const ifFrom = users.length ? ` ${ t('if_they_are_from', { + postProcess: 'sprintf', + sprintf: [users.map((element) => element).join(', ')], + }) }` : ''; + const filesOrMessages = t(attached ? 'files' : 'messages', {}); + + if (newerDate && olderDate) { + setCallOutText(t('Prune_Warning_between', { + postProcess: 'sprintf', + sprintf: [filesOrMessages, name, moment(fromDate).format('L LT'), moment(toDate).format('L LT')], + }) + exceptPinned + ifFrom); + } else if (newerDate) { + setCallOutText(t('Prune_Warning_after', { + postProcess: 'sprintf', + sprintf: [filesOrMessages, name, moment(fromDate).format('L LT')], + }) + exceptPinned + ifFrom); + } else if (olderDate) { + setCallOutText(t('Prune_Warning_before', { + postProcess: 'sprintf', + sprintf: [filesOrMessages, name, moment(toDate).format('L LT')], + }) + exceptPinned + ifFrom); + } else { + setCallOutText(t('Prune_Warning_all', { + postProcess: 'sprintf', + sprintf: [filesOrMessages, name], + }) + exceptPinned + ifFrom); + } + + if (fromDate > toDate) { + return setValidateText(t('Newer_than_may_not_exceed_Older_than', { + postProcess: 'sprintf', + sprintf: [], + })); + } + if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { + return setValidateText(t('error-invalid-date', { + postProcess: 'sprintf', + sprintf: [], + })); + } + + setValidateText(); + }, [newerDate, olderDate, fromDate, toDate, attached, name, t, pinned, users]); + + return ( + + ); +}; diff --git a/client/views/room/contextualBar/PruneMessages/PruneMessages.stories.js b/client/views/room/contextualBar/PruneMessages/PruneMessages.stories.js new file mode 100644 index 000000000000..96418b251ab0 --- /dev/null +++ b/client/views/room/contextualBar/PruneMessages/PruneMessages.stories.js @@ -0,0 +1,23 @@ +import React from 'react'; + +import { PruneMessages } from './PruneMessages'; +import VerticalBar from '../../../../components/VerticalBar'; + +export default { + title: 'components/PruneMessages', + component: PruneMessages, +}; + +export const Default = () => + +; + +export const withCallout = () => + +; diff --git a/client/views/room/contextualBar/PruneMessages/index.js b/client/views/room/contextualBar/PruneMessages/index.js new file mode 100644 index 000000000000..4a583c01aba4 --- /dev/null +++ b/client/views/room/contextualBar/PruneMessages/index.js @@ -0,0 +1,3 @@ +import PruneMessages from './PruneMessages'; + +export default PruneMessages; diff --git a/client/views/room/lib/Toolbox/defaultActions.ts b/client/views/room/lib/Toolbox/defaultActions.ts index 37940f7661ea..981b0093178c 100644 --- a/client/views/room/lib/Toolbox/defaultActions.ts +++ b/client/views/room/lib/Toolbox/defaultActions.ts @@ -1,4 +1,4 @@ -import { useMemo, lazy, LazyExoticComponent, FC } from 'react'; +import { useMemo, lazy } from 'react'; import { usePermission } from '../../../../contexts/AuthorizationContext'; @@ -19,7 +19,7 @@ addAction('user-info', { id: 'user-info', title: 'User_Info', icon: 'user', - template: lazy(() => import('../../MemberListRouter')) as LazyExoticComponent, + template: lazy(() => import('../../MemberListRouter')), order: 5, }); @@ -28,7 +28,7 @@ addAction('user-info-group', { id: 'user-info-group', title: 'Members', icon: 'team', - template: lazy(() => import('../../MemberListRouter')) as LazyExoticComponent, + template: lazy(() => import('../../MemberListRouter')), order: 5, }); @@ -39,7 +39,7 @@ addAction('members-list', ({ room }) => { id: 'members-list', title: 'Members', icon: 'team', - template: lazy(() => import('../../MemberListRouter')) as LazyExoticComponent, + template: lazy(() => import('../../MemberListRouter')), order: 5, } : null), [hasPermission, room.broadcast]); }); @@ -49,7 +49,7 @@ addAction('uploaded-files-list', { id: 'uploaded-files-list', title: 'Files', icon: 'clip', - template: lazy(() => import('../../contextualBar/RoomFiles')) as LazyExoticComponent, + template: lazy(() => import('../../contextualBar/RoomFiles')), order: 6, }); diff --git a/client/views/room/lib/Toolbox/index.tsx b/client/views/room/lib/Toolbox/index.tsx index e4d66b5dcb80..96e5cfef36f6 100644 --- a/client/views/room/lib/Toolbox/index.tsx +++ b/client/views/room/lib/Toolbox/index.tsx @@ -31,7 +31,7 @@ export type ToolboxActionConfig = { groups: Array<'group' | 'channel' | 'live' | 'direct' | 'direct_multiple'>; hotkey?: string; action?: (e: MouseEvent) => void; - template?: string | FC | JSX.Element | LazyExoticComponent; + template?: string | FC | LazyExoticComponent>; } export type ToolboxAction = ToolboxHook | ToolboxActionConfig; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 9432bd3c3907..871acce9b9ba 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2764,6 +2764,7 @@ "No_Limit": "No Limit", "No_livechats": "You have no livechats", "No_mentions_found": "No mentions found", + "No_messages_found_to_prune": "No messages found to prune", "No_messages_yet": "No messages yet", "No_pages_yet_Try_hitting_Reload_Pages_button": "No pages yet. Try hitting \"Reload Pages\" button.", "No_pinned_messages": "No pinned messages", @@ -2944,6 +2945,7 @@ "Please_add_a_comment": "Please add a comment", "Please_add_a_comment_to_close_the_room": "Please, add a comment to close the room", "Please_answer_survey": "Please take a moment to answer a quick survey about this chat", + "Please_enter_usernames": "Please enter usernames...", "please_enter_valid_domain": "Please enter a valid domain", "Please_enter_value_for_url": "Please enter a value for the url of your avatar.", "Please_enter_your_new_password_below": "Please enter your new password below:", From cb8f2756d419dceb645c9897f4585b467223dd01 Mon Sep 17 00:00:00 2001 From: Renato Becker Date: Tue, 12 Jan 2021 18:03:55 -0300 Subject: [PATCH 18/98] [FIX] Livechat.RegisterGuest method removing unset fields (#20124) * Update index.js * Fix Livechat.registerGuest method. * pass only fields that need to be inserted * undo changes on Livechat, registerGuest method. * refactor contacts using your own lib file. * remove unnecessary http header method * refactor null value on server side. * check if customFields are valid and adding only valid fields. * check if customField is visitor and it has some value. * Improve code style. * Implements search for fields check if either email or phone already exist. * validate if both email and phone already exist * code enhancements on api and field validations. * undo unecessary field clean validation. * remove email field icon to avoid weird error validation and clear error phone if empty phone. Co-authored-by: Guilherme Gazzo Co-authored-by: Rafael Ferreira --- app/livechat/server/api/v1/contact.js | 40 ++++++++++-- app/livechat/server/lib/Contacts.js | 65 +++++++++++++++++++ app/livechat/server/lib/Livechat.js | 27 ++------ client/omnichannel/directory/ContactForm.js | 44 ++++++++++--- packages/rocketchat-i18n/i18n/en.i18n.json | 2 + packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 1 + 6 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 app/livechat/server/lib/Contacts.js diff --git a/app/livechat/server/api/v1/contact.js b/app/livechat/server/api/v1/contact.js index 10880b282913..f98b721c55fa 100644 --- a/app/livechat/server/api/v1/contact.js +++ b/app/livechat/server/api/v1/contact.js @@ -1,11 +1,13 @@ import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Livechat } from '../../lib/Livechat'; +import { Contacts } from '../../lib/Contacts'; import { LivechatVisitors, } from '../../../../models'; + API.v1.addRoute('omnichannel/contact', { authRequired: true }, { post() { try { @@ -15,15 +17,11 @@ API.v1.addRoute('omnichannel/contact', { authRequired: true }, { name: String, email: Match.Maybe(String), phone: Match.Maybe(String), - livechatData: Match.Maybe(Object), + customFields: Match.Maybe(Object), contactManager: Match.Maybe(Object), }); - const contactParams = this.bodyParams; - if (this.bodyParams.phone) { - contactParams.phone = { number: this.bodyParams.phone }; - } - const contact = Livechat.registerGuest(contactParams); + const contact = Contacts.registerContact(this.bodyParams); return API.v1.success({ contact }); } catch (e) { @@ -40,3 +38,31 @@ API.v1.addRoute('omnichannel/contact', { authRequired: true }, { return API.v1.success({ contact }); }, }); + + +API.v1.addRoute('omnichannel/contact.search', { authRequired: true }, { + get() { + try { + check(this.queryParams, { + email: Match.Maybe(String), + phone: Match.Maybe(String), + }); + + const { email, phone } = this.queryParams; + + if (!email && !phone) { + throw new Meteor.Error('error-invalid-params'); + } + + const query = Object.assign({}, { + ...email && { visitorEmails: { address: email } }, + ...phone && { phone: { phoneNumber: phone } }, + }); + + const contact = Promise.await(LivechatVisitors.findOne(query)); + return API.v1.success({ contact }); + } catch (e) { + return API.v1.failure(e); + } + }, +}); diff --git a/app/livechat/server/lib/Contacts.js b/app/livechat/server/lib/Contacts.js new file mode 100644 index 000000000000..7e0f94e10d7c --- /dev/null +++ b/app/livechat/server/lib/Contacts.js @@ -0,0 +1,65 @@ +import { check } from 'meteor/check'; +import s from 'underscore.string'; + +import { + LivechatVisitors, + LivechatCustomField, +} from '../../../models'; + + +export const Contacts = { + + registerContact({ token, name, email, phone, username, customFields = {}, contactManager = {} } = {}) { + check(token, String); + + let contactId; + const updateUser = { + $set: { + token, + }, + }; + + const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); + + if (user) { + contactId = user._id; + } else { + if (!username) { + username = LivechatVisitors.getNextVisitorUsername(); + } + + let existingUser = null; + + if (s.trim(email) !== '' && (existingUser = LivechatVisitors.findOneGuestByEmailAddress(email))) { + contactId = existingUser._id; + } else { + const userData = { + username, + ts: new Date(), + }; + + contactId = LivechatVisitors.insert(userData); + } + } + + updateUser.$set.name = name; + updateUser.$set.phone = (phone && [{ phoneNumber: phone }]) || null; + updateUser.$set.visitorEmails = (email && [{ address: email }]) || null; + + const allowedCF = LivechatCustomField.find({ scope: 'visitor' }).map(({ _id }) => _id); + + const livechatData = Object.keys(customFields) + .filter((key) => allowedCF.includes(key) && customFields[key] !== '' && customFields[key] !== undefined) + .reduce((obj, key) => { + obj[key] = customFields[key]; + return obj; + }, {}); + + updateUser.$set.livechatData = livechatData; + updateUser.$set.contactManager = (contactManager?.username && { username: contactManager.username }) || null; + + LivechatVisitors.updateById(contactId, updateUser); + + return contactId; + }, +}; diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index dbd53debd3c0..198b5da2eed1 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -192,7 +192,7 @@ export const Livechat = { return true; }, - registerGuest({ token, name, email, department, phone, username, livechatData, contactManager, connectionData } = {}) { + registerGuest({ token, name, email, department, phone, username, connectionData } = {}) { check(token, String); let userId; @@ -200,7 +200,6 @@ export const Livechat = { $set: { token, }, - $unset: { }, }; const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } }); @@ -235,45 +234,29 @@ export const Livechat = { } } - if (name) { - updateUser.$set.name = name; - } - if (phone) { updateUser.$set.phone = [ { phoneNumber: phone.number }, ]; - } else { - updateUser.$unset.phone = 1; } if (email && email.trim() !== '') { updateUser.$set.visitorEmails = [ { address: email }, ]; - } else { - updateUser.$unset.visitorEmails = 1; } - if (livechatData) { - updateUser.$set.livechatData = livechatData; - } else { - updateUser.$unset.livechatData = 1; - } - - if (contactManager) { - updateUser.$set.contactManager = contactManager; - } else { - updateUser.$unset.contactManager = 1; + if (name) { + updateUser.$set.name = name; } if (!department) { - updateUser.$unset.department = 1; + Object.assign(updateUser, { $unset: { department: 1 } }); } else { const dep = LivechatDepartment.findOneByIdOrName(department); updateUser.$set.department = dep && dep._id; } - if (_.isEmpty(updateUser.$unset)) { delete updateUser.$unset; } + LivechatVisitors.updateById(userId, updateUser); return userId; diff --git a/client/omnichannel/directory/ContactForm.js b/client/omnichannel/directory/ContactForm.js index a70031b22d6c..fb018ac6b77a 100644 --- a/client/omnichannel/directory/ContactForm.js +++ b/client/omnichannel/directory/ContactForm.js @@ -1,5 +1,5 @@ import React, { useState, useMemo } from 'react'; -import { Field, TextInput, Icon, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import { Field, TextInput, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useSubscription } from 'use-subscription'; @@ -95,7 +95,7 @@ export function ContactNewEdit({ id, data, reload, close }) { const [nameError, setNameError] = useState(); const [emailError, setEmailError] = useState(); - const [phoneError] = useState(); + const [phoneError, setPhoneError] = useState(); const { value: allCustomFields, phase: state } = useEndpointData('livechat/custom-fields'); @@ -118,6 +118,27 @@ export function ContactNewEdit({ id, data, reload, close }) { ? jsonConverterToValidFormat(allCustomFields.customFields) : {}), [allCustomFields]); const saveContact = useEndpointAction('POST', 'omnichannel/contact'); + const emailAlreadyExistsAction = useEndpointAction('GET', `omnichannel/contact.search?email=${ email }`); + const phoneAlreadyExistsAction = useEndpointAction('GET', `omnichannel/contact.search?phone=${ phone }`); + + const checkEmailExists = useMutableCallback(async () => { + if (!isEmail(email)) { return; } + const { contact } = await emailAlreadyExistsAction(); + if (!contact || (id && contact._id === id)) { + return setEmailError(null); + } + setEmailError(t('Email_already_exists')); + }); + + const checkPhoneExists = useMutableCallback(async () => { + if (!phone) { return; } + const { contact } = await phoneAlreadyExistsAction(); + if (!contact || (id && contact._id === id)) { + return setPhoneError(null); + } + setPhoneError(t('Phone_already_exists')); + }); + const dispatchToastMessage = useToastMessageDispatch(); @@ -125,8 +146,11 @@ export function ContactNewEdit({ id, data, reload, close }) { setNameError(!name ? t('The_field_is_required', t('Name')) : ''); }, [t, name]); useComponentDidUpdate(() => { - setEmailError(email && !isEmail(email) ? t('Validate_email_address') : undefined); + setEmailError(email && !isEmail(email) ? t('Validate_email_address') : null); }, [t, email]); + useComponentDidUpdate(() => { + !phone && setPhoneError(null); + }, [phone]); const handleSave = useMutableCallback(async (e) => { e.preventDefault(); @@ -146,9 +170,11 @@ export function ContactNewEdit({ id, data, reload, close }) { const payload = { name, - email, - phone, }; + payload.phone = phone; + payload.email = email; + payload.customFields = livechatData || {}; + payload.contactManager = username ? { username } : {}; if (id) { payload._id = id; @@ -156,8 +182,6 @@ export function ContactNewEdit({ id, data, reload, close }) { } else { payload.token = createToken(); } - if (livechatData) { payload.livechatData = livechatData; } - if (username) { payload.contactManager = { username }; } try { await saveContact(payload); @@ -169,7 +193,7 @@ export function ContactNewEdit({ id, data, reload, close }) { } }); - const formIsValid = name && !emailError; + const formIsValid = name && !emailError && !phoneError; if ([state].includes(AsyncStatePhase.LOADING)) { @@ -190,7 +214,7 @@ export function ContactNewEdit({ id, data, reload, close }) { {t('Email')} - }/> + {t(emailError)} @@ -199,7 +223,7 @@ export function ContactNewEdit({ id, data, reload, close }) { {t('Phone')} - + {t(phoneError)} diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 871acce9b9ba..f4ae1a419ed3 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1539,6 +1539,7 @@ "error-invalid-method": "Invalid method", "error-invalid-name": "Invalid name", "error-invalid-password": "Invalid password", + "error-invalid-params": "Invalid params", "error-invalid-permission": "Invalid permission", "error-invalid-priority": "Invalid priority", "error-invalid-redirectUri": "Invalid redirectUri", @@ -2921,6 +2922,7 @@ "Permissions": "Permissions", "Personal_Access_Tokens": "Personal Access Tokens", "Phone": "Phone", + "Phone_already_exists": "Phone already exists", "Phone_number": "Phone number", "Pin": "Pin", "Pin_Message": "Pin Message", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 4d0e1d47c3eb..76b039d91a3a 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -2519,6 +2519,7 @@ "Permissions": "Permissões", "Personal_Access_Tokens": "Tokens de acesso pessoal", "Phone": "Telefone", + "Phone_already_exists": "Telefone já cadastrado", "Phone_number": "Telefone", "Pin": "Fixar", "Pin_Message": "Fixar Mensagem", From 81b150bf7135f9aa537fd5d1d12bbb2e42d7cefd Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Wed, 13 Jan 2021 00:27:03 -0300 Subject: [PATCH 19/98] [FIX] Wrong userId when open own user profile (#20181) --- client/views/room/MemberListRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/views/room/MemberListRouter.js b/client/views/room/MemberListRouter.js index 7ab507badf84..ca3a9a0ec855 100644 --- a/client/views/room/MemberListRouter.js +++ b/client/views/room/MemberListRouter.js @@ -19,7 +19,7 @@ const MemberListRouter = ({ rid }) => { return ; } - return uid !== ownUserId).shift() }} onClose={onClickClose} rid={rid}/>; + return uid !== ownUserId).shift() }} onClose={onClickClose} rid={rid}/>; }; export default MemberListRouter; From 0cfb988a82fe80e24d2457f18e97918306184513 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Wed, 13 Jan 2021 00:32:02 -0300 Subject: [PATCH 20/98] Regression: Change sort icon (#20177) --- client/components/SortList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/SortList.js b/client/components/SortList.js index 0f27e2436dfd..0d800b6ac349 100644 --- a/client/components/SortList.js +++ b/client/components/SortList.js @@ -61,7 +61,7 @@ function SortModeList() {
    - } /> + } /> } />
From 0c5100d2c0ecc92c0728dd12473f54589a3270d1 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 13 Jan 2021 16:02:31 -0300 Subject: [PATCH 21/98] [FIX] Room's list showing all rooms with same name (#20176) --- server/startup/migrations/index.js | 1 + server/startup/migrations/v213.js | 89 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 server/startup/migrations/v213.js diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 9e53f75c3b96..307fd414164c 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -209,4 +209,5 @@ import './v209'; import './v210'; import './v211'; import './v212'; +import './v213'; import './xrun'; diff --git a/server/startup/migrations/v213.js b/server/startup/migrations/v213.js new file mode 100644 index 000000000000..7752f0d047cd --- /dev/null +++ b/server/startup/migrations/v213.js @@ -0,0 +1,89 @@ +import { Migrations } from '../../../app/migrations'; +import { Subscriptions, Rooms } from '../../../app/models/server/raw'; + +const updateSubscriptions = async () => { + const cursor = Subscriptions.find({ t: 'd' }, { projection: { rid: 1, u: 1 } }); + + let actions = []; + for await (const sub of cursor) { + const room = await Rooms.findOne({ _id: sub.rid }, { projection: { usernames: 1 } }); + if (!room) { + console.log(`[migration] room record not found: ${ sub.rid }`); + continue; + } + + if (!room.usernames || room.usernames.length === 0) { + console.log(`[migration] room without usernames: ${ sub.rid }`); + continue; + } + + const name = room.usernames + .filter((u) => u !== sub.u.username) + .sort() + .join(', ') || sub.u.username; + if (!name) { + console.log(`[migration] subscription without name ${ sub._id }`); + continue; + } + + actions.push({ + updateMany: { + filter: { _id: sub._id }, + update: { $set: { name, _updatedAt: new Date() } }, + }, + }); + if (actions.length === 1000) { + await Subscriptions.col.bulkWrite(actions, { ordered: false }); + actions = []; + } + } + if (actions.length) { + await Subscriptions.col.bulkWrite(actions, { ordered: false }); + } +}; + +Migrations.add({ + version: 213, + up() { + Promise.await((async () => { + const options = { + projection: { rid: 1, u: 1, name: 1 }, + }; + const cursor = Subscriptions.find({ t: 'd' }, options).sort({ _updatedAt: 1 }).limit(100); + const total = await cursor.count(); + + // if number of subscription is low, we can go ahead and fix them all + if (total < 1000) { + return updateSubscriptions(); + } + + // otherwise we'll first see if they're broken + const subs = await cursor.toArray(); + const subsTotal = subs.length; + + const subsWithRoom = await Promise.all(subs.map(async (sub) => ({ + sub, + room: await Rooms.findOne({ _id: sub.rid }, { projection: { usernames: 1 } }), + }))); + + const wrongSubs = subsWithRoom + .filter(({ room }) => room && room.usernames && room.usernames.length > 0) + .filter(({ room, sub }) => { + const name = room.usernames + .filter((u) => u !== sub.u.username) + .sort() + .join(', ') || sub.u.username; + + return name !== sub.name; + }).length; + + + // if less then 5% of subscriptions are wrong, we're fine, doesn't need to do anything + if (wrongSubs / subsTotal < 0.05) { + return; + } + + return updateSubscriptions(); + })()); + }, +}); From 7b864ed43b9eea8edcfc9b45b0e6d7a6e2e998a6 Mon Sep 17 00:00:00 2001 From: Lucas Sartor Chauvin Date: Thu, 14 Jan 2021 01:17:12 -0300 Subject: [PATCH 22/98] Chore: Change console.warning() to console.warn() (#20200) --- ee/app/auditing/server/methods.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/app/auditing/server/methods.js b/ee/app/auditing/server/methods.js index fb781ce340f8..4055bca3cb14 100644 --- a/ee/app/auditing/server/methods.js +++ b/ee/app/auditing/server/methods.js @@ -26,7 +26,7 @@ const getRoomInfoByAuditParams = ({ type, roomId, users, visitor, agent }) => { } if (type === 'l') { - console.warning('Deprecation Warning! This method will be removed in the next version (4.0.0)'); + console.warn('Deprecation Warning! This method will be removed in the next version (4.0.0)'); const rooms = LivechatRooms.findByVisitorIdAndAgentId(visitor, agent, { fields: { _id: 1 } }).fetch(); return rooms && rooms.length && { rids: rooms.map(({ _id }) => _id), name: TAPi18n.__('Omnichannel') }; } From ff5e26d3b4c5c24a8a93409c08bb5430fcfb8500 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Thu, 14 Jan 2021 09:10:58 -0300 Subject: [PATCH 23/98] Regression: Info Page Icon style and usage graph breaking (#20180) --- client/components/Card/Card.js | 13 ++++++- client/components/Card/Card.stories.js | 8 ++--- client/views/admin/info/InformationRoute.js | 26 ++++++++++++++ client/views/admin/info/LicenseCard.js | 2 +- client/views/admin/info/UsageCard.js | 38 ++++++++++----------- client/views/admin/info/UsagePieGraph.js | 6 ++-- 6 files changed, 66 insertions(+), 27 deletions(-) diff --git a/client/components/Card/Card.js b/client/components/Card/Card.js index 01200cfc13c3..56be7526e96a 100644 --- a/client/components/Card/Card.js +++ b/client/components/Card/Card.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Divider } from '@rocket.chat/fuselage'; +import { Box, Divider, Icon } from '@rocket.chat/fuselage'; export const DOUBLE_COLUMN_CARD_WIDTH = 552; @@ -19,6 +19,16 @@ const CardDivider = () => {children}; +const CardIcon = ({ name, children, ...props }) => + {children || } +; + Object.assign(Col, { Title: ColTitle, Section: ColSection, @@ -30,6 +40,7 @@ Object.assign(Card, { Col, Footer, Divider: CardDivider, + Icon: CardIcon, }); export default Card; diff --git a/client/components/Card/Card.stories.js b/client/components/Card/Card.stories.js index c950beba69e3..0f36f8911a15 100644 --- a/client/components/Card/Card.stories.js +++ b/client/components/Card/Card.stories.js @@ -61,10 +61,10 @@ export const Double = () => A Section -
A bunch of stuff
-
A bunch of stuff
-
A bunch of stuff
-
A bunch of stuff
+ A bunch of stuff + A bunch of stuff + A bunch of stuff + A bunch of stuff
Another Section diff --git a/client/views/admin/info/InformationRoute.js b/client/views/admin/info/InformationRoute.js index bcc2f565ddb4..bcf671a1715c 100644 --- a/client/views/admin/info/InformationRoute.js +++ b/client/views/admin/info/InformationRoute.js @@ -1,15 +1,20 @@ import React, { useState, useEffect } from 'react'; +import { Callout, ButtonGroup, Button, Icon } from '@rocket.chat/fuselage'; import { usePermission } from '../../../contexts/AuthorizationContext'; import NotAuthorizedPage from '../../../components/NotAuthorizedPage'; +import Page from '../../../components/Page'; import { useMethod, useServerInformation, useEndpoint } from '../../../contexts/ServerContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; import { downloadJsonAs } from '../../../lib/download'; import NewInformationPage from './NewInformationPage'; const InformationRoute = React.memo(function InformationRoute() { + const t = useTranslation(); const canViewStatistics = usePermission('view-statistics'); const [isLoading, setLoading] = useState(true); + const [error, setError] = useState(); const [statistics, setStatistics] = useState({}); const [instances, setInstances] = useState([]); const [fetchStatistics, setFetchStatistics] = useState(() => () => ({})); @@ -21,6 +26,7 @@ const InformationRoute = React.memo(function InformationRoute() { const fetchStatistics = async ({ refresh = false } = {}) => { setLoading(true); + setError(false); try { const [statistics, instances] = await Promise.all([ @@ -33,6 +39,8 @@ const InformationRoute = React.memo(function InformationRoute() { } setStatistics(statistics); setInstances(instances); + } catch (error) { + setError(error); } finally { setLoading(false); } @@ -65,6 +73,23 @@ const InformationRoute = React.memo(function InformationRoute() { downloadJsonAs(statistics, 'statistics'); }; + if (error) { + return + + + + + + + + {t('Error_loading_pages')} {/* : {error.message || error.stack}*/} + + + ; + } + if (canViewStatistics) { return ; } + return ; }); diff --git a/client/views/admin/info/LicenseCard.js b/client/views/admin/info/LicenseCard.js index 12feeea07325..fe42b59d37ed 100644 --- a/client/views/admin/info/LicenseCard.js +++ b/client/views/admin/info/LicenseCard.js @@ -30,7 +30,7 @@ const LicenseCard = ({ statistics, isLoading }) => { const { value, phase, error } = useEndpointData('licenses.get'); const endpointLoading = phase === AsyncStatePhase.LOADING; - const { maxActiveUsers = 0, modules = [] } = endpointLoading || error ? {} : value.licenses[0]; + const { maxActiveUsers = 0, modules = [] } = endpointLoading || error || !value.licenses.length ? {} : value.licenses[0]; const hasEngagement = modules.includes('engagement-dashboard'); const hasOmnichannel = modules.includes('livechat-enterprise'); diff --git a/client/views/admin/info/UsageCard.js b/client/views/admin/info/UsageCard.js index d9c494c1f806..e784c5787f78 100644 --- a/client/views/admin/info/UsageCard.js +++ b/client/views/admin/info/UsageCard.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Skeleton, Icon, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { Box, Skeleton, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import DotLeader from '../../../components/DotLeader'; @@ -11,7 +11,7 @@ import { useRoute } from '../../../contexts/RouterContext'; import { useHasLicense } from '../../../../ee/client/hooks/useHasLicense'; const TextSeparator = ({ label, value }) => - {label} + {label} {value} ; @@ -36,23 +36,23 @@ const UsageCard = React.memo(function UsageCard({ statistics, isLoading, vertica {t('Users')} {t('Total')}} + label={<> {t('Total')}} value={s(() => statistics.totalUsers)} /> {t('Online')}} + label={<> {t('Online')}} value={s(() => statistics.onlineUsers)} /> {t('Busy')}} + label={<> {t('Busy')}} value={s(() => statistics.busyUsers)} /> {t('Away')}} + label={<> {t('Away')}} value={s(() => statistics.awayUsers)} /> {t('Offline')}} + label={<> {t('Offline')}} value={s(() => statistics.offlineUsers)} /> @@ -96,54 +96,54 @@ const UsageCard = React.memo(function UsageCard({ statistics, isLoading, vertica {t('Rooms')} {t('Stats_Total_Rooms')}} + label={<> {t('Stats_Total_Rooms')}} value={s(() => statistics.totalRooms)} /> {t('Stats_Total_Channels')}} + label={<> {t('Stats_Total_Channels')}} value={s(() => statistics.totalChannels)} /> {t('Stats_Total_Private_Groups')}} + label={<> {t('Stats_Total_Private_Groups')}} value={s(() => statistics.totalPrivateGroups)} /> {t('Stats_Total_Direct_Messages')}} + label={<> {t('Stats_Total_Direct_Messages')}} value={s(() => statistics.totalDirect)} /> {t('Total_Discussions')}} + label={<> {t('Total_Discussions')}} value={s(() => statistics.totalDiscussions)} /> {t('Stats_Total_Livechat_Rooms')}} + label={<> {t('Stats_Total_Livechat_Rooms')}} value={s(() => statistics.totalLivechat)} /> {t('Messages')} {t('Stats_Total_Messages')}} + label={t('Stats_Total_Messages')} value={s(() => statistics.totalMessages)} /> {t('Total_Threads')}} + label={t('Total_Threads')} value={s(() => statistics.totalThreads)} /> {t('Stats_Total_Messages_Channel')}} + label={t('Stats_Total_Messages_Channel')} value={s(() => statistics.totalChannelMessages)} /> {t('Stats_Total_Messages_PrivateGroup')}} + label={t('Stats_Total_Messages_PrivateGroup')} value={s(() => statistics.totalPrivateGroupMessages)} /> {t('Stats_Total_Messages_Direct')}} + label={t('Stats_Total_Messages_Direct')} value={s(() => statistics.totalDirectMessages)} /> {t('Stats_Total_Messages_Livechat')}} + label={t('Stats_Total_Messages_Livechat')} value={s(() => statistics.totalLivechatMessages)} /> diff --git a/client/views/admin/info/UsagePieGraph.js b/client/views/admin/info/UsagePieGraph.js index 7c64474cc652..902548047803 100644 --- a/client/views/admin/info/UsagePieGraph.js +++ b/client/views/admin/info/UsagePieGraph.js @@ -18,6 +18,8 @@ const UsageGraph = ({ used = 0, total = 0, label, color, size }) => { const getColor = useCallback((data) => graphColors(color)[data.id], [color]); + const unlimited = total === 0; + return @@ -40,11 +42,11 @@ const UsageGraph = ({ used = 0, total = 0, label, color, size }) => { fontScale='p2' style={{ left: 0, right: 0, top: 0, bottom: 0 }} > - {Number((100 / total) * used).toFixed(2)}% + {unlimited ? '∞' : `${ Number((100 / total) * used).toFixed(2) }%`} - {used} / {total} + {used} / {unlimited ? '∞' : total} {label} ; }; From 28a25778b438713e55841e3627664ea1ae325bd5 Mon Sep 17 00:00:00 2001 From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Fri, 15 Jan 2021 01:24:41 +0530 Subject: [PATCH 24/98] [FIX] Invalid filters on the Omnichannel Analytics page (#19899) * [FIX] Omnichannel Analytics Page - Fix layout not rendering properly - handle some edge cases for date select * Fix error logs appearing on server * Change layout for filter inputs * Apply suggestions from code review * Apply suggestion from code review * Apply suggestions from code review * Minor fix - renaming issue Co-authored-by: Renato Becker --- .../omnichannel/analytics/AgentOverview.js | 27 +++++-- .../omnichannel/analytics/AnalyticsPage.js | 30 +++----- .../omnichannel/analytics/DateRangePicker.js | 73 +++++++------------ .../analytics/InterchangeableChart.js | 9 ++- .../views/omnichannel/analytics/Overview.js | 31 +++++--- 5 files changed, 82 insertions(+), 88 deletions(-) diff --git a/client/views/omnichannel/analytics/AgentOverview.js b/client/views/omnichannel/analytics/AgentOverview.js index 30d88d1d77e1..210d0b0f7f51 100644 --- a/client/views/omnichannel/analytics/AgentOverview.js +++ b/client/views/omnichannel/analytics/AgentOverview.js @@ -1,8 +1,8 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect, useState } from 'react'; import { Table } from '@rocket.chat/fuselage'; import { useTranslation } from '../../../contexts/TranslationContext'; -import { useMethodData } from '../../../hooks/useMethodData'; +import { useMethod } from '../../../contexts/ServerContext'; const style = { width: '100%' }; @@ -10,22 +10,35 @@ const AgentOverview = ({ type, dateRange, departmentId }) => { const t = useTranslation(); const { start, end } = dateRange; - const params = useMemo(() => [{ + const params = useMemo(() => ({ chartOptions: { name: type }, daterange: { from: start, to: end }, ...departmentId && { departmentId }, - }], [departmentId, end, start, type]); + }), [departmentId, end, start, type]); - const { value: displayData } = useMethodData('livechat:getAgentOverviewData', params); + const [displayData, setDisplayData] = useState({ head: [], data: [] }); + + const loadData = useMethod('livechat:getAgentOverviewData'); + + useEffect(() => { + async function fetchData() { + if (!start || !end) { + return; + } + const value = await loadData(params); + setDisplayData(value); + } + fetchData(); + }, [start, end, loadData, params]); return - {displayData?.head?.map(({ name }, i) => { t(name) })} + {displayData.head?.map(({ name }, i) => { t(name) })} - {displayData?.data?.map(({ name, value }, i) => + {displayData.data?.map(({ name, value }, i) => {name} {value} )} diff --git a/client/views/omnichannel/analytics/AnalyticsPage.js b/client/views/omnichannel/analytics/AnalyticsPage.js index b68ade11709e..c5669bcb522b 100644 --- a/client/views/omnichannel/analytics/AnalyticsPage.js +++ b/client/views/omnichannel/analytics/AnalyticsPage.js @@ -1,5 +1,5 @@ import React, { useMemo, useState, useEffect } from 'react'; -import { Box, Select, Margins, Field } from '@rocket.chat/fuselage'; +import { Box, Select, Margins, Field, Label } from '@rocket.chat/fuselage'; import DepartmentAutoComplete from '../DepartmentAutoComplete'; import DateRangePicker from './DateRangePicker'; @@ -50,26 +50,16 @@ const AnalyticsPage = () => { - - - - - {t('Type')} - - - + + + + + diff --git a/client/views/omnichannel/analytics/DateRangePicker.js b/client/views/omnichannel/analytics/DateRangePicker.js index 33cdf4f118e1..512eb050adda 100644 --- a/client/views/omnichannel/analytics/DateRangePicker.js +++ b/client/views/omnichannel/analytics/DateRangePicker.js @@ -1,42 +1,23 @@ import React, { useState, useMemo, useEffect } from 'react'; -import { Box, InputBox, Menu, Margins, Field } from '@rocket.chat/fuselage'; +import { Box, InputBox, Menu, Field } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import moment from 'moment'; import { useTranslation } from '../../../contexts/TranslationContext'; -const date = new Date(); +const formatToDateInput = (date) => date.format('YYYY-MM-DD'); -const formatToDateInput = (date) => date.toISOString().slice(0, 10); +const todayDate = formatToDateInput(moment()); -const todayDate = formatToDateInput(date); +const getMonthRange = (monthsToSubtractFromToday) => ({ + start: formatToDateInput(moment().subtract(monthsToSubtractFromToday, 'month').date(1)), + end: formatToDateInput(monthsToSubtractFromToday === 0 ? moment() : moment().subtract(monthsToSubtractFromToday).date(0)), +}); -const getMonthRange = (monthsToSubtractFromToday) => { - const date = new Date(); - return { - start: formatToDateInput(new Date( - date.getFullYear(), - date.getMonth() - monthsToSubtractFromToday, - 1)), - end: formatToDateInput(new Date( - date.getFullYear(), - date.getMonth() - monthsToSubtractFromToday + 1, - 0)), - }; -}; - -const getWeekRange = (daysToSubtractFromStart, daysToSubtractFromEnd) => { - const date = new Date(); - return { - start: formatToDateInput(new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - daysToSubtractFromStart)), - end: formatToDateInput(new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - daysToSubtractFromEnd)), - }; -}; +const getWeekRange = (daysToSubtractFromStart, daysToSubtractFromEnd) => ({ + start: formatToDateInput(moment().subtract(daysToSubtractFromStart, 'day')), + end: formatToDateInput(moment().subtract(daysToSubtractFromEnd, 'day')), +}); const DateRangePicker = ({ onChange = () => {}, ...props }) => { const t = useTranslation(); @@ -110,22 +91,20 @@ const DateRangePicker = ({ onChange = () => {}, ...props }) => { }, }), [handleRange, t]); - return - - - {t('Start')} - - - - - - {t('End')} - - - - - - + return + + {t('Start')} + + + + + + {t('End')} + + + + + ; }; diff --git a/client/views/omnichannel/analytics/InterchangeableChart.js b/client/views/omnichannel/analytics/InterchangeableChart.js index d29c323ccbff..1b5bfad12e47 100644 --- a/client/views/omnichannel/analytics/InterchangeableChart.js +++ b/client/views/omnichannel/analytics/InterchangeableChart.js @@ -24,9 +24,12 @@ const InterchangeableChart = ({ departmentId, dateRange, chartName, ...props }) const draw = useMutableCallback(async (params) => { try { + if (!params?.daterange?.from || !params?.daterange?.to) { + return; + } const result = await loadData(params); - if (!(result && result.chartLabel && result.dataLabels && result.dataPoints)) { - return console.log('livechat:getAnalyticsChartData => Missing Data'); + if (!result?.chartLabel || !result?.dataLabels || !result?.dataPoints) { + throw new Error('Error! fetching chart data. Details: livechat:getAnalyticsChartData => Missing Data'); } context.current = await drawLineChart(canvas.current, context.current, [result.chartLabel], result.dataLabels, [result.dataPoints]); } catch (error) { @@ -43,7 +46,7 @@ const InterchangeableChart = ({ departmentId, dateRange, chartName, ...props }) chartOptions: { name: chartName }, ...departmentId && { departmentId }, }); - }, [chartName, departmentId, draw, end, start, t]); + }, [chartName, departmentId, draw, end, start, t, loadData]); return ; }; diff --git a/client/views/omnichannel/analytics/Overview.js b/client/views/omnichannel/analytics/Overview.js index 2e151192089d..2195c855368b 100644 --- a/client/views/omnichannel/analytics/Overview.js +++ b/client/views/omnichannel/analytics/Overview.js @@ -4,8 +4,7 @@ import { Box, Skeleton } from '@rocket.chat/fuselage'; import CounterItem from '../realTimeMonitoring/counter/CounterItem'; import CounterRow from '../realTimeMonitoring/counter/CounterRow'; import { useTranslation } from '../../../contexts/TranslationContext'; -import { useMethodData } from '../../../hooks/useMethodData'; -import { AsyncStatePhase } from '../../../hooks/useAsyncState'; +import { useMethod } from '../../../contexts/ServerContext'; const initialData = Array.from({ length: 3 }).map(() => ({ title: '', value: '' })); @@ -17,13 +16,13 @@ const Overview = ({ type, dateRange, departmentId }) => { const { start, end } = dateRange; - const params = useMemo(() => [{ + const params = useMemo(() => ({ analyticsOptions: { name: type }, daterange: { from: start, to: end }, ...departmentId && { departmentId }, - }], [departmentId, end, start, type]); + }), [departmentId, end, start, type]); - const { phase, value } = useMethodData('livechat:getAnalyticsOverviewData', params); + const loadData = useMethod('livechat:getAnalyticsOverviewData'); const [displayData, setDisplayData] = useState(conversationsInitialData); @@ -32,14 +31,24 @@ const Overview = ({ type, dateRange, departmentId }) => { }, [type]); useEffect(() => { - if (phase === AsyncStatePhase.RESOLVED) { - if (value?.length > 3) { - setDisplayData([value.slice(0, 3), value.slice(3)]); - } else if (value) { - setDisplayData([value]); + async function fetchData() { + if (!start || !end) { + return; } + + const value = await loadData(params); + if (!value) { + return; + } + + if (value.length > 3) { + return setDisplayData([value.slice(0, 3), value.slice(3)]); + } + + setDisplayData([value]); } - }, [value, phase]); + fetchData(); + }, [start, end, loadData, params]); return Date: Fri, 15 Jan 2021 15:39:34 -0300 Subject: [PATCH 25/98] [IMPROVE] Message Collection Hooks (#20121) --- .eslintrc | 13 +- app/ui-utils/client/config.js | 1 + client/contexts/ServerContext.ts | 17 +- client/hooks/lists/useRecordList.ts | 65 +++ .../hooks/lists/useScrollableMessageList.ts | 30 ++ client/hooks/lists/useScrollableRecordList.ts | 28 ++ .../lists/useStreamUpdatesForMessageList.ts | 80 ++++ client/hooks/useAsyncState.ts | 149 ++----- client/lib/asyncState/AsyncState.ts | 10 + client/lib/asyncState/AsyncStatePhase.ts | 6 + client/lib/asyncState/functions.ts | 106 +++++ client/lib/asyncState/index.ts | 3 + client/lib/lists/DiscussionsList.ts | 55 +++ client/lib/lists/MessageList.ts | 12 + client/lib/lists/RecordList.ts | 169 ++++++++ client/lib/lists/ThreadsList.ts | 90 ++++ client/lib/minimongo/bson.spec.ts | 37 ++ client/lib/minimongo/bson.ts | 184 ++++++++ client/lib/minimongo/comparisons.ts | 84 ++++ client/lib/minimongo/index.ts | 6 + client/lib/minimongo/lookups.spec.ts | 13 + client/lib/minimongo/lookups.ts | 42 ++ client/lib/minimongo/query.ts | 275 ++++++++++++ client/lib/minimongo/sort.ts | 75 ++++ client/lib/minimongo/types.ts | 73 ++++ client/providers/ServerProvider.js | 12 +- client/views/room/Header/icons/Translate.js | 2 +- .../room/contextualBar/Discussions/index.js | 90 +--- .../Discussions/useDiscussionsList.ts | 63 +++ .../views/room/contextualBar/Threads/index.js | 397 ++++++++++-------- .../contextualBar/Threads/useThreadsList.ts | 64 +++ definition/IMessage.ts | 7 + definition/ObjectFromApi.ts | 3 + 33 files changed, 1889 insertions(+), 372 deletions(-) create mode 100644 client/hooks/lists/useRecordList.ts create mode 100644 client/hooks/lists/useScrollableMessageList.ts create mode 100644 client/hooks/lists/useScrollableRecordList.ts create mode 100644 client/hooks/lists/useStreamUpdatesForMessageList.ts create mode 100644 client/lib/asyncState/AsyncState.ts create mode 100644 client/lib/asyncState/AsyncStatePhase.ts create mode 100644 client/lib/asyncState/functions.ts create mode 100644 client/lib/asyncState/index.ts create mode 100644 client/lib/lists/DiscussionsList.ts create mode 100644 client/lib/lists/MessageList.ts create mode 100644 client/lib/lists/RecordList.ts create mode 100644 client/lib/lists/ThreadsList.ts create mode 100644 client/lib/minimongo/bson.spec.ts create mode 100644 client/lib/minimongo/bson.ts create mode 100644 client/lib/minimongo/comparisons.ts create mode 100644 client/lib/minimongo/index.ts create mode 100644 client/lib/minimongo/lookups.spec.ts create mode 100644 client/lib/minimongo/lookups.ts create mode 100644 client/lib/minimongo/query.ts create mode 100644 client/lib/minimongo/sort.ts create mode 100644 client/lib/minimongo/types.ts create mode 100644 client/views/room/contextualBar/Discussions/useDiscussionsList.ts create mode 100644 client/views/room/contextualBar/Threads/useThreadsList.ts create mode 100644 definition/ObjectFromApi.ts diff --git a/.eslintrc b/.eslintrc index d1d22fdb7fa5..e486bf36405e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -83,7 +83,9 @@ "indent": "off", "no-extra-parens": "off", "no-spaced-func": "off", + "no-unused-vars": "off", "no-useless-constructor": "off", + "no-use-before-define": "off", "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", "react/jsx-no-undef": "error", @@ -99,6 +101,10 @@ "SwitchCase": 1 } ], + "@typescript-eslint/interface-name-prefix": [ + "error", + "always" + ], "@typescript-eslint/no-extra-parens": [ "error", "all", @@ -111,10 +117,9 @@ } ], "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/interface-name-prefix": [ - "error", - "always" - ] + "@typescript-eslint/no-unused-vars": ["error", { + "argsIgnorePattern": "^_" + }] }, "env": { "browser": true, diff --git a/app/ui-utils/client/config.js b/app/ui-utils/client/config.js index 996c37ebeabb..ce484f86a7dd 100644 --- a/app/ui-utils/client/config.js +++ b/app/ui-utils/client/config.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; const url = new URL(window.location); const keys = new Set(); + export const getConfig = (key) => { keys.add(key); return url.searchParams.get(key) || Meteor._localStorage.getItem(`rc-config-${ key }`); diff --git a/client/contexts/ServerContext.ts b/client/contexts/ServerContext.ts index 3ef3ceb69bf2..bb252b2ba37a 100644 --- a/client/contexts/ServerContext.ts +++ b/client/contexts/ServerContext.ts @@ -1,17 +1,12 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; -interface IServerStream { - on(eventName: string, callback: (data: any) => void): void; - off(eventName: string, callback: (data: any) => void): void; -} - type ServerContextValue = { info: {}; absoluteUrl: (path: string) => string; callMethod: (methodName: string, ...args: any[]) => Promise; callEndpoint: (httpMethod: 'GET' | 'POST' | 'DELETE', endpoint: string, ...args: any[]) => Promise; uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise; - getStream: (streamName: string, options?: {}) => IServerStream; + getStream: (streamName: string, options?: {}) => (eventName: string, callback: (data: T) => void) => () => void; }; export const ServerContext = createContext({ @@ -20,10 +15,7 @@ export const ServerContext = createContext({ callMethod: async () => undefined, callEndpoint: async () => undefined, uploadToEndpoint: async () => undefined, - getStream: () => ({ - on: (): void => undefined, - off: (): void => undefined, - }), + getStream: () => () => (): void => undefined, }); export const useServerInformation = (): {} => useContext(ServerContext).info; @@ -45,7 +37,10 @@ export const useUpload = (endpoint: string): (params: any, formData: any) => Pro return useCallback((params, formData: any) => uploadToEndpoint(endpoint, params, formData), [endpoint, uploadToEndpoint]); }; -export const useStream = (streamName: string, options?: {}): IServerStream => { +export const useStream = ( + streamName: string, + options?: {}, +): (eventName: string, callback: (data: T) => void) => (() => void) => { const { getStream } = useContext(ServerContext); return useMemo(() => getStream(streamName, options), [getStream, streamName, options]); }; diff --git a/client/hooks/lists/useRecordList.ts b/client/hooks/lists/useRecordList.ts new file mode 100644 index 000000000000..44be8269d88f --- /dev/null +++ b/client/hooks/lists/useRecordList.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +import { AsyncStatePhase } from '../../lib/asyncState'; +import { RecordList } from '../../lib/lists/RecordList'; +import { IRocketChatRecord } from '../../../definition/IRocketChatRecord'; + +type RecordListValue = { + phase: AsyncStatePhase; + items: T[]; + itemCount: number; + error: Error | undefined; +} + +export const useRecordList = ( + recordList: RecordList, +): RecordListValue => { + const [state, setState] = useState>(() => ({ + phase: recordList.phase, + items: recordList.items, + itemCount: recordList.itemCount, + error: undefined, + })); + + useEffect(() => { + const disconnectMutatingEvent = recordList.on('mutating', () => { + setState(() => ({ + phase: recordList.phase, + items: recordList.items, + itemCount: recordList.itemCount, + error: undefined, + })); + }); + + const disconnectMutatedEvent = recordList.on('mutated', () => { + setState((prevState) => ({ + phase: recordList.phase, + items: recordList.items, + itemCount: recordList.itemCount, + error: prevState.error, + })); + }); + + const disconnectClearedEvent = recordList.on('cleared', () => { + setState(() => ({ + phase: recordList.phase, + items: recordList.items, + itemCount: recordList.itemCount, + error: undefined, + })); + }); + + const disconnectErroredEvent = recordList.on('errored', (error) => { + setState((state) => ({ ...state, error })); + }); + + return (): void => { + disconnectMutatingEvent(); + disconnectMutatedEvent(); + disconnectClearedEvent(); + disconnectErroredEvent(); + }; + }, [recordList]); + + return state; +}; diff --git a/client/hooks/lists/useScrollableMessageList.ts b/client/hooks/lists/useScrollableMessageList.ts new file mode 100644 index 000000000000..b77238144930 --- /dev/null +++ b/client/hooks/lists/useScrollableMessageList.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; + +import { IMessage } from '../../../definition/IMessage'; +import { MessageList } from '../../lib/lists/MessageList'; +import { ObjectFromApi } from '../../../definition/ObjectFromApi'; +import { useScrollableRecordList } from './useScrollableRecordList'; +import { RecordListBatchChanges } from '../../lib/lists/RecordList'; + +const convertMessageFromApi = (apiMessage: ObjectFromApi): IMessage => ({ + ...apiMessage, + _updatedAt: new Date(apiMessage._updatedAt), + ts: new Date(apiMessage.ts), + ...apiMessage.tlm && { tlm: new Date(apiMessage.tlm) }, +}); + +export const useScrollableMessageList = ( + messageList: MessageList, + fetchMessages: (start: number, end: number) => Promise>>, + initialItemCount?: number, +): ReturnType => { + const fetchItems = useCallback(async (start: number, end: number): Promise> => { + const batchChanges = await fetchMessages(start, end); + return { + ...batchChanges.items && { items: batchChanges.items.map(convertMessageFromApi) }, + ...batchChanges.itemCount && { itemCount: batchChanges.itemCount }, + }; + }, [fetchMessages]); + + return useScrollableRecordList(messageList, fetchItems, initialItemCount); +}; diff --git a/client/hooks/lists/useScrollableRecordList.ts b/client/hooks/lists/useScrollableRecordList.ts new file mode 100644 index 000000000000..56a12f603415 --- /dev/null +++ b/client/hooks/lists/useScrollableRecordList.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect } from 'react'; + +import { RecordList, RecordListBatchChanges } from '../../lib/lists/RecordList'; +import { IRocketChatRecord } from '../../../definition/IRocketChatRecord'; + +const INITIAL_ITEM_COUNT = 25; + +export const useScrollableRecordList = ( + recordList: RecordList, + fetchBatchChanges: (start: number, end: number) => Promise>, + initialItemCount: number = INITIAL_ITEM_COUNT, +): { + loadMoreItems: (start: number, end: number) => void; + initialItemCount: number; +} => { + const loadMoreItems = useCallback( + (start: number, end: number) => { + recordList.batchHandle(() => fetchBatchChanges(start, end)); + }, + [recordList, fetchBatchChanges], + ); + + useEffect(() => { + loadMoreItems(0, initialItemCount ?? INITIAL_ITEM_COUNT); + }, [loadMoreItems, initialItemCount]); + + return { loadMoreItems, initialItemCount }; +}; diff --git a/client/hooks/lists/useStreamUpdatesForMessageList.ts b/client/hooks/lists/useStreamUpdatesForMessageList.ts new file mode 100644 index 000000000000..f7709784e556 --- /dev/null +++ b/client/hooks/lists/useStreamUpdatesForMessageList.ts @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; + +import { useStream } from '../../contexts/ServerContext'; +import { IMessage } from '../../../definition/IMessage'; +import { + createFilterFromQuery, + FieldExpression, + Query, +} from '../../lib/minimongo'; +import { MessageList } from '../../lib/lists/MessageList'; +import { IRoom } from '../../../definition/IRoom'; +import { IUser } from '../../../definition/IUser'; + +type RoomMessagesRidEvent = IMessage; + +type NotifyRoomRidDeleteMessageEvent = { _id: IMessage['_id'] }; + +type NotifyRoomRidDeleteMessageBulkEvent = { + rid: IMessage['rid']; + excludePinned: boolean; + ignoreDiscussion: boolean; + ts: FieldExpression; + users: string[]; +}; + +const createDeleteCriteria = ( + params: NotifyRoomRidDeleteMessageBulkEvent, +): ((message: IMessage) => boolean) => { + const query: Query = { ts: params.ts }; + + if (params.excludePinned) { + query.pinned = { $ne: true }; + } + + if (params.ignoreDiscussion) { + query.drid = { $exists: false }; + } + if (params.users && params.users.length) { + query['u.username'] = { $in: params.users }; + } + + return createFilterFromQuery(query); +}; + +export const useStreamUpdatesForMessageList = (messageList: MessageList, uid: IUser['_id'] | null, rid: IRoom['_id'] | null): void => { + const subscribeToRoomMessages = useStream('room-messages'); + const subscribeToNotifyRoom = useStream('notify-room'); + + useEffect(() => { + if (!uid || !rid) { + messageList.clear(); + return; + } + + const unsubscribeFromRoomMessages = subscribeToRoomMessages(rid, (message) => { + messageList.handle(message); + }); + + const unsubscribeFromDeleteMessage = subscribeToNotifyRoom( + `${ rid }/deleteMessage`, + ({ _id: mid }) => { + messageList.remove(mid); + }, + ); + + const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom( + `${ rid }/deleteMessageBulk`, + (params) => { + const matchDeleteCriteria = createDeleteCriteria(params); + messageList.prune(matchDeleteCriteria); + }, + ); + + return (): void => { + unsubscribeFromRoomMessages(); + unsubscribeFromDeleteMessage(); + unsubscribeFromDeleteMessageBulk(); + }; + }, [subscribeToRoomMessages, subscribeToNotifyRoom, uid, rid, messageList]); +}; diff --git a/client/hooks/useAsyncState.ts b/client/hooks/useAsyncState.ts index 9f1333ab5377..83e8a9dbda01 100644 --- a/client/hooks/useAsyncState.ts +++ b/client/hooks/useAsyncState.ts @@ -1,20 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSafely } from '@rocket.chat/fuselage-hooks'; +import { useCallback, useMemo, useState } from 'react'; -export const enum AsyncStatePhase { - LOADING = 'loading', - RESOLVED = 'resolved', - REJECTED = 'rejected', - UPDATING = 'updating' -} - -export type AsyncState = ( - { phase: AsyncStatePhase.LOADING; value: undefined; error: undefined } | - { phase: AsyncStatePhase.LOADING; value: T; error: undefined } | - { phase: AsyncStatePhase.LOADING; value: undefined; error: Error } | - { phase: AsyncStatePhase.RESOLVED; value: T; error: undefined } | - { phase: AsyncStatePhase.UPDATING; value: T; error: undefined } | - { phase: AsyncStatePhase.REJECTED; value: undefined; error: Error } -); +import { AsyncStatePhase, AsyncState, asyncState } from '../lib/asyncState'; type AsyncStateObject = AsyncState & { resolve: (value: T | ((prev: T | undefined) => T)) => void; @@ -24,123 +11,42 @@ type AsyncStateObject = AsyncState & { }; export const useAsyncState = (initialValue?: T | (() => T)): AsyncStateObject => { - const [state, setState] = useState>(() => { - if (typeof initialValue === 'undefined') { - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }; - } - - return { - phase: AsyncStatePhase.RESOLVED, - value: typeof initialValue === 'function' - ? (initialValue as () => T)() - : initialValue, - }; - }); - - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; + const [state, setState] = useSafely( + useState>(() => { + if (typeof initialValue === 'undefined') { + return asyncState.loading(); + } - return (): void => { - isMountedRef.current = false; - }; - }, []); + return asyncState.resolved( + typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue, + ); + }), + ); const resolve = useCallback((value: T | ((prev: T | undefined) => T)) => { - if (!isMountedRef.current) { - return; - } - setState((state) => { - if (![AsyncStatePhase.LOADING, AsyncStatePhase.UPDATING].includes(state.phase)) { - return state; + if (typeof value === 'function') { + return asyncState.resolve(state, (value as (prev: T | undefined) => T)(state.value)); } - return { - phase: AsyncStatePhase.RESOLVED, - value: typeof value === 'function' ? (value as (prev: T | undefined) => T)(state.value) : value, - error: undefined, - }; + return asyncState.resolve(state, value); }); - }, []); + }, [setState]); const reject = useCallback((error: Error) => { - if (!isMountedRef.current) { - return; - } - - setState((state) => { - if (![AsyncStatePhase.LOADING, AsyncStatePhase.UPDATING].includes(state.phase)) { - return state; - } - - return { - phase: AsyncStatePhase.REJECTED, - value: undefined, - error, - }; - }); - }, []); + setState((state) => asyncState.reject(state, error)); + }, [setState]); const update = useCallback(() => { - if (!isMountedRef.current) { - return; - } - - setState((state) => { - switch (state.phase) { - case AsyncStatePhase.LOADING: - case AsyncStatePhase.UPDATING: - return state; - case AsyncStatePhase.RESOLVED: - return { - phase: AsyncStatePhase.UPDATING, - value: state.value, - error: state.error, - }; - - case AsyncStatePhase.REJECTED: - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: state.error, - }; - } - }); - }, []); + setState((state) => asyncState.update(state)); + }, [setState]); const reset = useCallback(() => { - if (!isMountedRef.current) { - return; - } - - setState((state) => { - switch (state.phase) { - case AsyncStatePhase.LOADING: - return state; - case AsyncStatePhase.UPDATING: - case AsyncStatePhase.RESOLVED: - return { - phase: AsyncStatePhase.LOADING, - value: state.value, - error: state.error, - }; - - case AsyncStatePhase.REJECTED: - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: state.error, - }; - } - }); - }, []); + setState((state) => asyncState.reload(state)); + }, [setState]); return useMemo(() => ({ ...state, @@ -150,3 +56,8 @@ export const useAsyncState = (initialValue?: T | (() => T)): AsyncStateObject update, }), [state, resolve, reject, reset, update]); }; + +export { + AsyncStatePhase, + AsyncState, +}; diff --git a/client/lib/asyncState/AsyncState.ts b/client/lib/asyncState/AsyncState.ts new file mode 100644 index 000000000000..f02a99138c37 --- /dev/null +++ b/client/lib/asyncState/AsyncState.ts @@ -0,0 +1,10 @@ +import { AsyncStatePhase } from './AsyncStatePhase'; + +export type AsyncState = ( + { phase: AsyncStatePhase.LOADING; value: undefined; error: undefined } | + { phase: AsyncStatePhase.LOADING; value: T; error: undefined } | + { phase: AsyncStatePhase.LOADING; value: undefined; error: Error } | + { phase: AsyncStatePhase.RESOLVED; value: T; error: undefined } | + { phase: AsyncStatePhase.UPDATING; value: T; error: undefined } | + { phase: AsyncStatePhase.REJECTED; value: undefined; error: Error } +); diff --git a/client/lib/asyncState/AsyncStatePhase.ts b/client/lib/asyncState/AsyncStatePhase.ts new file mode 100644 index 000000000000..6d599f8d6751 --- /dev/null +++ b/client/lib/asyncState/AsyncStatePhase.ts @@ -0,0 +1,6 @@ +export const enum AsyncStatePhase { + LOADING = 'loading', + RESOLVED = 'resolved', + REJECTED = 'rejected', + UPDATING = 'updating' +} diff --git a/client/lib/asyncState/functions.ts b/client/lib/asyncState/functions.ts new file mode 100644 index 000000000000..f34d0fc5472c --- /dev/null +++ b/client/lib/asyncState/functions.ts @@ -0,0 +1,106 @@ +import { AsyncState } from './AsyncState'; +import { AsyncStatePhase } from './AsyncStatePhase'; + +export const loading = (): AsyncState => ({ + phase: AsyncStatePhase.LOADING, + value: undefined, + error: undefined, +}); + +export const updating = (value: T): AsyncState => ({ + phase: AsyncStatePhase.UPDATING, + value, + error: undefined, +}); + +export const resolved = (value: T): AsyncState => ({ + phase: AsyncStatePhase.RESOLVED, + value, + error: undefined, +}); + +export const rejected = (error: Error): AsyncState => ({ + phase: AsyncStatePhase.REJECTED, + value: undefined, + error, +}); + +export const reload = (prevState: AsyncState): AsyncState => { + switch (prevState.phase) { + case AsyncStatePhase.LOADING: + return prevState; + + case AsyncStatePhase.UPDATING: + case AsyncStatePhase.RESOLVED: + return { + phase: AsyncStatePhase.LOADING, + value: prevState.value, + error: undefined, + }; + + case AsyncStatePhase.REJECTED: + return { + phase: AsyncStatePhase.LOADING, + value: undefined, + error: prevState.error, + }; + } +}; + +export const update = (prevState: AsyncState): AsyncState => { + switch (prevState.phase) { + case AsyncStatePhase.LOADING: + case AsyncStatePhase.UPDATING: + return prevState; + + case AsyncStatePhase.RESOLVED: + return { + phase: AsyncStatePhase.UPDATING, + value: prevState.value, + error: undefined, + }; + + case AsyncStatePhase.REJECTED: + return { + phase: AsyncStatePhase.LOADING, + value: undefined, + error: prevState.error, + }; + } +}; + +export const resolve = (prevState: AsyncState, value: T): AsyncState => { + switch (prevState.phase) { + case AsyncStatePhase.LOADING: + case AsyncStatePhase.UPDATING: + return { + phase: AsyncStatePhase.RESOLVED, + value, + error: undefined, + }; + + case AsyncStatePhase.RESOLVED: + case AsyncStatePhase.REJECTED: + return prevState; + } +}; + +export const reject = (prevState: AsyncState, error: Error): AsyncState => { + switch (prevState.phase) { + case AsyncStatePhase.LOADING: + case AsyncStatePhase.UPDATING: + return { + phase: AsyncStatePhase.REJECTED, + value: undefined, + error, + }; + + case AsyncStatePhase.RESOLVED: + case AsyncStatePhase.REJECTED: + return prevState; + } +}; + +export const value = (state: AsyncState): T | undefined => state.value; + +export const error = (state: AsyncState): Error | undefined => state.error; diff --git a/client/lib/asyncState/index.ts b/client/lib/asyncState/index.ts new file mode 100644 index 000000000000..aaf5e6c37c20 --- /dev/null +++ b/client/lib/asyncState/index.ts @@ -0,0 +1,3 @@ +export { AsyncState } from './AsyncState'; +export { AsyncStatePhase } from './AsyncStatePhase'; +export * as asyncState from './functions'; diff --git a/client/lib/lists/DiscussionsList.ts b/client/lib/lists/DiscussionsList.ts new file mode 100644 index 000000000000..86d5184cd722 --- /dev/null +++ b/client/lib/lists/DiscussionsList.ts @@ -0,0 +1,55 @@ +import { MessageList } from './MessageList'; +import type { IMessage } from '../../../definition/IMessage'; +import { escapeRegExp } from '../../../lib/escapeRegExp'; + +type DiscussionMessage = Omit & Required>; + +export type DiscussionsListOptions = { + rid: IMessage['rid']; + text?: string; +}; + +const isDiscussionMessageInRoom = (message: IMessage, rid: IMessage['rid']): message is DiscussionMessage => + message.rid === rid && 'drid' in message; + +const isDiscussionTextMatching = (discussionMessage: DiscussionMessage, regex: RegExp): boolean => + regex.test(discussionMessage.msg); + +export class DiscussionsList extends MessageList { + public constructor(private _options: DiscussionsListOptions) { + super(); + } + + public get options(): DiscussionsListOptions { + return this._options; + } + + public updateFilters(options: DiscussionsListOptions): void { + this._options = options; + this.clear(); + } + + protected filter(message: IMessage): boolean { + const { rid } = this._options; + + if (!isDiscussionMessageInRoom(message, rid)) { + return false; + } + + if (this._options.text) { + const regex = new RegExp( + this._options.text.split(/\s/g) + .map((text) => escapeRegExp(text)).join('|'), + ); + if (!isDiscussionTextMatching(message, regex)) { + return false; + } + } + + return true; + } + + protected compare(a: IMessage, b: IMessage): number { + return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); + } +} diff --git a/client/lib/lists/MessageList.ts b/client/lib/lists/MessageList.ts new file mode 100644 index 000000000000..b18aae4783cf --- /dev/null +++ b/client/lib/lists/MessageList.ts @@ -0,0 +1,12 @@ +import type { IMessage } from '../../../definition/IMessage'; +import { RecordList } from './RecordList'; + +export class MessageList extends RecordList { + protected filter(message: IMessage): boolean { + return message._hidden !== true; + } + + protected compare(a: IMessage, b: IMessage): number { + return a.ts.getTime() - b.ts.getTime(); + } +} diff --git a/client/lib/lists/RecordList.ts b/client/lib/lists/RecordList.ts new file mode 100644 index 000000000000..42f297bedafc --- /dev/null +++ b/client/lib/lists/RecordList.ts @@ -0,0 +1,169 @@ +import { Emitter } from '@rocket.chat/emitter'; + +import type { IRocketChatRecord } from '../../../definition/IRocketChatRecord'; +import { AsyncStatePhase } from '../asyncState'; + +export type RecordListBatchChanges = { + items?: T[]; + itemCount?: number; +}; + +export class RecordList extends Emitter { + #hasChanges = false; + + #index = new Map(); + + #phase: AsyncStatePhase.LOADING | AsyncStatePhase.UPDATING | AsyncStatePhase.RESOLVED = AsyncStatePhase.LOADING + + #items: T[] | undefined = undefined; + + #itemCount: number | undefined = undefined; + + protected filter(_item: T): boolean { + return true; + } + + protected compare(a: T, b: T): number { + return a._updatedAt.getTime() - b._updatedAt.getTime(); + } + + public get phase(): AsyncStatePhase { + return this.#phase; + } + + public get items(): T[] { + if (!this.#items) { + this.#items = Array.from(this.#index.values()).sort(this.compare); + } + + return this.#items; + } + + public get itemCount(): number { + return this.#itemCount ?? this.#index.size; + } + + private insert(item: T): void { + this.#index.set(item._id, item); + this.emit(`${ item._id }/inserted`, item); + if (typeof this.#itemCount === 'number') { + this.#itemCount++; + } + this.#hasChanges = true; + } + + private update(item: T): void { + this.#index.set(item._id, item); + this.emit(`${ item._id }/updated`, item); + this.#hasChanges = true; + } + + private delete(_id: T['_id']): void { + this.#index.delete(_id); + this.emit(`${ _id }/deleted`); + if (typeof this.#itemCount === 'number') { + this.#itemCount--; + } + this.#hasChanges = true; + } + + private push(item: T): void { + const exists = this.#index.has(item._id); + const valid = this.filter(item); + + if (exists && !valid) { + this.delete(item._id); + return; + } + + if (exists && valid) { + this.update(item); + return; + } + + if (!exists && valid) { + this.insert(item); + } + } + + #pedingMutation: Promise = Promise.resolve(); + + protected async mutate(mutation: () => void | Promise): Promise { + try { + if (this.#phase === AsyncStatePhase.RESOLVED) { + this.#phase = AsyncStatePhase.UPDATING; + this.emit('mutating'); + } + + this.#pedingMutation = this.#pedingMutation.then(mutation); + await this.#pedingMutation; + } catch (error) { + this.emit('errored', error); + } finally { + const hasChanged = this.#hasChanges; + this.#phase = AsyncStatePhase.RESOLVED; + if (hasChanged) { + this.#items = undefined; + this.#hasChanges = false; + } + this.emit('mutated', hasChanged); + } + } + + public batchHandle(getInfo: () => Promise>): Promise { + return this.mutate(async () => { + const info = await getInfo(); + + if (info.items) { + for (const item of info.items) { + this.push(item); + } + } + + if (info.itemCount) { + this.#itemCount = info.itemCount; + this.#hasChanges = true; + } + }); + } + + public prune(matchCriteria: (item: T) => boolean): Promise { + return this.mutate(() => { + for (const item of this.#index.values()) { + if (matchCriteria(item)) { + this.delete(item._id); + } + } + }); + } + + public handle(item: T): Promise { + return this.mutate(() => { + this.push(item); + }); + } + + public remove(_id: T['_id']): Promise { + return this.mutate(() => { + if (!this.#index.has(_id)) { + return; + } + + this.delete(_id); + }); + } + + public clear(): Promise { + return this.mutate(() => { + if (this.#index.size === 0) { + return; + } + + this.#index.clear(); + this.#items = undefined; + this.#itemCount = undefined; + this.#hasChanges = true; + this.emit('cleared'); + }); + } +} diff --git a/client/lib/lists/ThreadsList.ts b/client/lib/lists/ThreadsList.ts new file mode 100644 index 000000000000..efa0b23f448a --- /dev/null +++ b/client/lib/lists/ThreadsList.ts @@ -0,0 +1,90 @@ +import { MessageList } from './MessageList'; +import type { IMessage } from '../../../definition/IMessage'; +import { IUser } from '../../../definition/IUser'; +import { ISubscription } from '../../../definition/ISubscription'; +import { escapeRegExp } from '../../../lib/escapeRegExp'; + +type ThreadMessage = Omit & Required>; + +export type ThreadsListOptions = { + rid: IMessage['rid']; + text?: string; +} +& ( + { + type: 'unread'; + tunread: ISubscription['tunread']; + } + | { + type: 'following'; + uid: IUser['_id']; + } + | { + type: 'all'; + } +); + +const isThreadMessageInRoom = (message: IMessage, rid: IMessage['rid']): message is ThreadMessage => + message.rid === rid && typeof (message as ThreadMessage).tcount === 'number'; + +const isThreadFollowedByUser = (threadMessage: ThreadMessage, uid: IUser['_id']): boolean => + threadMessage.replies?.includes(uid) ?? false; + +const isThreadUnread = (threadMessage: ThreadMessage, tunread: ISubscription['tunread']): boolean => + tunread.includes(threadMessage._id); + +const isThreadTextMatching = (threadMessage: ThreadMessage, regex: RegExp): boolean => + regex.test(threadMessage.msg); + +export class ThreadsList extends MessageList { + public constructor(private _options: ThreadsListOptions) { + super(); + } + + public get options(): ThreadsListOptions { + return this._options; + } + + public updateFilters(options: ThreadsListOptions): void { + this._options = options; + this.clear(); + } + + protected filter(message: IMessage): boolean { + const { rid } = this._options; + + if (!isThreadMessageInRoom(message, rid)) { + return false; + } + + if (this._options.type === 'following') { + const { uid } = this._options; + if (!isThreadFollowedByUser(message, uid)) { + return false; + } + } + + if (this._options.type === 'unread') { + const { tunread } = this._options; + if (!isThreadUnread(message, tunread)) { + return false; + } + } + + if (this._options.text) { + const regex = new RegExp( + this._options.text.split(/\s/g) + .map((text) => escapeRegExp(text)).join('|'), + ); + if (!isThreadTextMatching(message, regex)) { + return false; + } + } + + return true; + } + + protected compare(a: IMessage, b: IMessage): number { + return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); + } +} diff --git a/client/lib/minimongo/bson.spec.ts b/client/lib/minimongo/bson.spec.ts new file mode 100644 index 000000000000..4d800bf9ad8e --- /dev/null +++ b/client/lib/minimongo/bson.spec.ts @@ -0,0 +1,37 @@ +import chai from 'chai'; +import { describe, it } from 'mocha'; + +import { BSONType } from './types'; +import { getBSONType, compareBSONValues } from './bson'; + +describe('getBSONType', () => { + it('should work', () => { + chai.expect(getBSONType(1)).to.be.equals(BSONType.Double); + chai.expect(getBSONType('xyz')).to.be.equals(BSONType.String); + chai.expect(getBSONType({})).to.be.equals(BSONType.Object); + chai.expect(getBSONType([])).to.be.equals(BSONType.Array); + chai.expect(getBSONType(new Uint8Array())).to.be.equals(BSONType.BinData); + chai.expect(getBSONType(undefined)).to.be.equals(BSONType.Object); + chai.expect(getBSONType(null)).to.be.equals(BSONType.Null); + chai.expect(getBSONType(false)).to.be.equals(BSONType.Boolean); + chai.expect(getBSONType(/.*/)).to.be.equals(BSONType.Regex); + chai.expect(getBSONType(() => true)).to.be.equals(BSONType.JavaScript); + chai.expect(getBSONType(new Date(0))).to.be.equals(BSONType.Date); + }); +}); + +describe('compareBSONValues', () => { + it('should work for the same types', () => { + chai.expect(compareBSONValues(2, 3)).to.be.equals(-1); + chai.expect(compareBSONValues('xyz', 'abc')).to.be.equals(1); + chai.expect(compareBSONValues({}, {})).to.be.equals(0); + chai.expect(compareBSONValues(true, false)).to.be.equals(1); + chai.expect(compareBSONValues(new Date(0), new Date(1))).to.be.equals(-1); + }); + + it('should work for different types', () => { + chai.expect(compareBSONValues(2, null)).to.be.equals(1); + chai.expect(compareBSONValues('xyz', {})).to.be.equals(-1); + chai.expect(compareBSONValues(false, 3)).to.be.equals(1); + }); +}); diff --git a/client/lib/minimongo/bson.ts b/client/lib/minimongo/bson.ts new file mode 100644 index 000000000000..40b0a882ab98 --- /dev/null +++ b/client/lib/minimongo/bson.ts @@ -0,0 +1,184 @@ +import { BSONType } from './types'; + +export const getBSONType = (v: T): BSONType => { + if (typeof v === 'number') { + return BSONType.Double; + } + + if (typeof v === 'string') { + return BSONType.String; + } + + if (typeof v === 'boolean') { + return BSONType.Boolean; + } + + if (Array.isArray(v)) { + return BSONType.Array; + } + + if (v === null) { + return BSONType.Null; + } + + if (v instanceof RegExp) { + return BSONType.Regex; + } + + if (typeof v === 'function') { + return BSONType.JavaScript; + } + + if (v instanceof Date) { + return BSONType.Date; + } + + if (v instanceof Uint8Array) { + return BSONType.BinData; + } + + return BSONType.Object; +}; + +const getBSONTypeOrder = (type: BSONType): number => { + switch (type) { + case BSONType.Null: + return 0; + + case BSONType.Double: + case BSONType.Int: + case BSONType.Long: + return 1; + + case BSONType.String: + case BSONType.Symbol: + return 2; + + case BSONType.Object: + return 3; + + case BSONType.Array: + return 4; + + case BSONType.BinData: + return 5; + + case BSONType.ObjectId: + return 6; + + case BSONType.Boolean: + return 7; + + case BSONType.Date: + case BSONType.Timestamp: + return 8; + + case BSONType.Regex: + return 9; + + case BSONType.JavaScript: + case BSONType.JavaScriptWithScope: + return 100; + + default: + return -1; + } +}; + +type ObjectID = { + toHexString(): string; + equals(otherID: ObjectID): boolean; +}; + +export const compareBSONValues = (a: unknown, b: unknown): number => { + if (a === undefined) { + return b === undefined ? 0 : -1; + } + + if (b === undefined) { + return 1; + } + + const ta = getBSONType(a); + const oa = getBSONTypeOrder(ta); + + const tb = getBSONType(b); + const ob = getBSONTypeOrder(tb); + + if (oa !== ob) { + return oa < ob ? -1 : 1; + } + + if (ta !== tb) { + throw Error('Missing type coercion logic in compareBSONValues'); + } + + switch (ta) { + case BSONType.Double: + return (a as number) - (b as number); + + case BSONType.String: + return (a as string).localeCompare(b as string); + + case BSONType.Object: + return compareBSONValues( + Array.prototype.concat.call([], ...Object.entries(a as Record)), + Array.prototype.concat.call([], ...Object.entries(b as Record)), + ); + + case BSONType.Array: { + for (let i = 0; ; i++) { + if (i === (a as unknown[]).length) { + return i === (b as unknown[]).length ? 0 : -1; + } + + if (i === (b as unknown[]).length) { + return 1; + } + + const s = compareBSONValues((a as unknown[])[i], (b as unknown[])[i]); + if (s !== 0) { + return s; + } + } + } + + case BSONType.BinData: { + if ((a as Uint8Array).length !== (b as Uint8Array).length) { + return (a as Uint8Array).length - (b as Uint8Array).length; + } + + for (let i = 0; i < (a as Uint8Array).length; i++) { + if ((a as Uint8Array)[i] === (b as Uint8Array)[i]) { + continue; + } + + return (a as Uint8Array)[i] < (b as Uint8Array)[i] ? -1 : 1; + } + + return 0; + } + + case BSONType.Null: + case BSONType.Undefined: + return 0; + + case BSONType.ObjectId: + return (a as ObjectID).toHexString().localeCompare((b as ObjectID).toHexString()); + + case BSONType.Boolean: + return Number(a) - Number(b); + + case BSONType.Date: + return (a as Date).getTime() - (b as Date).getTime(); + + case BSONType.Regex: + throw Error('Sorting not supported on regular expression'); + + case BSONType.JavaScript: + case BSONType.JavaScriptWithScope: + throw Error('Sorting not supported on Javascript code'); + } + + throw Error('Unknown type to sort'); +}; diff --git a/client/lib/minimongo/comparisons.ts b/client/lib/minimongo/comparisons.ts new file mode 100644 index 000000000000..7dcb9b57e7ac --- /dev/null +++ b/client/lib/minimongo/comparisons.ts @@ -0,0 +1,84 @@ +export const equals = (a: T, b: T): boolean => { + if (a === b) { + return true; + } + + if (!a || !b) { + return false; + } + + if (typeof a !== 'object' || typeof b !== 'object') { + return false; + } + + if (a instanceof Date && b instanceof Date) { + return a.valueOf() === b.valueOf(); + } + + if (a instanceof Uint8Array && b instanceof Uint8Array) { + if (a.length !== b.length) { return false; } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (!equals(a[i], b[i])) { + return false; + } + } + return true; + } + + if (Object.keys(b).length !== Object.keys(a).length) { + return false; + } + + for (const key of Object.keys(a)) { + if (!(key in b)) { + return false; + } + + if (!equals((a as Record)[key], (b as Record)[key])) { + return false; + } + } + + return true; +}; + +export const isObject = (value: unknown): value is object => { + const type = typeof value; + return !!value && (type === 'object' || type === 'function'); +}; + +export const flatSome = (x: T[] | T, f: (x: T) => boolean): boolean => { + if (Array.isArray(x)) { + return x.some(f); + } + + return f(x); +}; + +export const some = (x: T | T[], f: (x: T | T[]) => boolean): boolean => { + if (f(x)) { + return true; + } + + return Array.isArray(x) && x.some(f); +}; + +export const isEmptyArray = (value: unknown): value is T[] & { length: 0 } => + Array.isArray(value) && value.length === 0; diff --git a/client/lib/minimongo/index.ts b/client/lib/minimongo/index.ts new file mode 100644 index 000000000000..426acef0b4f5 --- /dev/null +++ b/client/lib/minimongo/index.ts @@ -0,0 +1,6 @@ +import { compileDocumentSelector } from './query'; +import { compileSort } from './sort'; + +export const createFilterFromQuery = compileDocumentSelector; +export const createComparatorFromSort = compileSort; +export { FieldExpression, Query, Sort } from './types'; diff --git a/client/lib/minimongo/lookups.spec.ts b/client/lib/minimongo/lookups.spec.ts new file mode 100644 index 000000000000..736e0d3cd704 --- /dev/null +++ b/client/lib/minimongo/lookups.spec.ts @@ -0,0 +1,13 @@ +import chai from 'chai'; +import { describe, it } from 'mocha'; + +import { createLookupFunction } from './lookups'; + +describe('createLookupFunction', () => { + it('should work', () => { + chai.expect(createLookupFunction('a.x')({ a: { x: 1 } })).to.be.deep.equals([1]); + chai.expect(createLookupFunction('a.x')({ a: { x: [1] } })).to.be.deep.equals([[1]]); + chai.expect(createLookupFunction('a.x')({ a: 5 })).to.be.deep.equals([undefined]); + chai.expect(createLookupFunction('a.x')({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })).to.be.deep.equals([1, [2], undefined]); + }); +}); diff --git a/client/lib/minimongo/lookups.ts b/client/lib/minimongo/lookups.ts new file mode 100644 index 000000000000..f5fc99837c82 --- /dev/null +++ b/client/lib/minimongo/lookups.ts @@ -0,0 +1,42 @@ +import { isEmptyArray } from './comparisons'; + +const isNullDocument = (doc: unknown): doc is undefined | null => + doc === undefined || doc === null; + +const isRecordDocument = (doc: unknown): doc is Record => + doc !== undefined && doc !== null && (typeof doc === 'object' || typeof doc === 'function'); + +const isIndexedByNumber = (value: unknown, isIndexedByNumber: boolean): value is T[] => + Array.isArray(value) || isIndexedByNumber; + +export const createLookupFunction = (key: string): ((doc: T) => unknown[]) => { + const [first, rest] = key.split(/\.(.+)/); + + if (!rest) { + return (doc: T): unknown[] => { + if (isNullDocument(doc) || !isRecordDocument(doc)) { + return [undefined]; + } + + return [doc[first]]; + }; + } + + const lookupRest = createLookupFunction(rest); + const nextIsNumeric = /^\d+(\.|$)/.test(rest); + + return (doc: T): unknown[] => { + if (isNullDocument(doc) || !isRecordDocument(doc)) { + return [undefined]; + } + + const firstLevel = doc[first]; + + if (isEmptyArray(firstLevel)) { + return [undefined]; + } + + const docs = isIndexedByNumber(firstLevel, nextIsNumeric) ? firstLevel : [firstLevel as T]; + return Array.prototype.concat.apply([], docs.map(lookupRest)); + }; +}; diff --git a/client/lib/minimongo/query.ts b/client/lib/minimongo/query.ts new file mode 100644 index 000000000000..046a0fa77a3f --- /dev/null +++ b/client/lib/minimongo/query.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { equals, flatSome, isObject, some } from './comparisons'; +import { createLookupFunction } from './lookups'; +import { BSONType, FieldExpression, Query } from './types'; +import { compareBSONValues, getBSONType } from './bson'; + +const isArrayOfFields = (values: unknown[]): values is T[] => + values.every((value) => ['number', 'string', 'symbol'].includes(typeof value)); + +const $in = (operand: T[], _options: undefined): ((value: T) => boolean) => { + let index: Record | null = null; + if (isArrayOfFields(operand)) { + index = {} as Record; + for (const operandElement of operand) { + index[operandElement] = operandElement; + } + } + + return (value: T): boolean => some(value, (x) => { + if (typeof x === 'string' && index !== null) { + return !!index[x]; + } + + return operand.some((operandElement) => equals(operandElement, x)); + }); +}; + +const $nin = (operand: T[], _options: undefined): ((value: T) => boolean) => { + const isIn = $in(operand, undefined); + + return (value: T): boolean => { + if (value === undefined) { + return true; + } + + return !isIn(value); + }; +}; + +const $all = (operand: T[], _options: undefined): ((value: T) => boolean) => + (value: T): boolean => { + if (!Array.isArray(value)) { + return false; + } + + return operand.every((operandElement) => value.some((valueElement) => equals(operandElement, valueElement))); + }; + +const $lt = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) < 0); + +const $lte = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) <= 0); + +const $gt = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) > 0); + +const $gte = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) >= 0); + +const $ne = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => !some(value, (x) => equals(x, operand)); + +const $exists = (operand: boolean, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => operand === (value !== undefined); + +const $mod = ([divisor, remainder]: [number, number], _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => Number(x) % divisor === remainder); + +const $size = (operand: number, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => Array.isArray(value) && operand === value.length; + +const $type = (operand: BSONType, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => getBSONType(x) === operand); + }; + +const $regex = (operand: string | RegExp, options: string): ((value: T) => boolean) => { + let regex: RegExp; + + if (options !== undefined) { + const regexSource = operand instanceof RegExp ? operand.source : operand; + regex = new RegExp(regexSource, options); + } else if (!(operand instanceof RegExp)) { + regex = new RegExp(operand); + } + + return (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => regex.test(String(x))); + }; +}; + +const $elemMatch = (operand: Query, _options: undefined): ((value: T) => boolean) => { + const matcher = compileDocumentSelector(operand); + + return (value: T): boolean => { + if (!Array.isArray(value)) { + return false; + } + + return value.some((x) => matcher(x)); + }; +}; + +const $not = (operand: FieldExpression, _options: undefined): ((value: T) => boolean) => { + const matcher = compileValueSelector(operand); + return (value: T): boolean => !matcher(value); +}; + +const dummyOperator = (_operand: unknown, _options: undefined): ((value: T) => boolean) => + (_value: T): boolean => true; + +const $options = dummyOperator; +const $near = dummyOperator; +const $geoIntersects = dummyOperator; + +const valueOperators = { + $in, + $nin, + $all, + $lt, + $lte, + $gt, + $gte, + $ne, + $exists, + $mod, + $size, + $type, + $regex, + $elemMatch, + $not, + $options, + $near, + $geoIntersects, +} as const; + +const $and = (subSelector: Query[]): ((doc: T) => boolean) => { + const subSelectorFunctions = subSelector.map(compileDocumentSelector); + return (doc: T): boolean => subSelectorFunctions.every((f) => f(doc)); +}; + +const $or = (subSelector: Query[]): ((doc: T) => boolean) => { + const subSelectorFunctions = subSelector.map(compileDocumentSelector); + return (doc: T): boolean => subSelectorFunctions.some((f) => f(doc)); +}; + +const $nor = (subSelector: Query[]): ((doc: T) => boolean) => { + const subSelectorFunctions = subSelector.map(compileDocumentSelector); + return (doc: T): boolean => subSelectorFunctions.every((f) => !f(doc)); +}; + +const $where = (selectorValue: string | Function): ((doc: T) => boolean) => { + const fn = selectorValue instanceof Function ? selectorValue : Function(`return ${ selectorValue }`); + return (doc: T): boolean => !!fn.call(doc); +}; + +const logicalOperators = { + $and, + $or, + $nor, + $where, +} as const; + +const isValueOperator = (operator: string): operator is keyof typeof valueOperators => + operator in valueOperators; + +const isLogicalOperator = (operator: string): operator is keyof typeof logicalOperators => + operator in logicalOperators; + +const hasValueOperators = (valueSelector: FieldExpression): boolean => + Object.keys(valueSelector).every((key) => key.slice(0, 1) === '$'); + +const compileUndefinedOrNullSelector = (): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => x === undefined || x === null); + +const compilePrimitiveSelector = (primitive: T) => + (value: T): boolean => flatSome(value, (x) => x === primitive); + +const compileRegexSelector = (regex: RegExp) => + (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => regex.test(String(x))); + }; + +const compileArraySelector = (expected: T) => + (value: T): boolean => { + if (!Array.isArray(value)) { + return false; + } + + return some(value, (x) => equals(expected, x)); + }; + +const compileValueOperatorsSelector = (expression: FieldExpression): ((value: T) => boolean) => { + const operatorFunctions: ((value: T) => boolean)[] = []; + for (const operator of Object.keys(expression) as (keyof FieldExpression)[]) { + if (!isValueOperator(operator)) { + continue; + } + + const operand = expression[operator]; + const operation = valueOperators[operator] as unknown as ((operand: unknown, options: unknown) => (value: T) => boolean); + operatorFunctions.push(operation(operand, expression.$options)); + } + return (value: T): boolean => operatorFunctions.every((f) => f(value)); +}; + +const compileValueSelector = (valueSelector: FieldExpression[keyof FieldExpression]): ((value: T) => boolean) => { + if (valueSelector === undefined || valueSelector === null) { + return compileUndefinedOrNullSelector(); + } + + if (!isObject(valueSelector)) { + return compilePrimitiveSelector(valueSelector as T); + } + + if (valueSelector instanceof RegExp) { + return compileRegexSelector(valueSelector); + } + + if (Array.isArray(valueSelector)) { + return compileArraySelector(valueSelector as unknown as T); + } + + if (hasValueOperators(valueSelector)) { + return compileValueOperatorsSelector(valueSelector); + } + + return (value: T): boolean => flatSome(value, (x) => equals(valueSelector, x as unknown as object)); +}; + +export const compileDocumentSelector = (docSelector: Query | FieldExpression['$where'][]): ((doc: T) => boolean) => { + const perKeySelectors = Object.entries(docSelector).map(([key, subSelector]) => { + if (subSelector === undefined) { + return (): boolean => true; + } + + if (isLogicalOperator(key)) { + switch (key) { + case '$and': + return $and(subSelector); + + case '$or': + return $or(subSelector); + + case '$nor': + return $nor(subSelector); + + case '$where': + return $where(subSelector); + } + } + + const lookUpByIndex = createLookupFunction(key); + const valueSelectorFunc = compileValueSelector(subSelector); + return (doc: T): boolean => { + const branchValues = lookUpByIndex(doc); + return branchValues.some(valueSelectorFunc); + }; + }); + + return (doc: T): boolean => perKeySelectors.every((f) => f(doc)); +}; diff --git a/client/lib/minimongo/sort.ts b/client/lib/minimongo/sort.ts new file mode 100644 index 000000000000..4e4a4f6f5aaf --- /dev/null +++ b/client/lib/minimongo/sort.ts @@ -0,0 +1,75 @@ +import { compareBSONValues } from './bson'; +import { isEmptyArray } from './comparisons'; +import { createLookupFunction } from './lookups'; +import { Sort } from './types'; + +const createSortSpecParts = (spec: Sort): { + lookup: (doc: T) => unknown[]; + ascending: boolean; +}[] => { + if (Array.isArray(spec)) { + return spec.map((value) => { + if (typeof value === 'string') { + return { + lookup: createLookupFunction(value), + ascending: true, + }; + } + + return { + lookup: createLookupFunction(value[0]), + ascending: value[1] !== 'desc', + }; + }); + } + + return Object.entries(spec).map(([key, value]) => ({ + lookup: createLookupFunction(key), + ascending: value >= 0, + })); +}; + +const reduceValue = (branchValues: unknown[], ascending: boolean): unknown => + ([] as unknown[]).concat( + ...branchValues.map((branchValue) => { + if (!Array.isArray(branchValue)) { + return [branchValue]; + } + + if (isEmptyArray(branchValue)) { + return [undefined]; + } + + return branchValue; + }), + ).reduce((reduced, value) => { + const cmp = compareBSONValues(reduced, value); + if ((ascending && cmp > 0) || (!ascending && cmp < 0)) { + return value; + } + + return reduced; + }); + +export const compileSort = (spec: Sort): ((a: unknown, b: unknown) => number) => { + const sortSpecParts = createSortSpecParts(spec); + + if (sortSpecParts.length === 0) { + return (): number => 0; + } + + return (a: unknown, b: unknown): number => { + for (let i = 0; i < sortSpecParts.length; ++i) { + const specPart = sortSpecParts[i]; + const aValue = reduceValue(specPart.lookup(a), specPart.ascending); + const bValue = reduceValue(specPart.lookup(b), specPart.ascending); + const compare = compareBSONValues(aValue, bValue); + + if (compare !== 0) { + return specPart.ascending ? compare : -compare; + } + } + + return 0; + }; +}; diff --git a/client/lib/minimongo/types.ts b/client/lib/minimongo/types.ts new file mode 100644 index 000000000000..02b318edb581 --- /dev/null +++ b/client/lib/minimongo/types.ts @@ -0,0 +1,73 @@ +export const enum BSONType { + Double = 1, + String, + Object, + Array, + BinData, + /** @deprecated */ + Undefined, + ObjectId, + Boolean, + Date, + Null, + Regex, + /** @deprecated */ + DBPointer, + JavaScript, + /** @deprecated */ + Symbol, + JavaScriptWithScope, + Int, + Timestamp, + Long, + Decimal, + MinKey = -1, + MaxKey = 127, +} + +export type FieldExpression = { + $eq?: T; + $gt?: T; + $gte?: T; + $lt?: T; + $lte?: T; + $in?: T[]; + $nin?: T[]; + $ne?: T; + $exists?: boolean; + $type?: BSONType[] | BSONType; + $not?: FieldExpression; + $expr?: FieldExpression; + $jsonSchema?: unknown; + $mod?: number[]; + $regex?: RegExp | string; + $options?: string; + $text?: { $search: string; $language?: string; $caseSensitive?: boolean; $diacriticSensitive?: boolean }; + $where?: string | Function; + $geoIntersects?: unknown; + $geoWithin?: unknown; + $near?: unknown; + $nearSphere?: unknown; + $all?: T[]; + $elemMatch?: T extends {} ? Query : FieldExpression; + $size?: number; + $bitsAllClear?: unknown; + $bitsAllSet?: unknown; + $bitsAnyClear?: unknown; + $bitsAnySet?: unknown; + $comment?: string; +}; + +export type Flatten = T extends unknown[] ? T[0] : T; + +export type Query = { + [P in keyof T]?: Flatten | RegExp | FieldExpression> +} & { + $or?: Query[]; + $and?: Query[]; + $nor?: Query[]; +} & Record>; + +export type Sort = (string | [string, 'asc' | 'desc'])[] | { + [key: string]: -1 | 1; +}; diff --git a/client/providers/ServerProvider.js b/client/providers/ServerProvider.js index c378261972ec..64e2dcfc06d5 100644 --- a/client/providers/ServerProvider.js +++ b/client/providers/ServerProvider.js @@ -42,7 +42,17 @@ const uploadToEndpoint = (endpoint, params, formData) => { return APIClient.v1.upload(endpoint, params, formData).promise; }; -const getStream = (streamName, options = {}) => new Meteor.Streamer(streamName, options); +const getStream = (streamName, options = {}) => { + const streamer = Meteor.StreamerCentral.instances[streamName] + ? Meteor.StreamerCentral.instances[streamName] + : new Meteor.Streamer(streamName, options); + return (eventName, callback) => { + streamer.on(eventName, callback); + return () => { + streamer.removeListener(eventName, callback); + }; + }; +}; const contextValue = { info, diff --git a/client/views/room/Header/icons/Translate.js b/client/views/room/Header/icons/Translate.js index afc6c2d1f91e..f5b24f43fff8 100644 --- a/client/views/room/Header/icons/Translate.js +++ b/client/views/room/Header/icons/Translate.js @@ -10,7 +10,7 @@ const Translate = ({ room: { autoTranslateLanguage, autoTranslate } }) => { const t = useTranslation(); const autoTranslateEnabled = useSetting('AutoTranslate_Enabled'); const encryptedLabel = t('Translated'); - return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? : null; + return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? : null; }; export default memo(Translate); diff --git a/client/views/room/contextualBar/Discussions/index.js b/client/views/room/contextualBar/Discussions/index.js index a2cfaa20c529..50526ab2df9c 100644 --- a/client/views/room/contextualBar/Discussions/index.js +++ b/client/views/room/contextualBar/Discussions/index.js @@ -1,14 +1,10 @@ -import { Mongo } from 'meteor/mongo'; -import { Tracker } from 'meteor/tracker'; import { FlowRouter } from 'meteor/kadira:flow-router'; -import React, { useCallback, useMemo, useState, useEffect, useRef, memo } from 'react'; +import React, { useCallback, useMemo, useState, useRef, memo } from 'react'; import { Box, Icon, TextInput, Callout } from '@rocket.chat/fuselage'; import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; -import { useDebouncedValue, useDebouncedState, useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import { useDebouncedValue, useResizeObserver } from '@rocket.chat/fuselage-hooks'; -import { getConfig } from '../../../../../app/ui-utils/client/config'; -import { Messages } from '../../../../../app/models/client'; import VerticalBar from '../../../../components/VerticalBar'; import { useTranslation } from '../../../../contexts/TranslationContext'; import { useUserId, useUserSubscription } from '../../../../contexts/UserContext'; @@ -19,11 +15,12 @@ import { useSetting } from '../../../../contexts/SettingsContext'; import DiscussionListMessage from './components/Message'; import { clickableItem } from '../../../../lib/clickableItem'; import { escapeHTML } from '../../../../../lib/escapeHTML'; -import { useEndpointData } from '../../../../hooks/useEndpointData'; import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; import ScrollableContentWrapper from '../../../../components/ScrollableContentWrapper'; import { useTabBarClose } from '../../providers/ToolboxProvider'; import { renderMessageBody } from '../../../../lib/renderMessageBody'; +import { useDiscussionsList } from './useDiscussionsList'; +import { useRecordList } from '../../../../hooks/lists/useRecordList'; function mapProps(WrappedComponent) { return ({ msg, username, tcount, ts, ...props }) => ; @@ -33,10 +30,6 @@ const Discussion = React.memo(mapProps(clickableItem(DiscussionListMessage))); const Skeleton = React.memo(clickableItem(MessageSkeleton)); -const LIST_SIZE = parseInt(getConfig('discussionListSize')) || 25; - -const filterProps = ({ msg, drid, u, dcount, mentions, tcount, ts, _id, dlm, attachments, name }) => ({ ..._id && { _id }, drid, attachments, name, mentions, msg, u, dcount, tcount, ts: new Date(ts), dlm: new Date(dlm) }); - const subscriptionFields = { tunread: 1, tunreadUser: 1, tunreadGroup: 1 }; const roomFields = { t: 1, name: 1 }; @@ -48,65 +41,21 @@ export function withData(WrappedComponent) { const onClose = useTabBarClose(); const [text, setText] = useState(''); - const [total, setTotal] = useState(LIST_SIZE); - const [discussions, setDiscussions] = useDebouncedState([], 100); - const Discussions = useRef(new Mongo.Collection(null)); - const ref = useRef(); - const [pagination, setPagination] = useState({ skip: 0, count: LIST_SIZE }); - - const params = useMemo(() => ({ roomId: room._id, count: pagination.count, offset: pagination.skip, text }), [room._id, pagination.skip, pagination.count, text]); + const debouncedText = useDebouncedValue(text, 400); - const { value: data, phase: state, error } = useEndpointData('chat.getDiscussions', useDebouncedValue(params, 400)); - - const loadMoreItems = useCallback((skip, count) => { - setPagination({ skip, count: count - skip }); - - return new Promise((resolve) => { ref.current = resolve; }); - }, []); + const options = useMemo(() => ({ + rid, + text: debouncedText, + }), [rid, debouncedText]); - useEffect(() => () => Discussions.current.remove({}, () => {}), [text]); - - useEffect(() => { - if (state !== AsyncStatePhase.RESOLVED || !data || !data.messages) { - return; - } - - data.messages.forEach(({ _id, ...message }) => { - Discussions.current.upsert({ _id }, filterProps(message)); - }); - - setTotal(data.total); - ref.current && ref.current(); - }, [data, state]); - - useEffect(() => { - const cursor = Messages.find({ rid: room._id, drid: { $exists: true } }).observe({ - added: ({ _id, ...message }) => { - Discussions.current.upsert({ _id }, message); - }, // Update message to re-render DOM - changed: ({ _id, ...message }) => { - Discussions.current.update({ _id }, message); - }, // Update message to re-render DOM - removed: ({ _id }) => { - Discussions.current.remove(_id); - }, - }); - return () => cursor.stop(); - }, [room._id]); - - - useEffect(() => { - const cursor = Tracker.autorun(() => { - const query = { - }; - setDiscussions(Discussions.current.find(query, { sort: { tlm: -1 } }).fetch().map(filterProps)); - }); - - return () => cursor.stop(); - }, [room._id, setDiscussions, userId]); + const { + discussionsList, + initialItemCount, + loadMoreItems, + } = useDiscussionsList(options, userId); + const { phase, error, items: discussions, itemCount: totalItemCount } = useRecordList(discussionsList); const handleTextChange = useCallback((e) => { - setPagination({ skip: 0, count: LIST_SIZE }); setText(e.currentTarget.value); }, []); @@ -119,8 +68,9 @@ export function withData(WrappedComponent) { userId={userId} error={error} discussions={discussions} - total={total} - loading={state === AsyncStatePhase.LOADING} + total={totalItemCount} + initial={initialItemCount} + loading={phase === AsyncStatePhase.LOADING} loadMoreItems={loadMoreItems} room={room} text={text} @@ -180,7 +130,7 @@ const Row = memo(function Row({ />; }); -export function DiscussionList({ total = 10, discussions = [], loadMoreItems, loading, onClose, error, userId, text, setText }) { +export function DiscussionList({ total = 10, initial = 10, discussions = [], loadMoreItems, loading, onClose, error, userId, text, setText }) { const showRealNames = useSetting('UI_Use_Real_Name'); const discussionsRef = useRef(); @@ -231,7 +181,7 @@ export function DiscussionList({ total = 10, discussions = [], loadMoreItems, lo itemData={discussions} itemSize={124} ref={ref} - minimumBatchSize={LIST_SIZE} + minimumBatchSize={initial} onItemsRendered={onItemsRendered} >{rowRenderer} )} diff --git a/client/views/room/contextualBar/Discussions/useDiscussionsList.ts b/client/views/room/contextualBar/Discussions/useDiscussionsList.ts new file mode 100644 index 000000000000..93993c79deb8 --- /dev/null +++ b/client/views/room/contextualBar/Discussions/useDiscussionsList.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + DiscussionsList, + DiscussionsListOptions, +} from '../../../../lib/lists/DiscussionsList'; +import { useEndpoint } from '../../../../contexts/ServerContext'; +import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; +import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; +import { IUser } from '../../../../../definition/IUser'; +import { getConfig } from '../../../../../app/ui-utils/client/config'; + +export const useDiscussionsList = ( + options: DiscussionsListOptions, + uid: IUser['_id'], +): { + discussionsList: DiscussionsList; + initialItemCount: number; + loadMoreItems: (start: number, end: number) => void; + } => { + const [discussionsList] = useState(() => new DiscussionsList(options)); + + useEffect(() => { + if (discussionsList.options !== options) { + discussionsList.updateFilters(options); + } + }, [discussionsList, options]); + + const getDiscussions = useEndpoint('GET', 'chat.getDiscussions'); + + const fetchMessages = useCallback( + async (start, end) => { + const { messages, total } = await getDiscussions({ + roomId: options.rid, + text: options.text, + offset: start, + count: end - start, + }); + + return { + items: messages, + itemCount: total, + }; + }, + [getDiscussions, options.rid, options.text], + ); + + const { loadMoreItems, initialItemCount } = useScrollableMessageList( + discussionsList, + fetchMessages, + useMemo(() => { + const discussionListSize = getConfig('discussionListSize'); + return discussionListSize ? parseInt(discussionListSize, 10) : undefined; + }, []), + ); + useStreamUpdatesForMessageList(discussionsList, uid, options.rid); + + return { + discussionsList, + loadMoreItems, + initialItemCount, + }; +}; diff --git a/client/views/room/contextualBar/Threads/index.js b/client/views/room/contextualBar/Threads/index.js index 9d316af787aa..537558c5b718 100644 --- a/client/views/room/contextualBar/Threads/index.js +++ b/client/views/room/contextualBar/Threads/index.js @@ -1,14 +1,33 @@ -import React, { useCallback, useMemo, useState, useEffect, useRef, memo } from 'react'; -import { Box, Icon, TextInput, Select, Margins, Callout } from '@rocket.chat/fuselage'; +import React, { useCallback, useMemo, useState, useRef, memo } from 'react'; +import { + Box, + Icon, + TextInput, + Select, + Margins, + Callout, +} from '@rocket.chat/fuselage'; import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; -import { useDebouncedValue, useResizeObserver, useLocalStorage, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { + useDebouncedValue, + useResizeObserver, + useLocalStorage, + useMutableCallback, +} from '@rocket.chat/fuselage-hooks'; import VerticalBar from '../../../../components/VerticalBar'; import { useTranslation } from '../../../../contexts/TranslationContext'; -import { useRoute, useCurrentRoute, useQueryStringParameter } from '../../../../contexts/RouterContext'; +import { + useRoute, + useCurrentRoute, + useQueryStringParameter, +} from '../../../../contexts/RouterContext'; import { call } from '../../../../../app/ui-utils/client'; -import { useUserId, useUserSubscription } from '../../../../contexts/UserContext'; +import { + useUserId, + useUserSubscription, +} from '../../../../contexts/UserContext'; import { useUserRoom } from '../../hooks/useUserRoom'; import { useSetting } from '../../../../contexts/SettingsContext'; import { useTimeAgo } from '../../../../hooks/useTimeAgo'; @@ -16,134 +35,101 @@ import { clickableItem } from '../../../../lib/clickableItem'; import { MessageSkeleton } from '../../components/Message'; import ThreadListMessage from './components/Message'; import { escapeHTML } from '../../../../../lib/escapeHTML'; -import { getConfig } from '../../../../../app/ui-utils/client/config'; -import { useEndpoint } from '../../../../contexts/ServerContext'; import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; import ScrollableContentWrapper from '../../../../components/ScrollableContentWrapper'; import { useTabBarClose, useTabContext } from '../../providers/ToolboxProvider'; import ThreadComponent from '../../../../../app/threads/client/components/ThreadComponent'; import { renderMessageBody } from '../../../../lib/renderMessageBody'; +import { useThreadsList } from './useThreadsList'; +import { useRecordList } from '../../../../hooks/lists/useRecordList'; function mapProps(WrappedComponent) { - return ({ msg, username, replies, tcount, ts, ...props }) => ; + return ({ msg, username, replies = [], tcount, ts, ...props }) => ( + + ); } const Thread = React.memo(mapProps(clickableItem(ThreadListMessage))); const Skeleton = React.memo(clickableItem(MessageSkeleton)); -const LIST_SIZE = parseInt(getConfig('threadsListSize')) || 25; - -const filterProps = ({ msg, u, replies, mentions, tcount, ts, _id, tlm, attachments }) => ({ ..._id && { _id }, attachments, mentions, msg, u, replies, tcount, ts: new Date(ts), tlm: new Date(tlm) }); - const subscriptionFields = { tunread: 1, tunreadUser: 1, tunreadGroup: 1 }; const roomFields = { t: 1, name: 1 }; -const mergeThreads = (threads, newThreads) => - Array.from( - new Map([ - ...threads.map((msg) => [msg._id, msg]), - ...newThreads.map((msg) => [msg._id, msg]), - ]).values(), - ) - .sort((a, b) => b.tlm.getTime() - a.tlm.getTime()); - export function withData(WrappedComponent) { return ({ rid, ...props }) => { + const userId = useUserId(); const onClose = useTabBarClose(); const room = useUserRoom(rid, roomFields); const subscription = useUserSubscription(rid, subscriptionFields); - const userId = useUserId(); - const [{ - state, - error, - threads, - count, - }, setState] = useState(() => ({ - state: AsyncStatePhase.LOADING, - error: null, - threads: [], - count: 0, - })); const [type, setType] = useLocalStorage('thread-list-type', 'all'); - const [text, setText] = useState(''); - - const getThreadsList = useEndpoint('GET', 'chat.getThreadsList'); - const fetchThreads = useMutableCallback(async ({ rid, offset, limit, type, text }) => { - try { - const data = await getThreadsList({ - rid, - offset, - count: limit, - type, - text, - }); - - setState(({ threads }) => ({ - state: AsyncStatePhase.RESOLVED, - error: null, - threads: mergeThreads(offset === 0 ? [] : threads, data.threads.map(filterProps)), - count: data.total, - })); - } catch (error) { - setState(({ threads, count }) => ({ - state: AsyncStatePhase.REJECTED, - error, - threads, - count, - })); - } - }); + const [text, setText] = useState(''); const debouncedText = useDebouncedValue(text, 400); - useEffect(() => { - fetchThreads({ - rid: room._id, - offset: 0, - limit: LIST_SIZE, - type, - text: debouncedText, - }); - }, [debouncedText, fetchThreads, room._id, type]); - const loadMoreItems = useCallback((start, end) => fetchThreads({ - rid: room._id, - offset: start, - limit: end - start, - type, - text, - }), [fetchThreads, room._id, type, text]); + const options = useMemo( + () => ({ + rid, + text: debouncedText, + type, + tunread: subscription?.tunread, + uid: userId, + }), + [rid, debouncedText, type, subscription, userId], + ); + + const { + threadsList, + initialItemCount, + loadMoreItems, + } = useThreadsList(options, userId); + const { phase, error, items: threads, itemCount: totalItemCount } = useRecordList(threadsList); const handleTextChange = useCallback((event) => { setText(event.currentTarget.value); }, []); - return ; + return ( + + ); }; } const handleFollowButton = (e) => { e.preventDefault(); e.stopPropagation(); - call(![true, 'true'].includes(e.currentTarget.dataset.following) ? 'followMessage' : 'unfollowMessage', { mid: e.currentTarget.dataset.id }); + call( + ![true, 'true'].includes(e.currentTarget.dataset.following) + ? 'followMessage' + : 'unfollowMessage', + { mid: e.currentTarget.dataset.id }, + ); }; export const normalizeThreadMessage = ({ ...message }) => { @@ -152,7 +138,9 @@ export const normalizeThreadMessage = ({ ...message }) => { } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + const attachment = message.attachments.find( + (attachment) => attachment.title || attachment.description, + ); if (attachment && attachment.description) { return escapeHTML(attachment.description); @@ -179,31 +167,51 @@ const Row = memo(function Row({ const formatDate = useTimeAgo(); if (!data[index]) { - return ; + return ; } const thread = data[index]; const msg = normalizeThreadMessage(thread); const { name = thread.u.username } = thread.u; - return ; + return ( + + ); }); -export function ThreadList({ total = 10, threads = [], room, unread = [], unreadUser = [], unreadGroup = [], type, setType, loadMoreItems, loading, onClose, error, userId, text, setText }) { +export function ThreadList({ + total = 10, + initial = 10, + threads = [], + room, + unread = [], + unreadUser = [], + unreadGroup = [], + type, + setType, + loadMoreItems, + loading, + onClose, + error, + userId, + text, + setText, +}) { const showRealNames = useSetting('UI_Use_Real_Name'); const threadsRef = useRef(); @@ -221,68 +229,125 @@ export function ThreadList({ total = 10, threads = [], room, unread = [], unread }); }); - const options = useMemo(() => [['all', t('All')], ['following', t('Following')], ['unread', t('Unread')]], [t]); + const options = useMemo( + () => [ + ['all', t('All')], + ['following', t('Following')], + ['unread', t('Unread')], + ], + [t], + ); threadsRef.current = threads; - const rowRenderer = useCallback(({ data, index, style }) => , [showRealNames, unread, unreadUser, unreadGroup, userId, onClick]); - - const isItemLoaded = useMutableCallback((index) => index < threadsRef.current.length); - const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ debounceDelay: 200 }); + const rowRenderer = useCallback( + ({ data, index, style }) => ( + + ), + [showRealNames, unread, unreadUser, unreadGroup, userId, onClick], + ); + + const isItemLoaded = useMutableCallback( + (index) => index < threadsRef.current.length, + ); + const { + ref, + contentBoxSize: { inlineSize = 378, blockSize = 1 } = {}, + } = useResizeObserver({ debounceDelay: 200 }); const mid = useTabContext(); const jump = useQueryStringParameter('jump'); - return <> - - - {t('Threads')} - - - - - - - }/> - + + - - - {error && {error.toString()}} - {total === 0 && {t('No_Threads')}} - {!error && total > 0 && {} : loadMoreItems} + - {({ onItemsRendered, ref }) => ({rowRenderer} + {error && ( + + {error.toString()} + )} - } - - - { mid && } - ; + {total === 0 && {t('No_Threads')}} + {!error && total > 0 && ( + {} : loadMoreItems} + > + {({ onItemsRendered, ref }) => ( + + {rowRenderer} + + )} + + )} + + + {mid && ( + + + + )} + + ); } export default withData(ThreadList); diff --git a/client/views/room/contextualBar/Threads/useThreadsList.ts b/client/views/room/contextualBar/Threads/useThreadsList.ts new file mode 100644 index 000000000000..91ae14577ebe --- /dev/null +++ b/client/views/room/contextualBar/Threads/useThreadsList.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + ThreadsList, + ThreadsListOptions, +} from '../../../../lib/lists/ThreadsList'; +import { useEndpoint } from '../../../../contexts/ServerContext'; +import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; +import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; +import { IUser } from '../../../../../definition/IUser'; +import { getConfig } from '../../../../../app/ui-utils/client/config'; + +export const useThreadsList = ( + options: ThreadsListOptions, + uid: IUser['_id'], +): { + threadsList: ThreadsList; + initialItemCount: number; + loadMoreItems: (start: number, end: number) => void; + } => { + const [threadsList] = useState(() => new ThreadsList(options)); + + useEffect(() => { + if (threadsList.options !== options) { + threadsList.updateFilters(options); + } + }, [threadsList, options]); + + const getThreadsList = useEndpoint('GET', 'chat.getThreadsList'); + + const fetchMessages = useCallback( + async (start, end) => { + const { threads, total } = await getThreadsList({ + rid: options.rid, + type: options.type, + text: options.text, + offset: start, + count: end - start, + }); + + return { + items: threads, + itemCount: total, + }; + }, + [getThreadsList, options.rid, options.text, options.type], + ); + + const { loadMoreItems, initialItemCount } = useScrollableMessageList( + threadsList, + fetchMessages, + useMemo(() => { + const threadsListSize = getConfig('threadsListSize'); + return threadsListSize ? parseInt(threadsListSize, 10) : undefined; + }, []), + ); + useStreamUpdatesForMessageList(threadsList, uid, options.rid); + + return { + threadsList, + loadMoreItems, + initialItemCount, + }; +}; diff --git a/definition/IMessage.ts b/definition/IMessage.ts index 7a974a593134..f67dfe12d3ce 100644 --- a/definition/IMessage.ts +++ b/definition/IMessage.ts @@ -20,4 +20,11 @@ export interface IMessage extends IRocketChatRecord { type: 'Point'; coordinates: [string, string]; }; + starred?: {_id: string}[]; + pinned?: boolean; + drid?: RoomID; + tlm?: Date; + + dcount?: number; + tcount?: number; } diff --git a/definition/ObjectFromApi.ts b/definition/ObjectFromApi.ts new file mode 100644 index 000000000000..fc3ea3d2aa9d --- /dev/null +++ b/definition/ObjectFromApi.ts @@ -0,0 +1,3 @@ +export type ObjectFromApi = { + [K in keyof T]: T[K] extends Date ? string : T[K]; +}; From 18dbfe577a11a9b8a89e7ea881e6d491d9096193 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 15 Jan 2021 18:50:25 -0300 Subject: [PATCH 26/98] Rewrite Broadcast Message (#20119) --- app/ui-message/client/message.html | 11 ++++------ .../client/messageBox/messageBox.js | 2 +- app/ui-utils/client/lib/messageContext.js | 11 +++++++++- .../views/app/lib/getCommonRoomEvents.js | 6 +----- client/adapters.js | 1 + .../components/Message/Metrics/Broadcast.tsx | 20 +++++++++++++++++++ 6 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 client/components/Message/Metrics/Broadcast.tsx diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index 350b2ba949d4..73042a77817e 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -130,6 +130,10 @@ {{> ThreadMetric counter=msg.tcount following=following lm=msg.tlm rid=msg.rid mid=msg._id unread=unread mention=mention all=all openThread=actions.openThread }} {{/if}} + {{#if broadcast}} + {{> BroadCastMetric mid=msg._id username=msg.u.username replyBroadcast=actions.replyBroadcast }} + {{/if}} + {{#with readReceipt}}
{{> icon icon="check" }} @@ -168,13 +172,6 @@ {{/each}} {{/unless}} - {{#if broadcast}} - {{#with msg}} - - {{/with}} - {{/if}} {{#unless hideReactions}}
    {{#each reaction in reactions}} diff --git a/app/ui-message/client/messageBox/messageBox.js b/app/ui-message/client/messageBox/messageBox.js index b1e6606bce59..86c3aff9eee9 100644 --- a/app/ui-message/client/messageBox/messageBox.js +++ b/app/ui-message/client/messageBox/messageBox.js @@ -29,7 +29,7 @@ import { t, roomTypes, getUserPreference, -} from '../../../utils'; +} from '../../../utils/client'; import './messageBoxActions'; import './messageBoxReplyPreview'; import './messageBoxTyping'; diff --git a/app/ui-utils/client/lib/messageContext.js b/app/ui-utils/client/lib/messageContext.js index aea913f577be..ba9bef42ede1 100644 --- a/app/ui-utils/client/lib/messageContext.js +++ b/app/ui-utils/client/lib/messageContext.js @@ -5,9 +5,10 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Subscriptions, Rooms, Users } from '../../../models/client'; import { hasPermission } from '../../../authorization/client'; import { settings } from '../../../settings/client'; -import { getUserPreference } from '../../../utils/client'; +import { getUserPreference, roomTypes } from '../../../utils/client'; import { AutoTranslate } from '../../../autotranslate/client'; + const fields = { name: 1, username: 1, 'settings.preferences.showMessageInMainThread': 1, 'settings.preferences.autoImageLoad': 1, 'settings.preferences.saveMobileBandwidth': 1, 'settings.preferences.collapseMediaByDefault': 1, 'settings.preferences.hideRoles': 1 }; export function messageContext({ rid } = Template.instance()) { @@ -33,6 +34,11 @@ export function messageContext({ rid } = Template.instance()) { FlowRouter.goToRoomById(drid); }; + const replyBroadcast = (e) => { + const { username, mid } = e.currentTarget.dataset; + roomTypes.openRouteLink('d', { name: username }, { ...FlowRouter.current().queryParams, reply: mid }); + }; + return { u: user, room: Rooms.findOne({ _id: rid }, { @@ -59,6 +65,9 @@ export function messageContext({ rid } = Template.instance()) { openDiscussion() { return openDiscussion; }, + replyBroadcast() { + return replyBroadcast; + }, }, settings: { translateLanguage: AutoTranslate.getLanguage(rid), diff --git a/app/ui/client/views/app/lib/getCommonRoomEvents.js b/app/ui/client/views/app/lib/getCommonRoomEvents.js index 68c7d7e684b2..d4f5b3bfbacc 100644 --- a/app/ui/client/views/app/lib/getCommonRoomEvents.js +++ b/app/ui/client/views/app/lib/getCommonRoomEvents.js @@ -15,7 +15,7 @@ import { isURL } from '../../../../../utils/lib/isURL'; import { openUserCard } from '../../../lib/UserCard'; import { messageArgs } from '../../../../../ui-utils/client/lib/messageArgs'; import { ChatMessage, Rooms } from '../../../../../models'; -import { t, roomTypes } from '../../../../../utils/client'; +import { t } from '../../../../../utils/client'; import { chatMessages } from '../room'; import { EmojiEvents } from '../../../../../reactions/client/init'; @@ -183,10 +183,6 @@ export const getCommonRoomEvents = () => ({ jump: tmid && tmid !== _id && _id && _id, }); }, - 'click .js-reply-broadcast'() { - const msg = messageArgs(this); - roomTypes.openRouteLink('d', { name: msg.u.username }, { ...FlowRouter.current().queryParams, reply: msg._id }); - }, 'click .image-to-download'(event) { const { msg } = messageArgs(this); diff --git a/client/adapters.js b/client/adapters.js index 252ef77a9019..1e23e8ef96a5 100644 --- a/client/adapters.js +++ b/client/adapters.js @@ -2,3 +2,4 @@ const { createTemplateForComponent } = require('./reactAdapters'); createTemplateForComponent('ThreadMetric', () => import('./components/Message/Metrics/Thread')); createTemplateForComponent('DiscussionMetric', () => import('./components/Message/Metrics/Discussion')); +createTemplateForComponent('BroadCastMetric', () => import('./components/Message/Metrics/Broadcast')); diff --git a/client/components/Message/Metrics/Broadcast.tsx b/client/components/Message/Metrics/Broadcast.tsx new file mode 100644 index 000000000000..2849884913fc --- /dev/null +++ b/client/components/Message/Metrics/Broadcast.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +import { useTranslation } from '../../../contexts/TranslationContext'; +import { Reply, Content } from '..'; + + +type BroadcastOptions = { + username: string; + mid: string; + replyBroadcast: () => void; +}; + +const BroadcastMetric: FC = ({ username, mid, replyBroadcast }) => { + const t = useTranslation(); + return + {t('Reply')} + ; +}; + +export default BroadcastMetric; From 9d4d7733cc6dea0433bfb7bebf8cdc85f4163b0b Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Fri, 15 Jan 2021 19:32:37 -0300 Subject: [PATCH 27/98] Regression: User Dropdown margin (#20222) --- client/sidebar/header/UserDropdown.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/sidebar/header/UserDropdown.js b/client/sidebar/header/UserDropdown.js index a93dc1556bc4..94a99cb0f9bf 100644 --- a/client/sidebar/header/UserDropdown.js +++ b/client/sidebar/header/UserDropdown.js @@ -33,7 +33,8 @@ const ADMIN_PERMISSIONS = [ ]; const style = { - marginInline: '-16px', + marginLeft: '-16px', + marginRight: '-16px', }; const setStatus = (status, statusText) => { From 9b33bbe5d01fca435a52c2698a0a5409c3b0b7e7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 15 Jan 2021 19:43:44 -0300 Subject: [PATCH 28/98] Rewrite: Message Attachments (#20106) --- KNOWN_ISSUES.md | 6 + app/message-action/client/index.js | 3 - app/message-action/client/messageAction.html | 31 --- app/message-action/client/messageAction.js | 13 -- .../client/stylesheets/messageAction.css | 64 ------- app/message-action/index.js | 1 - app/message-attachments/client/index.js | 5 - .../client/messageAttachment.html | 178 ------------------ .../client/messageAttachment.js | 150 --------------- .../client/renderField.html | 20 -- app/message-attachments/client/renderField.js | 54 +----- .../client/stylesheets/messageAttachments.css | 163 ---------------- app/theme/client/imports/general/base.css | 4 + app/ui-message/client/message.html | 8 +- .../messageBox/messageBoxReplyPreview.html | 2 +- .../messageBox/messageBoxReplyPreview.js | 7 + client/adapters.js | 3 +- .../Message/Attachments/ActionAttachtment.tsx | 30 +++ .../Message/Attachments/Attachment.tsx | 95 ++++++++++ .../Attachments/Attachments.stories.js | 104 ++++++++++ .../Message/Attachments/FieldsAttachment.tsx | 19 ++ .../Attachments/Files/AudioAttachment.tsx | 42 +++++ .../Files/GenericFileAttachment.tsx | 39 ++++ .../Attachments/Files/ImageAttachment.tsx | 49 +++++ .../Attachments/Files/PDFAttachment.tsx | 38 ++++ .../Attachments/Files/VideoAttachment.tsx | 42 +++++ .../Message/Attachments/Files/index.tsx | 26 +++ .../Message/Attachments/QuoteAttachment.tsx | 34 ++++ .../Attachments/components/Image.stories.js | 11 ++ .../Message/Attachments/components/Image.tsx | 95 ++++++++++ .../Attachments/context/AttachmentContext.tsx | 35 ++++ .../Message/Attachments/hooks/useCollapse.tsx | 11 ++ .../Attachments/hooks/useLoadImage.tsx | 8 + .../components/Message/Attachments/index.tsx | 87 +++++++++ .../providers/AttachmentProvider.tsx | 29 +++ client/importPackages.js | 1 - client/providers/MeteorProvider.js | 6 +- client/types/fuselage.d.ts | 9 + packages/rocketchat-i18n/i18n/en.i18n.json | 2 + 39 files changed, 833 insertions(+), 691 deletions(-) create mode 100644 KNOWN_ISSUES.md delete mode 100644 app/message-action/client/index.js delete mode 100644 app/message-action/client/messageAction.html delete mode 100644 app/message-action/client/messageAction.js delete mode 100644 app/message-action/client/stylesheets/messageAction.css delete mode 100644 app/message-action/index.js delete mode 100644 app/message-attachments/client/messageAttachment.html delete mode 100644 app/message-attachments/client/messageAttachment.js delete mode 100644 app/message-attachments/client/renderField.html delete mode 100644 app/message-attachments/client/stylesheets/messageAttachments.css create mode 100644 client/components/Message/Attachments/ActionAttachtment.tsx create mode 100644 client/components/Message/Attachments/Attachment.tsx create mode 100644 client/components/Message/Attachments/Attachments.stories.js create mode 100644 client/components/Message/Attachments/FieldsAttachment.tsx create mode 100644 client/components/Message/Attachments/Files/AudioAttachment.tsx create mode 100644 client/components/Message/Attachments/Files/GenericFileAttachment.tsx create mode 100644 client/components/Message/Attachments/Files/ImageAttachment.tsx create mode 100644 client/components/Message/Attachments/Files/PDFAttachment.tsx create mode 100644 client/components/Message/Attachments/Files/VideoAttachment.tsx create mode 100644 client/components/Message/Attachments/Files/index.tsx create mode 100644 client/components/Message/Attachments/QuoteAttachment.tsx create mode 100644 client/components/Message/Attachments/components/Image.stories.js create mode 100644 client/components/Message/Attachments/components/Image.tsx create mode 100644 client/components/Message/Attachments/context/AttachmentContext.tsx create mode 100644 client/components/Message/Attachments/hooks/useCollapse.tsx create mode 100644 client/components/Message/Attachments/hooks/useLoadImage.tsx create mode 100644 client/components/Message/Attachments/index.tsx create mode 100644 client/components/Message/Attachments/providers/AttachmentProvider.tsx diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 000000000000..9ab0a11c366a --- /dev/null +++ b/KNOWN_ISSUES.md @@ -0,0 +1,6 @@ +## `registerFieldTemplate` is deprecated + hmm it's true :(, we don't encourage this type of customization anymore, it ends up opening some security holes, we prefer the use of UIKit. If you feel any difficulty let us know +## `attachment.actions` is deprecated + same reason above +## `attachment PDF preview` is no longer being rendered + it is temporarily disabled, nowadays is huge effort render the previews and requires the download of the entire file on the client. We are working to improve this :) \ No newline at end of file diff --git a/app/message-action/client/index.js b/app/message-action/client/index.js deleted file mode 100644 index 7a623b88f1ef..000000000000 --- a/app/message-action/client/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import './messageAction.html'; -import './messageAction'; -import './stylesheets/messageAction.css'; diff --git a/app/message-action/client/messageAction.html b/app/message-action/client/messageAction.html deleted file mode 100644 index 8d6c73566772..000000000000 --- a/app/message-action/client/messageAction.html +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/app/message-action/client/messageAction.js b/app/message-action/client/messageAction.js deleted file mode 100644 index 025c06db224a..000000000000 --- a/app/message-action/client/messageAction.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Template } from 'meteor/templating'; - -Template.messageAction.helpers({ - isButton() { - return this.type === 'button'; - }, - areButtonsHorizontal() { - return Template.parentData(1).button_alignment === 'horizontal'; - }, - jsActionButtonClassname(processingType) { - return `js-actionButton-${ processingType || 'sendMessage' }`; - }, -}); diff --git a/app/message-action/client/stylesheets/messageAction.css b/app/message-action/client/stylesheets/messageAction.css deleted file mode 100644 index 5ff7235f858a..000000000000 --- a/app/message-action/client/stylesheets/messageAction.css +++ /dev/null @@ -1,64 +0,0 @@ -.attachment { - & .action { - margin-top: 2px; - } - - & .text-button { - - position: relative; - - display: inline-flex; - - min-width: 0; - max-width: 220px; - - height: 28px; - margin: 2px 2px 2px 0; - padding: 0 10px; - - cursor: pointer; - user-select: none; - - text-align: center; - - vertical-align: middle; - white-space: nowrap; - - text-decoration: none; - - color: #2c2d30; - - border: 2px solid lightgray; - border-radius: 4px; - - outline: none; - - background: rgb(250, 250, 250); - - font-size: 13px; - - font-weight: 500; - align-items: center; - -webkit-appearance: none; - justify-content: center; - -webkit-tap-highlight-color: transparent; - } - - & .overflow-ellipsis { - display: block; - - overflow: hidden; - - white-space: nowrap; - - text-overflow: ellipsis; - } - - & .image-button { - max-height: 200px; - } - - & .horizontal-buttons { - display: inline; - } -} diff --git a/app/message-action/index.js b/app/message-action/index.js deleted file mode 100644 index 40a7340d3887..000000000000 --- a/app/message-action/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './client/index'; diff --git a/app/message-attachments/client/index.js b/app/message-attachments/client/index.js index be13cfc60615..3dfb9dc37361 100644 --- a/app/message-attachments/client/index.js +++ b/app/message-attachments/client/index.js @@ -1,6 +1 @@ -import './messageAttachment.html'; -import './messageAttachment'; -import './renderField.html'; -import './stylesheets/messageAttachments.css'; - export { registerFieldTemplate } from './renderField'; diff --git a/app/message-attachments/client/messageAttachment.html b/app/message-attachments/client/messageAttachment.html deleted file mode 100644 index 5eb34a008123..000000000000 --- a/app/message-attachments/client/messageAttachment.html +++ /dev/null @@ -1,178 +0,0 @@ - diff --git a/app/message-attachments/client/messageAttachment.js b/app/message-attachments/client/messageAttachment.js deleted file mode 100644 index 25be63786461..000000000000 --- a/app/message-attachments/client/messageAttachment.js +++ /dev/null @@ -1,150 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Template } from 'meteor/templating'; - -import { DateFormat } from '../../lib'; -import { getURL } from '../../utils/client'; -import { createCollapseable } from '../../ui-utils'; -import { renderMessageBody } from '../../../client/lib/renderMessageBody'; - -const colors = { - good: '#35AC19', - warning: '#FCB316', - danger: '#D30230', -}; - -async function renderPdfToCanvas(canvasId, pdfLink) { - const isSafari = /constructor/i.test(window.HTMLElement) - || ((p) => p.toString() === '[object SafariRemoteNotification]')(!window.safari - || (typeof window.safari !== 'undefined' && window.safari.pushNotification)); - - if (isSafari) { - const [, version] = /Version\/([0-9]+)/.exec(navigator.userAgent) || [null, 0]; - if (version <= 12) { - return; - } - } - - if (!pdfLink || !/\.pdf$/i.test(pdfLink)) { - return; - } - pdfLink = getURL(pdfLink); - - const canvas = document.getElementById(canvasId); - if (!canvas) { - return; - } - - const pdfjsLib = await import('pdfjs-dist'); - pdfjsLib.GlobalWorkerOptions.workerSrc = `${ Meteor.absoluteUrl() }pdf.worker.min.js`; - - const loader = document.getElementById(`js-loading-${ canvasId }`); - - if (loader) { - loader.style.display = 'block'; - } - - const pdf = await pdfjsLib.getDocument(pdfLink).promise; - const page = await pdf.getPage(1); - const scale = 0.5; - const viewport = page.getViewport({ scale }); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - await page.render({ - canvasContext: context, - viewport, - }).promise; - - if (loader) { - loader.style.display = 'none'; - } - - canvas.style.maxWidth = '-webkit-fill-available'; - canvas.style.maxWidth = '-moz-available'; - canvas.style.display = 'block'; -} - -createCollapseable(Template.messageAttachment, (instance) => (instance.data && (instance.data.collapsed || (instance.data.settings && instance.data.settings.collapseMediaByDefault))) || false); - -Template.messageAttachment.helpers({ - parsedText() { - return renderMessageBody({ - msg: this.text, - }); - }, - markdownInPretext() { - return this.mrkdwn_in && this.mrkdwn_in.includes('pretext'); - }, - parsedPretext() { - return renderMessageBody({ - msg: this.pretext, - }); - }, - loadImage() { - if (this.downloadImages) { - return true; - } - - if (this.settings.autoImageLoad === false) { - return false; - } - - if (this.settings.saveMobileBandwidth === true) { - return false; - } - - return true; - }, - getImageHeight(height = 200) { - return height; - }, - color() { - return colors[this.color] || this.color; - }, - time() { - const messageDate = new Date(this.ts); - const today = new Date(); - if (messageDate.toDateString() === today.toDateString()) { - return DateFormat.formatTime(this.ts); - } - return DateFormat.formatDateAndTime(this.ts); - }, - injectIndex(data, previousIndex, index) { - data.index = `${ previousIndex }.attachments.${ index }`; - }, - injectSettings(data, settings) { - data.settings = settings; - }, - injectMessage(data, { rid, _id }) { - data.msg = { _id, rid }; - }, - injectCollapsedMedia(data) { - const { collapsedMedia } = data; - Object.assign(this, { collapsedMedia }); - return this; - }, - isFile() { - return this.type === 'file'; - }, - isPDF() { - if ( - this.type === 'file' - && this.title_link.endsWith('.pdf') - && Template.parentData(1).msg.file - ) { - this.fileId = Template.parentData(1).msg.file._id; - return true; - } - return false; - }, - getURL, -}); - -Template.messageAttachment.onRendered(function() { - const { msg } = Template.parentData(1); - this.autorun(() => { - if (msg && msg.file && msg.file.type === 'application/pdf' && !this.collapsedMedia.get()) { - Meteor.defer(() => { renderPdfToCanvas(msg.file._id, msg.attachments[0].title_link); }); - } - }); -}); diff --git a/app/message-attachments/client/renderField.html b/app/message-attachments/client/renderField.html deleted file mode 100644 index d9a5fbf1ecd4..000000000000 --- a/app/message-attachments/client/renderField.html +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/app/message-attachments/client/renderField.js b/app/message-attachments/client/renderField.js index 4a62768d9ed6..f82e79aa9c6c 100644 --- a/app/message-attachments/client/renderField.js +++ b/app/message-attachments/client/renderField.js @@ -1,11 +1,3 @@ -import { Template } from 'meteor/templating'; -import { Blaze } from 'meteor/blaze'; - -import { Markdown } from '../../markdown/client'; -import { escapeHTML } from '../../../lib/escapeHTML'; - -const renderers = {}; - /** * The field templates will be rendered non-reactive for all messages by the messages-list (@see rocketchat-nrr) * Thus, we cannot provide helpers or events to the template, but we need to register this interactivity at the parent @@ -15,48 +7,6 @@ const renderers = {}; * @param helpers * @param events */ -export function registerFieldTemplate(fieldType, templateName, events) { - renderers[fieldType] = templateName; - - // propagate helpers and events to the room template, changing the selectors - // loop at events. For each event (like 'click .accept'), copy the function to a function of the room events. - // While doing that, add the fieldType as class selector to the events function in order to avoid naming clashes - if (events != null) { - const uniqueEvents = {}; - // rename the event handlers so they are unique in the "parent" template to which the events bubble - for (const property in events) { - if (events.hasOwnProperty(property)) { - const event = property.substr(0, property.indexOf(' ')); - const selector = property.substr(property.indexOf(' ') + 1); - Object.defineProperty(uniqueEvents, - `${ event } .${ fieldType } ${ selector }`, - { - value: events[property], - enumerable: true, // assign as a own property - }); - } - } - Template.roomOld.events(uniqueEvents); - } +export function registerFieldTemplate() { + console.warn('registerFieldTemplate DEPRECATED'); } - -// onRendered is not being executed (no idea why). Consequently, we cannot use Blaze.renderWithData(), since we don't -// have access to the DOM outside onRendered. Therefore, we can only translate the content of the field to HTML and -// embed it non-reactively. -// This in turn means that onRendered of the field template will not be processed either. -// I guess it may have someting to do with rocketchat-nrr -Template.renderField.helpers({ - specializedRendering({ hash: { field, message } }) { - let html = ''; - if (field.type && renderers[field.type]) { - html = Blaze.toHTMLWithData(Template[renderers[field.type]], { field, message }); - } else { - // consider the value already formatted as html - html = escapeHTML(field.value); - } - return `
    ${ html }
    `; - }, - markdown(text) { - return Markdown.parse(text); - }, -}); diff --git a/app/message-attachments/client/stylesheets/messageAttachments.css b/app/message-attachments/client/stylesheets/messageAttachments.css deleted file mode 100644 index 2ff918cdf8e8..000000000000 --- a/app/message-attachments/client/stylesheets/messageAttachments.css +++ /dev/null @@ -1,163 +0,0 @@ -html.rtl .attachment { - direction: rtl; - - & .attachment-block { - padding-right: 15px; - padding-left: 0; - - & .attachment-block-border { - right: 0; - left: auto; - } - } - - & .attachment-thumb { - padding-top: 10px; - padding-right: 5px; - } - - & .attachment-download-icon { - margin-right: 5px; - margin-left: auto; - } -} - -.attachment { - & .attachment-block { - position: relative; - - margin: 5px 0; - padding-left: 15px; - - & .attachment-block-border { - position: absolute; - top: 0; - bottom: 0; - left: 0; - - width: 2px; - - border-radius: 8px; - } - } - - & .attachment-author { - font-size: 0.95rem; - font-weight: 600; - line-height: 1.2rem; - - & > a { - font-weight: 600; - } - - & img { - max-width: 16px; - max-height: 16px; - margin-right: 2px; - margin-bottom: -2px; - } - - & .time, - & .time-link { - font-size: 0.8em; - font-weight: normal; - } - } - - & .attachment-title { - - color: #1d74f5; - - font-size: 1.02rem; - font-weight: 500; - line-height: 1.5rem; - } - - & .attachment-text { - padding: 3px 0; - - line-height: 1rem; - } - - & .attachment-image { - margin-top: 4px; - - line-height: 0; - } - - & .attachment-fields { - display: flex; - - margin-top: 4px; - - align-items: center; - flex-wrap: wrap; - - & .attachment-field { - flex: 1 0 100%; - - padding-top: 5px; - padding-bottom: 5px; - - &.attachment-field-short { - display: inline-block; - - flex: 1 1; - - margin-right: 12px; - } - - & .attachment-field-title { - font-weight: 600; - line-height: 1rem; - } - } - } - - & .attachment-thumb { - padding-top: 5px; - padding-right: 10px; - - line-height: 0; - - & img { - max-width: 100px; - } - } - - & .attachment-flex { - display: flex; - align-items: flex-start; - - & .attachment-flex-column-grow { - word-break: break-word; - flex-grow: 1; - } - } - - & .attachment-small-content { - max-width: 700px; - } - - & .attachment-download-icon { - padding: 0 5px; - } - - & .attachment-canvas { - display: none; - } - - & .attachment-pdf-loading { - display: none; - - font-size: 1.5rem; - - svg { - animation: spin 1s linear infinite; - } - } - - & .actions-container { - margin-top: 6px; - } -} diff --git a/app/theme/client/imports/general/base.css b/app/theme/client/imports/general/base.css index 9c47a7bdcbf8..5eda246fb99e 100644 --- a/app/theme/client/imports/general/base.css +++ b/app/theme/client/imports/general/base.css @@ -246,3 +246,7 @@ button { display: none !important; } } + +.gallery-item { + cursor: pointer; +} \ No newline at end of file diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index 73042a77817e..57f2f8bb16f1 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -115,13 +115,7 @@ {{> oembedBaseWidget}} {{/each}} {{/if}} - {{#each msg.attachments}} - {{injectMessage . ../msg}} - {{injectSettings . ../settings}} - {{injectIndex . @index}} - {{> messageAttachment}} - {{/each}} - + {{> reactAttachments attachments=msg.attachments file=msg.file }} {{#if msg.drid}} {{> DiscussionMetric count=msg.dcount drid=msg.drid lm=msg.dlm openDiscussion=actions.openDiscussion }} {{/if}} diff --git a/app/ui-message/client/messageBox/messageBoxReplyPreview.html b/app/ui-message/client/messageBox/messageBoxReplyPreview.html index 1d874ff7208e..a3476628fe29 100644 --- a/app/ui-message/client/messageBox/messageBoxReplyPreview.html +++ b/app/ui-message/client/messageBox/messageBoxReplyPreview.html @@ -1,7 +1,7 @@