From 06f8907b854b6086766756a2c9c89c73c50433b2 Mon Sep 17 00:00:00 2001 From: Romaric Mourgues Date: Mon, 14 Nov 2022 10:55:26 +0100 Subject: [PATCH] Doc test 2 (#2606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🛠 Fix potential not set cache (#2554) * Fix potential not set cache * Fix minor frontend bug * Fix old mention stuff * 🛠 Minor badge fix * 🔎 Increase thumbnails size (#2556) * 🤖 Update KG sync (#2562) * Fix numbers formats * Make sure messages updates and users updates are sent * Do the same for companies and workspaces * 🛠Fix quote reply css (#2563) * 🌟 Add read only channel option (#2558) * 🌟 Add read only channel option * 🌟 Add read only in channel schema + fix update param * 🌟 Update fr.json * Delete tailwind.css~Stashed changes Co-authored-by: Romaric Mourgues * 🌟 Forward messages (#2566) * Add forward menu * Fix #2524 * Fix #2524 * Create modal * Finish initial version * Realtime online update * Realtime online update * Fix quoted message display * 🛠 Fix online service typo (#2575) * 🛠 Fix online service typo * Remove code * 🎨 Add checkbox atom and fix avatar atom (#2576) * Add checkbox atom and fix avatar atom * Make onChange optional * Depreciate old checkbox * Fix and add translations * Fix linter * 🛠 Fix ForwardMessages modal not in modal (#2578) * 🎨 Channel settings new UI screens (#2579) * Worked on channel settings new UI screens * Some changes * Update translations and logic * Fix channel icon display * Update back to store in S3 channel thumbnail * Fix linter * Fix int tests * 🛠 Fix backend save image (#2581) * 🌟 New invitation ui (#2577) * 🌟 added invitation ui * 🌟 added allow anyone by email feature * 🌟 add role check * 🌟 add invitation domain checks and implements auto join * 🛠 fixed e2e tests * 🌟 added invite domain checking * 🛠 fixed package-lock * Update changelog.md * Update changelog.md * 📚 Update changelog.md (#2587) * 🛠 2550 fixes (#2591) * Fix all from #2550 * Fix #2549 * Fix const / let * 🛠 Do not return files that doesn't exists anymore (#2592) * Do not return files that doesn't exists anymore * Fix filter * Fix displaying the forwarded message * Fix #2593 * 🛠 Fix scrollbar on Documents (#2599) * 🛠 Fix scrollbar on documents * Fix helpbar * 📚 Testing VitePress for documentation * Fix deployment issue * Fix deployment issue (#2604) * Fix it again Co-authored-by: Oubchid Co-authored-by: Khaled Ferjani --- .github/workflows/documentation.yml | 27 + Documentation/.gitignore | 2 + Documentation/docs/.vitepress/config.js | 18 + .../docs/.vitepress/theme/custom.css | 93 +++ Documentation/docs/.vitepress/theme/index.js | 4 + Documentation/docs/get-started.md | 1 + Documentation/docs/index.md | 1 + Documentation/docs/vite.config.js | 6 + Documentation/package.json | 18 + Documentation/yarn.lock | 692 ++++++++++++++++++ twake/backend/node/package.json | 2 + .../node/src/@types/free-email-domains.ts | 1 + .../services/knowledge-graph/api-client.ts | 12 +- .../services/knowledge-graph/index.ts | 26 +- .../services/knowledge-graph/provider.ts | 2 +- .../services/knowledge-graph/types.ts | 10 +- .../realtime/services/room-manager.ts | 6 +- .../src/services/channels/entities/channel.ts | 3 + .../channels/services/channel/service.ts | 47 +- .../channels/web/controllers/channel.ts | 13 + .../node/src/services/channels/web/routes.ts | 6 + .../node/src/services/channels/web/schemas.ts | 1 + .../src/services/console/clients/remote.ts | 1 + .../services/messages/entities/messages.ts | 10 +- .../services/messages/services/messages.ts | 52 +- .../node/src/services/online/service/index.ts | 5 + .../node/src/services/user/realtime.ts | 4 + .../src/services/user/services/companies.ts | 17 +- .../services/user/services/users/service.ts | 63 +- twake/backend/node/src/services/user/utils.ts | 1 + .../node/src/services/user/web/controller.ts | 4 +- .../node/src/services/user/web/schemas.ts | 1 + .../node/src/services/user/web/types.ts | 2 + .../services/workspaces/entities/workspace.ts | 5 + .../entities/workspace_invite_domain.ts | 30 + .../entities/workspace_invite_tokens.ts | 3 + .../services/workspaces/services/workspace.ts | 118 ++- .../controllers/workspace-invite-tokens.ts | 66 +- .../web/controllers/workspace-users.ts | 74 +- .../workspaces/web/controllers/workspaces.ts | 65 +- .../src/services/workspaces/web/routes.ts | 35 +- .../src/services/workspaces/web/schemas.ts | 26 + .../node/src/services/workspaces/web/types.ts | 14 +- twake/backend/node/src/utils/messages.ts | 9 +- .../workspaces.invite-tokens.spec.ts | 12 +- twake/frontend/package-lock.json | 31 + twake/frontend/package.json | 1 + twake/frontend/public/locales/en.json | 62 +- twake/frontend/public/locales/fr.json | 1 + .../src/app/atoms/avatar/index.stories.tsx | 7 +- twake/frontend/src/app/atoms/avatar/index.tsx | 87 ++- .../icons-agnostic/assets/check-outline.svg | 3 + .../app/atoms/icons-agnostic/assets/check.svg | 2 +- .../app/atoms/icons-agnostic/assets/users.svg | 3 + .../icons-agnostic/icons-agnostic.stories.tsx | 4 + .../src/app/atoms/icons-agnostic/index.tsx | 4 + .../app/atoms/icons-colored/assets/remove.svg | 3 + .../app/atoms/icons-colored/assets/sent.svg | 47 ++ .../icons-colored/icons-colored.stories.tsx | 4 + .../src/app/atoms/icons-colored/index.tsx | 10 + .../src/app/atoms/input/input-checkbox.tsx | 54 ++ .../atoms/input/stories/checkbox.stories.tsx | 40 + twake/frontend/src/app/atoms/modal/index.tsx | 2 +- .../add-user-button/add-user-button.tsx | 18 +- .../channel-members-modal.tsx | 51 +- .../components/channels-selector/index.tsx | 164 +++++ .../components-tester/group/inputs.js | 2 +- .../edit-channel/channel-access.tsx | 100 +++ .../edit-channel/channel-information.tsx | 248 +++++++ .../edit-channel/channel-notifications.tsx | 88 +++ .../edit-channel/channel-settings-menu.tsx | 366 +++++++++ .../src/app/components/edit-channel/index.tsx | 262 +++++++ .../app/components/forward-message/index.tsx | 103 +++ .../{checkbox.js => deprecated_checkbox.js} | 8 +- .../app/components/invitation/invitation.tsx | 108 +++ .../parts/allow-anyone-by-email.tsx | 56 ++ .../invitation/parts/bulk-invitation.tsx | 15 + .../parts/custom-role-invitation.tsx | 17 + .../invitation/parts/invitation-channels.tsx | 45 ++ .../parts/invitation-input-bulk.tsx | 84 +++ .../parts/invitation-input-list.tsx | 158 ++++ .../invitation/parts/invitation-sent.tsx | 53 ++ .../invitation/parts/invitation-target.tsx | 75 ++ .../invitation/parts/reached-limit.tsx | 15 + .../invitation/parts/workspace-link.tsx | 51 ++ .../src/app/deprecated/Apps/Drive/Drive.js | 12 +- .../hooks/member-hook.ts | 58 +- .../api/channel-members-api-client.ts | 27 +- .../features/channels/hooks/use-channel.ts | 4 + .../channels/hooks/use-favorite-channels.ts | 4 +- .../app/features/channels/types/channel.ts | 1 + .../app/features/companies/types/company.ts | 2 + .../src/app/features/global/utils/strings.ts | 13 + .../invitation/api/invitation-api-client.ts | 108 +++ .../hooks/use-invitation-channels.ts | 30 + .../invitation/hooks/use-invitation-users.ts | 71 ++ .../invitation/hooks/use-invitation.ts | 54 ++ .../features/invitation/state/invitation.ts | 47 ++ .../app/features/messages/types/message.ts | 7 +- .../users/services/current-user-service.ts | 12 +- .../workspaces/api/workspace-api-client.ts | 20 +- .../features/workspaces/types/workspace.ts | 5 + .../app/molecules/grouped-rows/base/index.tsx | 2 +- .../app/molecules/quoted-content/index.tsx | 66 +- .../frontend/src/app/molecules/tabs/index.tsx | 6 +- twake/frontend/src/app/styles/ui.less | 4 + .../applications/calendar/calendar-content.js | 2 +- .../calendar/modals/Part/DateSelector.js | 2 +- .../drive/viewer/drive-deprecated-viewer.tsx | 14 +- .../message/parts/MessageAttachments.tsx | 8 +- .../messages/message/parts/MessageContent.tsx | 182 +++-- .../messages/message/parts/Options.tsx | 19 + .../message/parts/message-forward/files.tsx | 19 + .../message/parts/message-forward/index.tsx | 78 ++ .../views/applications/messages/messages.tsx | 26 +- .../tasks/board/task/TaskEditor.js | 2 +- .../tasks/board/task/parts/Checklist.js | 2 +- .../applications/viewer/other/controls.tsx | 6 +- .../ChannelsWorkspace/WorkspaceChannel.tsx | 12 +- .../Modals/ChannelTemplateEditor.tsx | 150 ---- .../Modals/ChannelWorkspaceEditor.tsx | 136 ---- .../WorkspaceChannelList/ChannelRow.scss | 22 - .../WorkspaceChannelList/DirectChannelRow.tsx | 58 -- .../SearchListContainer.tsx | 59 -- .../WorkspaceChannelRow.tsx | 104 --- .../Modals/new-direct-channel-popup/index.tsx | 23 +- .../channels-bar/Parts/Channel/Channel.tsx | 22 +- .../Parts/Channel/ChannelCategory.tsx | 4 +- .../Parts/Channel/ChannelMenu.tsx | 231 +----- twake/frontend/src/app/views/client/index.tsx | 4 + .../ChannelHeader/ChannelHeader.tsx | 37 +- .../Pages/Workspace/WorkspaceIdentity.tsx | 14 +- .../Pages/WorkspacePartner.tsx | 25 +- .../app/views/login/internal/signin/signin.js | 2 +- twake/frontend/src/tailwind.css | 5 + twake/frontend/src/utils/transitions.ts | 18 + twake/frontend/tailwind.config.js | 2 + twake/frontend/yarn.lock | 22 + .../src/steps/create-channel.ts | 16 +- 139 files changed, 4787 insertions(+), 1152 deletions(-) create mode 100644 .github/workflows/documentation.yml create mode 100644 Documentation/.gitignore create mode 100644 Documentation/docs/.vitepress/config.js create mode 100644 Documentation/docs/.vitepress/theme/custom.css create mode 100644 Documentation/docs/.vitepress/theme/index.js create mode 100644 Documentation/docs/get-started.md create mode 100644 Documentation/docs/index.md create mode 100644 Documentation/docs/vite.config.js create mode 100644 Documentation/package.json create mode 100644 Documentation/yarn.lock create mode 100644 twake/backend/node/src/@types/free-email-domains.ts create mode 100644 twake/backend/node/src/services/workspaces/entities/workspace_invite_domain.ts create mode 100644 twake/frontend/src/app/atoms/icons-agnostic/assets/check-outline.svg create mode 100644 twake/frontend/src/app/atoms/icons-agnostic/assets/users.svg create mode 100644 twake/frontend/src/app/atoms/icons-colored/assets/remove.svg create mode 100644 twake/frontend/src/app/atoms/icons-colored/assets/sent.svg create mode 100644 twake/frontend/src/app/atoms/input/input-checkbox.tsx create mode 100644 twake/frontend/src/app/atoms/input/stories/checkbox.stories.tsx create mode 100644 twake/frontend/src/app/components/channels-selector/index.tsx create mode 100644 twake/frontend/src/app/components/edit-channel/channel-access.tsx create mode 100644 twake/frontend/src/app/components/edit-channel/channel-information.tsx create mode 100644 twake/frontend/src/app/components/edit-channel/channel-notifications.tsx create mode 100644 twake/frontend/src/app/components/edit-channel/channel-settings-menu.tsx create mode 100644 twake/frontend/src/app/components/edit-channel/index.tsx create mode 100644 twake/frontend/src/app/components/forward-message/index.tsx rename twake/frontend/src/app/components/inputs/{checkbox.js => deprecated_checkbox.js} (86%) create mode 100644 twake/frontend/src/app/components/invitation/invitation.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/allow-anyone-by-email.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/bulk-invitation.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/custom-role-invitation.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/invitation-channels.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/invitation-input-bulk.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/invitation-input-list.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/invitation-sent.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/invitation-target.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/reached-limit.tsx create mode 100644 twake/frontend/src/app/components/invitation/parts/workspace-link.tsx create mode 100644 twake/frontend/src/app/features/invitation/api/invitation-api-client.ts create mode 100644 twake/frontend/src/app/features/invitation/hooks/use-invitation-channels.ts create mode 100644 twake/frontend/src/app/features/invitation/hooks/use-invitation-users.ts create mode 100644 twake/frontend/src/app/features/invitation/hooks/use-invitation.ts create mode 100644 twake/frontend/src/app/features/invitation/state/invitation.ts create mode 100644 twake/frontend/src/app/views/applications/messages/message/parts/message-forward/files.tsx create mode 100644 twake/frontend/src/app/views/applications/messages/message/parts/message-forward/index.tsx delete mode 100755 twake/frontend/src/app/views/client/channels-bar/Modals/ChannelTemplateEditor.tsx delete mode 100644 twake/frontend/src/app/views/client/channels-bar/Modals/ChannelWorkspaceEditor.tsx delete mode 100644 twake/frontend/src/app/views/client/channels-bar/Modals/WorkspaceChannelList/ChannelRow.scss delete mode 100644 twake/frontend/src/app/views/client/channels-bar/Modals/WorkspaceChannelList/DirectChannelRow.tsx delete mode 100644 twake/frontend/src/app/views/client/channels-bar/Modals/WorkspaceChannelList/SearchListContainer.tsx delete mode 100644 twake/frontend/src/app/views/client/channels-bar/Modals/WorkspaceChannelList/WorkspaceChannelRow.tsx diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000000..3beaa451a0 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,27 @@ +name: Documentation + +on: + push: + branches: + - develop + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Use Node.js 16 + uses: actions/setup-node@v1 + with: + node-version: 16 + - run: npm install -g yarn + - run: cd Documentation && yarn install --frozen-lockfile + - name: Build + run: cd Documentation && yarn docs:build + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: Documentation/docs/.vitepress/dist diff --git a/Documentation/.gitignore b/Documentation/.gitignore new file mode 100644 index 0000000000..76add878f8 --- /dev/null +++ b/Documentation/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/Documentation/docs/.vitepress/config.js b/Documentation/docs/.vitepress/config.js new file mode 100644 index 0000000000..228267a9b6 --- /dev/null +++ b/Documentation/docs/.vitepress/config.js @@ -0,0 +1,18 @@ +export default { + base: "linagora/Twake", + title: "Twake", + description: "Public API documentation", + themeConfig: { + logo: "https://twake.app/images/logo-twake.svg", + sidebar: [ + { + text: "Guide", + items: [ + { text: "Introduction", link: "/" }, + { text: "Getting Started", link: "/get-started" }, + ], + }, + ], + }, + head: [], +}; diff --git a/Documentation/docs/.vitepress/theme/custom.css b/Documentation/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000000..7bac884894 --- /dev/null +++ b/Documentation/docs/.vitepress/theme/custom.css @@ -0,0 +1,93 @@ +#docsearch { + margin-right: 16px; +} + +#docsearch .DocSearch-Button { + justify-content: flex-start; + border: 1px solid transparent; + border-radius: 8px; + padding: 0 10px 0 12px; + width: 100%; + height: 40px; + background-color: var(--vp-c-bg-alt); +} + +#docsearch .DocSearch-Button:hover { + border-color: var(--vp-c-brand); + background: var(--vp-c-bg-alt); +} + +#docsearch .DocSearch-Button-Placeholder { + transition: color 0.2s; +} + +#docsearch .DocSearch-Button-Keys { + display: flex; + align-items: center; + min-width: auto; + pointer-events: auto; +} + +#docsearch .DocSearch-Button .DocSearch-Button-Key+.DocSearch-Button-Key { + border-right: 1px solid var(--vp-c-divider); + border-left: none; + border-radius: 0 4px 4px 0; + padding-left: 2px; + padding-right: 6px; +} + +#docsearch .DocSearch-Button .DocSearch-Button-Key { + display: block; + margin: 2px 0 0; + border: 1px solid var(--vp-c-divider); + border-right: none; + border-radius: 4px 0 0 4px; + padding-left: 6px; + min-width: 0; + width: auto; + height: 22px; + line-height: 22px; + font-family: var(--vp-font-family-base); + font-size: 12px; + font-weight: 500; + transition: color .5s,border-color .5s; +} + +/* Modal */ + +.DocSearch-Modal, .modal { + border-radius: 6px; + flex-direction: column; + margin: 60px auto auto; + max-width: 600px; + position: relative; +} + +.modal .flex-logo { + width: 56px; + margin-left: calc(100% - 74px); +} + +.DocSearch-Input { + margin-left: 0px; + margin-right: 14px; + font-size: 16px; + padding: 12px; +} + +.modal .search-item { + border-radius: 4px; + display: flex; + padding-bottom: 4px; + position: relative; + + align-items: center; + display: flex; + flex-direction: row; + height: 56px; + padding: 0 16px 0 16px; +} + +.modal .search-item-icon { + display: none; +} \ No newline at end of file diff --git a/Documentation/docs/.vitepress/theme/index.js b/Documentation/docs/.vitepress/theme/index.js new file mode 100644 index 0000000000..c495bc1b8d --- /dev/null +++ b/Documentation/docs/.vitepress/theme/index.js @@ -0,0 +1,4 @@ +import DefaultTheme from "vitepress/theme"; +import "./custom.css"; + +export default DefaultTheme; diff --git a/Documentation/docs/get-started.md b/Documentation/docs/get-started.md new file mode 100644 index 0000000000..59db870733 --- /dev/null +++ b/Documentation/docs/get-started.md @@ -0,0 +1 @@ +# Get started diff --git a/Documentation/docs/index.md b/Documentation/docs/index.md new file mode 100644 index 0000000000..1df55f4499 --- /dev/null +++ b/Documentation/docs/index.md @@ -0,0 +1 @@ +# Hello Twake diff --git a/Documentation/docs/vite.config.js b/Documentation/docs/vite.config.js new file mode 100644 index 0000000000..b293d10d1c --- /dev/null +++ b/Documentation/docs/vite.config.js @@ -0,0 +1,6 @@ +import { SearchPlugin } from "vitepress-plugin-search"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [SearchPlugin()], +}); diff --git a/Documentation/package.json b/Documentation/package.json new file mode 100644 index 0000000000..10bd00ef93 --- /dev/null +++ b/Documentation/package.json @@ -0,0 +1,18 @@ +{ + "name": "Documentation", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:serve": "vitepress serve docs" + }, + "devDependencies": { + "flexsearch": "^0.7.31", + "markdown-it": "^13.0.1", + "vitepress": "1.0.0-alpha.13", + "vitepress-plugin-search": "^1.0.4-alpha.15", + "vue": "^3.2.45" + } +} diff --git a/Documentation/yarn.lock b/Documentation/yarn.lock new file mode 100644 index 0000000000..ef28fd7eb3 --- /dev/null +++ b/Documentation/yarn.lock @@ -0,0 +1,692 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@algolia/autocomplete-core@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.7.2.tgz#8abbed88082f611997538760dffcb43b33b1fd1d" + integrity sha512-eclwUDC6qfApNnEfu1uWcL/rudQsn59tjEoUYZYE2JSXZrHLRjBUGMxiCoknobU2Pva8ejb0eRxpIYDtVVqdsw== + dependencies: + "@algolia/autocomplete-shared" "1.7.2" + +"@algolia/autocomplete-preset-algolia@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.7.2.tgz#9cd4f64b3d64399657ee2dc2b7e0a939e0713a26" + integrity sha512-+RYEG6B0QiGGfRb2G3MtPfyrl0dALF3cQNTWBzBX6p5o01vCCGTTinAm2UKG3tfc2CnOMAtnPLkzNZyJUpnVJw== + dependencies: + "@algolia/autocomplete-shared" "1.7.2" + +"@algolia/autocomplete-shared@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.7.2.tgz#daa23280e78d3b42ae9564d12470ae034db51a89" + integrity sha512-QCckjiC7xXHIUaIL3ektBtjJ0w7tTA3iqKcAE/Hjn1lZ5omp7i3Y4e09rAr9ZybqirL7AbxCLLq0Ra5DDPKeug== + +"@algolia/cache-browser-local-storage@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.14.2.tgz#d5b1b90130ca87c6321de876e167df9ec6524936" + integrity sha512-FRweBkK/ywO+GKYfAWbrepewQsPTIEirhi1BdykX9mxvBPtGNKccYAxvGdDCumU1jL4r3cayio4psfzKMejBlA== + dependencies: + "@algolia/cache-common" "4.14.2" + +"@algolia/cache-common@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.14.2.tgz#b946b6103c922f0c06006fb6929163ed2c67d598" + integrity sha512-SbvAlG9VqNanCErr44q6lEKD2qoK4XtFNx9Qn8FK26ePCI8I9yU7pYB+eM/cZdS9SzQCRJBbHUumVr4bsQ4uxg== + +"@algolia/cache-in-memory@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.14.2.tgz#88e4a21474f9ac05331c2fa3ceb929684a395a24" + integrity sha512-HrOukWoop9XB/VFojPv1R5SVXowgI56T9pmezd/djh2JnVN/vXswhXV51RKy4nCpqxyHt/aGFSq2qkDvj6KiuQ== + dependencies: + "@algolia/cache-common" "4.14.2" + +"@algolia/client-account@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.14.2.tgz#b76ac1ba9ea71e8c3f77a1805b48350dc0728a16" + integrity sha512-WHtriQqGyibbb/Rx71YY43T0cXqyelEU0lB2QMBRXvD2X0iyeGl4qMxocgEIcbHyK7uqE7hKgjT8aBrHqhgc1w== + dependencies: + "@algolia/client-common" "4.14.2" + "@algolia/client-search" "4.14.2" + "@algolia/transporter" "4.14.2" + +"@algolia/client-analytics@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.14.2.tgz#ca04dcaf9a78ee5c92c5cb5e9c74cf031eb2f1fb" + integrity sha512-yBvBv2mw+HX5a+aeR0dkvUbFZsiC4FKSnfqk9rrfX+QrlNOKEhCG0tJzjiOggRW4EcNqRmaTULIYvIzQVL2KYQ== + dependencies: + "@algolia/client-common" "4.14.2" + "@algolia/client-search" "4.14.2" + "@algolia/requester-common" "4.14.2" + "@algolia/transporter" "4.14.2" + +"@algolia/client-common@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.14.2.tgz#e1324e167ffa8af60f3e8bcd122110fd0bfd1300" + integrity sha512-43o4fslNLcktgtDMVaT5XwlzsDPzlqvqesRi4MjQz2x4/Sxm7zYg5LRYFol1BIhG6EwxKvSUq8HcC/KxJu3J0Q== + dependencies: + "@algolia/requester-common" "4.14.2" + "@algolia/transporter" "4.14.2" + +"@algolia/client-personalization@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-4.14.2.tgz#656bbb6157a3dd1a4be7de65e457fda136c404ec" + integrity sha512-ACCoLi0cL8CBZ1W/2juehSltrw2iqsQBnfiu/Rbl9W2yE6o2ZUb97+sqN/jBqYNQBS+o0ekTMKNkQjHHAcEXNw== + dependencies: + "@algolia/client-common" "4.14.2" + "@algolia/requester-common" "4.14.2" + "@algolia/transporter" "4.14.2" + +"@algolia/client-search@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.14.2.tgz#357bdb7e640163f0e33bad231dfcc21f67dc2e92" + integrity sha512-L5zScdOmcZ6NGiVbLKTvP02UbxZ0njd5Vq9nJAmPFtjffUSOGEp11BmD2oMJ5QvARgx2XbX4KzTTNS5ECYIMWw== + dependencies: + "@algolia/client-common" "4.14.2" + "@algolia/requester-common" "4.14.2" + "@algolia/transporter" "4.14.2" + +"@algolia/logger-common@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.14.2.tgz#b74b3a92431f92665519d95942c246793ec390ee" + integrity sha512-/JGlYvdV++IcMHBnVFsqEisTiOeEr6cUJtpjz8zc0A9c31JrtLm318Njc72p14Pnkw3A/5lHHh+QxpJ6WFTmsA== + +"@algolia/logger-console@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.14.2.tgz#ec49cb47408f5811d4792598683923a800abce7b" + integrity sha512-8S2PlpdshbkwlLCSAB5f8c91xyc84VM9Ar9EdfE9UmX+NrKNYnWR1maXXVDQQoto07G1Ol/tYFnFVhUZq0xV/g== + dependencies: + "@algolia/logger-common" "4.14.2" + +"@algolia/requester-browser-xhr@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.14.2.tgz#a2cd4d9d8d90d53109cc7f3682dc6ebf20f798f2" + integrity sha512-CEh//xYz/WfxHFh7pcMjQNWgpl4wFB85lUMRyVwaDPibNzQRVcV33YS+63fShFWc2+42YEipFGH2iPzlpszmDw== + dependencies: + "@algolia/requester-common" "4.14.2" + +"@algolia/requester-common@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.14.2.tgz#bc4e9e5ee16c953c0ecacbfb334a33c30c28b1a1" + integrity sha512-73YQsBOKa5fvVV3My7iZHu1sUqmjjfs9TteFWwPwDmnad7T0VTCopttcsM3OjLxZFtBnX61Xxl2T2gmG2O4ehg== + +"@algolia/requester-node-http@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.14.2.tgz#7c1223a1785decaab1def64c83dade6bea45e115" + integrity sha512-oDbb02kd1o5GTEld4pETlPZLY0e+gOSWjWMJHWTgDXbv9rm/o2cF7japO6Vj1ENnrqWvLBmW1OzV9g6FUFhFXg== + dependencies: + "@algolia/requester-common" "4.14.2" + +"@algolia/transporter@4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.14.2.tgz#77c069047fb1a4359ee6a51f51829508e44a1e3d" + integrity sha512-t89dfQb2T9MFQHidjHcfhh6iGMNwvuKUvojAj+JsrHAGbuSy7yE4BylhLX6R0Q1xYRoC4Vvv+O5qIw/LdnQfsQ== + dependencies: + "@algolia/cache-common" "4.14.2" + "@algolia/logger-common" "4.14.2" + "@algolia/requester-common" "4.14.2" + +"@babel/parser@^7.16.4": + version "7.20.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2" + integrity sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg== + +"@docsearch/css@3.3.0", "@docsearch/css@^3.2.1": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.3.0.tgz#d698e48302d12240d7c2f7452ccb2d2239a8cd80" + integrity sha512-rODCdDtGyudLj+Va8b6w6Y85KE85bXRsps/R4Yjwt5vueXKXZQKYw0aA9knxLBT6a/bI/GMrAcmCR75KYOM6hg== + +"@docsearch/js@^3.2.1": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@docsearch/js/-/js-3.3.0.tgz#c8f614b722cc8a6375e83f9c27557e9398d6a4d4" + integrity sha512-oFXWRPNvPxAzBhnFJ9UCFIYZiQNc3Yrv6912nZHw/UIGxsyzKpNRZgHq8HDk1niYmOSoLKtVFcxkccpQmYGFyg== + dependencies: + "@docsearch/react" "3.3.0" + preact "^10.0.0" + +"@docsearch/react@3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-3.3.0.tgz#b8ac8e7f49b9bf2f96d34c24bc1cfd097ec0eead" + integrity sha512-fhS5adZkae2SSdMYEMVg6pxI5a/cE+tW16ki1V0/ur4Fdok3hBRkmN/H8VvlXnxzggkQIIRIVvYPn00JPjen3A== + dependencies: + "@algolia/autocomplete-core" "1.7.2" + "@algolia/autocomplete-preset-algolia" "1.7.2" + "@docsearch/css" "3.3.0" + algoliasearch "^4.0.0" + +"@esbuild/android-arm@0.15.13": + version "0.15.13" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.13.tgz#ce11237a13ee76d5eae3908e47ba4ddd380af86a" + integrity sha512-RY2fVI8O0iFUNvZirXaQ1vMvK0xhCcl0gqRj74Z6yEiO1zAUa7hbsdwZM1kzqbxHK7LFyMizipfXT3JME+12Hw== + +"@esbuild/linux-loong64@0.15.13": + version "0.15.13" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.13.tgz#64e8825bf0ce769dac94ee39d92ebe6272020dfc" + integrity sha512-+BoyIm4I8uJmH/QDIH0fu7MG0AEx9OXEDXnqptXCwKOlOqZiS4iraH1Nr7/ObLMokW3sOCeBNyD68ATcV9b9Ag== + +"@types/flexsearch@^0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@types/flexsearch/-/flexsearch-0.7.3.tgz#ee79b1618035c82284278e05652e91116765b634" + integrity sha512-HXwADeHEP4exXkCIwy2n1+i0f1ilP1ETQOH5KDOugjkTFZPntWo0Gr8stZOaebkxsdx+k0X/K6obU/+it07ocg== + +"@types/linkify-it@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9" + integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA== + +"@types/markdown-it@^12.2.3": + version "12.2.3" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51" + integrity sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ== + dependencies: + "@types/linkify-it" "*" + "@types/mdurl" "*" + +"@types/mdurl@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" + integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== + +"@types/web-bluetooth@^0.0.16": + version "0.0.16" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz#1d12873a8e49567371f2a75fe3e7f7edca6662d8" + integrity sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ== + +"@vitejs/plugin-vue@^3.0.3": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz#a1484089dd85d6528f435743f84cdd0d215bbb54" + integrity sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw== + +"@vue/compiler-core@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.45.tgz#d9311207d96f6ebd5f4660be129fb99f01ddb41b" + integrity sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/shared" "3.2.45" + estree-walker "^2.0.2" + source-map "^0.6.1" + +"@vue/compiler-dom@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.45.tgz#c43cc15e50da62ecc16a42f2622d25dc5fd97dce" + integrity sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw== + dependencies: + "@vue/compiler-core" "3.2.45" + "@vue/shared" "3.2.45" + +"@vue/compiler-sfc@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.45.tgz#7f7989cc04ec9e7c55acd406827a2c4e96872c70" + integrity sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.45" + "@vue/compiler-dom" "3.2.45" + "@vue/compiler-ssr" "3.2.45" + "@vue/reactivity-transform" "3.2.45" + "@vue/shared" "3.2.45" + estree-walker "^2.0.2" + magic-string "^0.25.7" + postcss "^8.1.10" + source-map "^0.6.1" + +"@vue/compiler-ssr@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz#bd20604b6e64ea15344d5b6278c4141191c983b2" + integrity sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ== + dependencies: + "@vue/compiler-dom" "3.2.45" + "@vue/shared" "3.2.45" + +"@vue/devtools-api@^6.2.1": + version "6.4.5" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.4.5.tgz#d54e844c1adbb1e677c81c665ecef1a2b4bb8380" + integrity sha512-JD5fcdIuFxU4fQyXUu3w2KpAJHzTVdN+p4iOX2lMWSHMOoQdMAcpFLZzm9Z/2nmsoZ1a96QEhZ26e50xLBsgOQ== + +"@vue/reactivity-transform@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.45.tgz#07ac83b8138550c83dfb50db43cde1e0e5e8124d" + integrity sha512-BHVmzYAvM7vcU5WmuYqXpwaBHjsS8T63jlKGWVtHxAHIoMIlmaMyurUSEs1Zcg46M4AYT5MtB1U274/2aNzjJQ== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.45" + "@vue/shared" "3.2.45" + estree-walker "^2.0.2" + magic-string "^0.25.7" + +"@vue/reactivity@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.45.tgz#412a45b574de601be5a4a5d9a8cbd4dee4662ff0" + integrity sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A== + dependencies: + "@vue/shared" "3.2.45" + +"@vue/runtime-core@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.45.tgz#7ad7ef9b2519d41062a30c6fa001ec43ac549c7f" + integrity sha512-gzJiTA3f74cgARptqzYswmoQx0fIA+gGYBfokYVhF8YSXjWTUA2SngRzZRku2HbGbjzB6LBYSbKGIaK8IW+s0A== + dependencies: + "@vue/reactivity" "3.2.45" + "@vue/shared" "3.2.45" + +"@vue/runtime-dom@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.45.tgz#1a2ef6ee2ad876206fbbe2a884554bba2d0faf59" + integrity sha512-cy88YpfP5Ue2bDBbj75Cb4bIEZUMM/mAkDMfqDTpUYVgTf/kuQ2VQ8LebuZ8k6EudgH8pYhsGWHlY0lcxlvTwA== + dependencies: + "@vue/runtime-core" "3.2.45" + "@vue/shared" "3.2.45" + csstype "^2.6.8" + +"@vue/server-renderer@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.45.tgz#ca9306a0c12b0530a1a250e44f4a0abac6b81f3f" + integrity sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g== + dependencies: + "@vue/compiler-ssr" "3.2.45" + "@vue/shared" "3.2.45" + +"@vue/shared@3.2.45": + version "3.2.45" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.45.tgz#a3fffa7489eafff38d984e23d0236e230c818bc2" + integrity sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg== + +"@vueuse/core@^9.1.0": + version "9.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.5.0.tgz#6726e952e8f92b465457d3bc95deb385aacd9a41" + integrity sha512-6GsWBsJHEb3sYw15mbLrcbslAVY45pkzjJYTKYKCXv88z7srAF0VEW0q+oXKsl58tCbqooplInahXFg8Yo1m4w== + dependencies: + "@types/web-bluetooth" "^0.0.16" + "@vueuse/metadata" "9.5.0" + "@vueuse/shared" "9.5.0" + vue-demi "*" + +"@vueuse/metadata@9.5.0": + version "9.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.5.0.tgz#b01c84230261ddee4d439ae5d9c21343dc5ae565" + integrity sha512-4M1AyPZmIv41pym+K5+4wup3bKuYebbH8w8BROY1hmT7rIwcyS4tEL+UsGz0Hiu1FCOxcoBrwtAizc0YmBJjyQ== + +"@vueuse/shared@9.5.0": + version "9.5.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.5.0.tgz#f5306548af0dc9f2b3a0d4da74e62bfdd6211241" + integrity sha512-HnnCWU1Vg9CVWRCcI8ohDKDRB2Sc4bTgT1XAIaoLSfVHHn+TKbrox6pd3klCSw4UDxkhDfOk8cAdcK+Z5KleCA== + dependencies: + vue-demi "*" + +algoliasearch@^4.0.0: + version "4.14.2" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.14.2.tgz#63f142583bfc3a9bd3cd4a1b098bf6fe58e56f6c" + integrity sha512-ngbEQonGEmf8dyEh5f+uOIihv4176dgbuOZspiuhmTTBRBuzWu3KCGHre6uHj5YyuC7pNvQGzB6ZNJyZi0z+Sg== + dependencies: + "@algolia/cache-browser-local-storage" "4.14.2" + "@algolia/cache-common" "4.14.2" + "@algolia/cache-in-memory" "4.14.2" + "@algolia/client-account" "4.14.2" + "@algolia/client-analytics" "4.14.2" + "@algolia/client-common" "4.14.2" + "@algolia/client-personalization" "4.14.2" + "@algolia/client-search" "4.14.2" + "@algolia/logger-common" "4.14.2" + "@algolia/logger-console" "4.14.2" + "@algolia/requester-browser-xhr" "4.14.2" + "@algolia/requester-common" "4.14.2" + "@algolia/requester-node-http" "4.14.2" + "@algolia/transporter" "4.14.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +body-scroll-lock@^4.0.0-beta.0: + version "4.0.0-beta.0" + resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-4.0.0-beta.0.tgz#4f78789d10e6388115c0460cd6d7d4dd2bbc4f7e" + integrity sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ== + +csstype@^2.6.8: + version "2.6.21" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e" + integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w== + +entities@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" + integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== + +esbuild-android-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.13.tgz#5f25864055dbd62e250f360b38b4c382224063af" + integrity sha512-yRorukXBlokwTip+Sy4MYskLhJsO0Kn0/Fj43s1krVblfwP+hMD37a4Wmg139GEsMLl+vh8WXp2mq/cTA9J97g== + +esbuild-android-arm64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.13.tgz#d8820f999314efbe8e0f050653a99ff2da632b0f" + integrity sha512-TKzyymLD6PiVeyYa4c5wdPw87BeAiTXNtK6amWUcXZxkV51gOk5u5qzmDaYSwiWeecSNHamFsaFjLoi32QR5/w== + +esbuild-darwin-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.13.tgz#99ae7fdaa43947b06cd9d1a1c3c2c9f245d81fd0" + integrity sha512-WAx7c2DaOS6CrRcoYCgXgkXDliLnFv3pQLV6GeW1YcGEZq2Gnl8s9Pg7ahValZkpOa0iE/ojRVQ87sbUhF1Cbg== + +esbuild-darwin-arm64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.13.tgz#bafa1814354ad1a47adcad73de416130ef7f55e3" + integrity sha512-U6jFsPfSSxC3V1CLiQqwvDuj3GGrtQNB3P3nNC3+q99EKf94UGpsG9l4CQ83zBs1NHrk1rtCSYT0+KfK5LsD8A== + +esbuild-freebsd-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.13.tgz#84ef85535c5cc38b627d1c5115623b088d1de161" + integrity sha512-whItJgDiOXaDG/idy75qqevIpZjnReZkMGCgQaBWZuKHoElDJC1rh7MpoUgupMcdfOd+PgdEwNQW9DAE6i8wyA== + +esbuild-freebsd-arm64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.13.tgz#033f21de434ec8e0c478054b119af8056763c2d8" + integrity sha512-6pCSWt8mLUbPtygv7cufV0sZLeylaMwS5Fznj6Rsx9G2AJJsAjQ9ifA+0rQEIg7DwJmi9it+WjzNTEAzzdoM3Q== + +esbuild-linux-32@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.13.tgz#54290ea8035cba0faf1791ce9ae6693005512535" + integrity sha512-VbZdWOEdrJiYApm2kkxoTOgsoCO1krBZ3quHdYk3g3ivWaMwNIVPIfEE0f0XQQ0u5pJtBsnk2/7OPiCFIPOe/w== + +esbuild-linux-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.13.tgz#4264249281ea388ead948614b57fb1ddf7779a2c" + integrity sha512-rXmnArVNio6yANSqDQlIO4WiP+Cv7+9EuAHNnag7rByAqFVuRusLbGi2697A5dFPNXoO//IiogVwi3AdcfPC6A== + +esbuild-linux-arm64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.13.tgz#9323c333924f97a02bdd2ae8912b36298acb312d" + integrity sha512-alEMGU4Z+d17U7KQQw2IV8tQycO6T+rOrgW8OS22Ua25x6kHxoG6Ngry6Aq6uranC+pNWNMB6aHFPh7aTQdORQ== + +esbuild-linux-arm@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.13.tgz#b407f47b3ae721fe4e00e19e9f19289bef87a111" + integrity sha512-Ac6LpfmJO8WhCMQmO253xX2IU2B3wPDbl4IvR0hnqcPrdfCaUa2j/lLMGTjmQ4W5JsJIdHEdW12dG8lFS0MbxQ== + +esbuild-linux-mips64le@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.13.tgz#bdf905aae5c0bcaa8f83567fe4c4c1bdc1f14447" + integrity sha512-47PgmyYEu+yN5rD/MbwS6DxP2FSGPo4Uxg5LwIdxTiyGC2XKwHhHyW7YYEDlSuXLQXEdTO7mYe8zQ74czP7W8A== + +esbuild-linux-ppc64le@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.13.tgz#2911eae1c90ff58a3bd3259cb557235df25aa3b4" + integrity sha512-z6n28h2+PC1Ayle9DjKoBRcx/4cxHoOa2e689e2aDJSaKug3jXcQw7mM+GLg+9ydYoNzj8QxNL8ihOv/OnezhA== + +esbuild-linux-riscv64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.13.tgz#1837c660be12b1d20d2a29c7189ea703f93e9265" + integrity sha512-+Lu4zuuXuQhgLUGyZloWCqTslcCAjMZH1k3Xc9MSEJEpEFdpsSU0sRDXAnk18FKOfEjhu4YMGaykx9xjtpA6ow== + +esbuild-linux-s390x@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.13.tgz#d52880ece229d1bd10b2d936b792914ffb07c7fc" + integrity sha512-BMeXRljruf7J0TMxD5CIXS65y7puiZkAh+s4XFV9qy16SxOuMhxhVIXYLnbdfLrsYGFzx7U9mcdpFWkkvy/Uag== + +esbuild-netbsd-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.13.tgz#de14da46f1d20352b43e15d97a80a8788275e6ed" + integrity sha512-EHj9QZOTel581JPj7UO3xYbltFTYnHy+SIqJVq6yd3KkCrsHRbapiPb0Lx3EOOtybBEE9EyqbmfW1NlSDsSzvQ== + +esbuild-openbsd-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.13.tgz#45e8a5fd74d92ad8f732c43582369c7990f5a0ac" + integrity sha512-nkuDlIjF/sfUhfx8SKq0+U+Fgx5K9JcPq1mUodnxI0x4kBdCv46rOGWbuJ6eof2n3wdoCLccOoJAbg9ba/bT2w== + +esbuild-sunos-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.13.tgz#f646ac3da7aac521ee0fdbc192750c87da697806" + integrity sha512-jVeu2GfxZQ++6lRdY43CS0Tm/r4WuQQ0Pdsrxbw+aOrHQPHV0+LNOLnvbN28M7BSUGnJnHkHm2HozGgNGyeIRw== + +esbuild-windows-32@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.13.tgz#fb4fe77c7591418880b3c9b5900adc4c094f2401" + integrity sha512-XoF2iBf0wnqo16SDq+aDGi/+QbaLFpkiRarPVssMh9KYbFNCqPLlGAWwDvxEVz+ywX6Si37J2AKm+AXq1kC0JA== + +esbuild-windows-64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.13.tgz#1fca8c654392c0c31bdaaed168becfea80e20660" + integrity sha512-Et6htEfGycjDrtqb2ng6nT+baesZPYQIW+HUEHK4D1ncggNrDNk3yoboYQ5KtiVrw/JaDMNttz8rrPubV/fvPQ== + +esbuild-windows-arm64@0.15.13: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.13.tgz#4ffd01b6b2888603f1584a2fe96b1f6a6f2b3dd8" + integrity sha512-3bv7tqntThQC9SWLRouMDmZnlOukBhOCTlkzNqzGCmrkCJI7io5LLjwJBOVY6kOUlIvdxbooNZwjtBvj+7uuVg== + +esbuild@^0.15.9: + version "0.15.13" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.13.tgz#7293480038feb2bafa91d3f6a20edab3ba6c108a" + integrity sha512-Cu3SC84oyzzhrK/YyN4iEVy2jZu5t2fz66HEOShHURcjSkOSAVL8C/gfUT+lDJxkVHpg8GZ10DD0rMHRPqMFaQ== + optionalDependencies: + "@esbuild/android-arm" "0.15.13" + "@esbuild/linux-loong64" "0.15.13" + esbuild-android-64 "0.15.13" + esbuild-android-arm64 "0.15.13" + esbuild-darwin-64 "0.15.13" + esbuild-darwin-arm64 "0.15.13" + esbuild-freebsd-64 "0.15.13" + esbuild-freebsd-arm64 "0.15.13" + esbuild-linux-32 "0.15.13" + esbuild-linux-64 "0.15.13" + esbuild-linux-arm "0.15.13" + esbuild-linux-arm64 "0.15.13" + esbuild-linux-mips64le "0.15.13" + esbuild-linux-ppc64le "0.15.13" + esbuild-linux-riscv64 "0.15.13" + esbuild-linux-s390x "0.15.13" + esbuild-netbsd-64 "0.15.13" + esbuild-openbsd-64 "0.15.13" + esbuild-sunos-64 "0.15.13" + esbuild-windows-32 "0.15.13" + esbuild-windows-64 "0.15.13" + esbuild-windows-arm64 "0.15.13" + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +flexsearch@^0.7.31: + version "0.7.31" + resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.31.tgz#065d4110b95083110b9b6c762a71a77cc52e4702" + integrity sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA== + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +is-core-module@^2.9.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144" + integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw== + dependencies: + has "^1.0.3" + +jsonc-parser@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" + integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== + +linkify-it@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" + integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw== + dependencies: + uc.micro "^1.0.1" + +magic-string@^0.25.7: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + +markdown-it@^13.0.1: + version "13.0.1" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430" + integrity sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q== + dependencies: + argparse "^2.0.1" + entities "~3.0.1" + linkify-it "^4.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== + +nanoid@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +postcss@^8.1.10, postcss@^8.4.18: + version "8.4.19" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc" + integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA== + dependencies: + nanoid "^3.3.4" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +preact@^10.0.0: + version "10.11.2" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.2.tgz#e43f2a2f2985dedb426bb4c765b7bb037734f8a8" + integrity sha512-skAwGDFmgxhq1DCBHke/9e12ewkhc7WYwjuhHB8HHS8zkdtITXLRmUMTeol2ldxvLwYtwbFeifZ9uDDWuyL4Iw== + +resolve@^1.22.1: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +rollup@^2.79.1: + version "2.79.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" + integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== + optionalDependencies: + fsevents "~2.3.2" + +shiki@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.11.1.tgz#df0f719e7ab592c484d8b73ec10e215a503ab8cc" + integrity sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA== + dependencies: + jsonc-parser "^3.0.0" + vscode-oniguruma "^1.6.1" + vscode-textmate "^6.0.0" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + +vite@^3.0.8: + version "3.2.3" + resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.3.tgz#7a68d9ef73eff7ee6dc0718ad3507adfc86944a7" + integrity sha512-h8jl1TZ76eGs3o2dIBSsvXDLb1m/Ec1iej8ZMdz+PsaFUsftZeWe2CZOI3qogEsMNaywc17gu0q6cQDzh/weCQ== + dependencies: + esbuild "^0.15.9" + postcss "^8.4.18" + resolve "^1.22.1" + rollup "^2.79.1" + optionalDependencies: + fsevents "~2.3.2" + +vitepress-plugin-search@^1.0.4-alpha.15: + version "1.0.4-alpha.15" + resolved "https://registry.yarnpkg.com/vitepress-plugin-search/-/vitepress-plugin-search-1.0.4-alpha.15.tgz#c26575a7b5f1eaa087835e702a9b93902a2d7948" + integrity sha512-Ef/VkhTVYlECVI0H9Ck6745UNPfYFppAqnlxVSMJXdxP2vjOZ5TYNczlTTQ2p9dh16MFw/IurbL1/GrG4nXdNw== + dependencies: + "@types/flexsearch" "^0.7.3" + "@types/markdown-it" "^12.2.3" + markdown-it "^13.0.1" + +vitepress@1.0.0-alpha.13: + version "1.0.0-alpha.13" + resolved "https://registry.yarnpkg.com/vitepress/-/vitepress-1.0.0-alpha.13.tgz#dfc0af0c624fbb81496ced9095b3f5ecb7a3a8ce" + integrity sha512-gCbKb+6o0g5wHt2yyqBPk7FcvrB+MfwGtg1JMS5p99GTQR87l3b7symCl8o1ecv7MDXwJ2mPB8ZrYNLnQAJxLQ== + dependencies: + "@docsearch/css" "^3.2.1" + "@docsearch/js" "^3.2.1" + "@vitejs/plugin-vue" "^3.0.3" + "@vue/devtools-api" "^6.2.1" + "@vueuse/core" "^9.1.0" + body-scroll-lock "^4.0.0-beta.0" + shiki "^0.11.1" + vite "^3.0.8" + vue "^3.2.37" + +vscode-oniguruma@^1.6.1: + version "1.6.2" + resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz#aeb9771a2f1dbfc9083c8a7fdd9cccaa3f386607" + integrity sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA== + +vscode-textmate@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-6.0.0.tgz#a3777197235036814ac9a92451492f2748589210" + integrity sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ== + +vue-demi@*: + version "0.13.11" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99" + integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A== + +vue@^3.2.37, vue@^3.2.45: + version "3.2.45" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.45.tgz#94a116784447eb7dbd892167784619fef379b3c8" + integrity sha512-9Nx/Mg2b2xWlXykmCwiTUCWHbWIj53bnkizBxKai1g61f2Xit700A1ljowpTIM11e3uipOeiPcSqnmBg6gyiaA== + dependencies: + "@vue/compiler-dom" "3.2.45" + "@vue/compiler-sfc" "3.2.45" + "@vue/runtime-dom" "3.2.45" + "@vue/server-renderer" "3.2.45" + "@vue/shared" "3.2.45" diff --git a/twake/backend/node/package.json b/twake/backend/node/package.json index 44e17e5619..5bfba43c3a 100644 --- a/twake/backend/node/package.json +++ b/twake/backend/node/package.json @@ -141,11 +141,13 @@ "find-my-way": "^5.2.0", "fluent-ffmpeg": "^2.1.2", "fold-to-ascii": "^5.0.0", + "free-email-domains": "^1.0.26", "generate-password": "^1.6.0", "get-website-favicon": "^0.0.7", "html-metadata-parser": "^2.0.4", "html-to-text": "^8.2.1", "jsonwebtoken": "^8.5.1", + "keyv": "^4.5.0", "lodash": "^4.17.21", "match-all": "^1.2.6", "minio": "^7.0.18", diff --git a/twake/backend/node/src/@types/free-email-domains.ts b/twake/backend/node/src/@types/free-email-domains.ts new file mode 100644 index 0000000000..a25d0bf4bd --- /dev/null +++ b/twake/backend/node/src/@types/free-email-domains.ts @@ -0,0 +1 @@ +declare module "free-email-domains"; diff --git a/twake/backend/node/src/core/platform/services/knowledge-graph/api-client.ts b/twake/backend/node/src/core/platform/services/knowledge-graph/api-client.ts index 5e99b90651..ec8040f7b2 100644 --- a/twake/backend/node/src/core/platform/services/knowledge-graph/api-client.ts +++ b/twake/backend/node/src/core/platform/services/knowledge-graph/api-client.ts @@ -76,9 +76,9 @@ export default class KnowledgeGraphAPIClient { user_id: user.id, email: user.email_canonical, username: user.username_canonical, - user_last_activity: user.last_activity.toLocaleString(), + user_last_activity: user.last_activity, first_name: user.first_name, - user_created_at: user.creation_date.toLocaleString(), + user_created_at: user.creation_date, last_name: user.last_name, company_id: companyId, }, @@ -116,7 +116,7 @@ export default class KnowledgeGraphAPIClient { } } - public async onMessageCreated( + public async onMessageUpsert( channelId: string, message: Partial, sensitiveData: boolean, @@ -129,10 +129,10 @@ export default class KnowledgeGraphAPIClient { id: "Message", properties: { message_thread_id: message.thread_id, - message_created_at: message.created_at.toLocaleString(), message_content: sensitiveData ? message.text : "", type_message: message.type, - message_updated_at: message.updated_at.toLocaleString(), + message_created_at: message.created_at, + message_updated_at: message.updated_at, user_id: message.user_id, channel_id: channelId, workspace_id: message.cache?.workspace_id, @@ -144,7 +144,7 @@ export default class KnowledgeGraphAPIClient { }); if (response.statusText === "OK") { - this.logger.info("onMessageCreated %o", response.config.data); + this.logger.info("onMessageUpsert %o", response.config.data); } } diff --git a/twake/backend/node/src/core/platform/services/knowledge-graph/index.ts b/twake/backend/node/src/core/platform/services/knowledge-graph/index.ts index 3a10e451d7..3f6c2f6e9c 100644 --- a/twake/backend/node/src/core/platform/services/knowledge-graph/index.ts +++ b/twake/backend/node/src/core/platform/services/knowledge-graph/index.ts @@ -30,27 +30,27 @@ export default class KnowledgeGraphService } localEventBus.subscribe>( - KnowledgeGraphEvents.COMPANY_CREATED, + KnowledgeGraphEvents.COMPANY_UPSERT, this.onCompanyCreated.bind(this), ); localEventBus.subscribe>( - KnowledgeGraphEvents.WORKSPACE_CREATED, + KnowledgeGraphEvents.WORKSPACE_UPSERT, this.onWorkspaceCreated.bind(this), ); localEventBus.subscribe>( - KnowledgeGraphEvents.CHANNEL_CREATED, + KnowledgeGraphEvents.CHANNEL_UPSERT, this.onChannelCreated.bind(this), ); localEventBus.subscribe>( - KnowledgeGraphEvents.MESSAGE_CREATED, - this.onMessageCreated.bind(this), + KnowledgeGraphEvents.MESSAGE_UPSERT, + this.onMessageUpsert.bind(this), ); localEventBus.subscribe>( - KnowledgeGraphEvents.USER_CREATED, + KnowledgeGraphEvents.USER_UPSERT, this.onUserCreated.bind(this), ); @@ -58,7 +58,7 @@ export default class KnowledgeGraphService } async onCompanyCreated(data: KnowledgeGraphGenericEventPayload): Promise { - this.logger.info(`${KnowledgeGraphEvents.COMPANY_CREATED} %o`, data); + this.logger.info(`${KnowledgeGraphEvents.COMPANY_UPSERT} %o`, data); if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.id]))) { this.kgAPIClient.onCompanyCreated(data.resource); @@ -66,7 +66,7 @@ export default class KnowledgeGraphService } async onWorkspaceCreated(data: KnowledgeGraphGenericEventPayload): Promise { - this.logger.info(`${KnowledgeGraphEvents.WORKSPACE_CREATED} %o`, data); + this.logger.info(`${KnowledgeGraphEvents.WORKSPACE_UPSERT} %o`, data); if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.company_id]))) { this.kgAPIClient.onWorkspaceCreated(data.resource); @@ -74,15 +74,15 @@ export default class KnowledgeGraphService } async onChannelCreated(data: KnowledgeGraphGenericEventPayload): Promise { - this.logger.info(`${KnowledgeGraphEvents.CHANNEL_CREATED} %o`, data); + this.logger.info(`${KnowledgeGraphEvents.CHANNEL_UPSERT} %o`, data); if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.company_id]))) { this.kgAPIClient.onChannelCreated(data.resource); } } - async onMessageCreated(data: KnowledgeGraphGenericEventPayload): Promise { - this.logger.debug(`${KnowledgeGraphEvents.MESSAGE_CREATED} %o`, data); + async onMessageUpsert(data: KnowledgeGraphGenericEventPayload): Promise { + this.logger.debug(`${KnowledgeGraphEvents.MESSAGE_UPSERT} %o`, data); const allowedToShare = await this.shouldForwardEvent( [data.resource.cache.company_id], @@ -90,7 +90,7 @@ export default class KnowledgeGraphService ); if (this.kgAPIClient && allowedToShare) { - this.kgAPIClient.onMessageCreated( + this.kgAPIClient.onMessageUpsert( data.resource.cache.company_id, data.resource, allowedToShare === "all", @@ -99,7 +99,7 @@ export default class KnowledgeGraphService } async onUserCreated(data: KnowledgeGraphGenericEventPayload): Promise { - this.logger.info(`${KnowledgeGraphEvents.USER_CREATED} %o`, data); + this.logger.info(`${KnowledgeGraphEvents.USER_UPSERT} %o`, data); if ( this.kgAPIClient && diff --git a/twake/backend/node/src/core/platform/services/knowledge-graph/provider.ts b/twake/backend/node/src/core/platform/services/knowledge-graph/provider.ts index 435492f8e9..fc5a1f59db 100644 --- a/twake/backend/node/src/core/platform/services/knowledge-graph/provider.ts +++ b/twake/backend/node/src/core/platform/services/knowledge-graph/provider.ts @@ -10,6 +10,6 @@ export default interface KnowledgeGraphAPI extends TwakeServiceProvider { onCompanyCreated(data: KnowledgeGraphGenericEventPayload): void; onWorkspaceCreated(data: KnowledgeGraphGenericEventPayload): void; onChannelCreated(data: KnowledgeGraphGenericEventPayload): void; - onMessageCreated(data: KnowledgeGraphGenericEventPayload): void; + onMessageUpsert(data: KnowledgeGraphGenericEventPayload): void; onUserCreated(data: KnowledgeGraphGenericEventPayload): void; } diff --git a/twake/backend/node/src/core/platform/services/knowledge-graph/types.ts b/twake/backend/node/src/core/platform/services/knowledge-graph/types.ts index aebc1249df..09f4a56ef3 100644 --- a/twake/backend/node/src/core/platform/services/knowledge-graph/types.ts +++ b/twake/backend/node/src/core/platform/services/knowledge-graph/types.ts @@ -84,9 +84,9 @@ export type KnowledgeGraphGenericEventPayload = { }; export enum KnowledgeGraphEvents { - COMPANY_CREATED = "kg:company:created", - WORKSPACE_CREATED = "kg:workspace:created", - CHANNEL_CREATED = "kg:channel:created", - MESSAGE_CREATED = "kg:message:created", - USER_CREATED = "kg:user:created", + COMPANY_UPSERT = "kg:company:upsert", + WORKSPACE_UPSERT = "kg:workspace:upsert", + CHANNEL_UPSERT = "kg:channel:upsert", + MESSAGE_UPSERT = "kg:message:upsert", + USER_UPSERT = "kg:user:upsert", } diff --git a/twake/backend/node/src/core/platform/services/realtime/services/room-manager.ts b/twake/backend/node/src/core/platform/services/realtime/services/room-manager.ts index d0a57fb665..c8cdb0ed40 100644 --- a/twake/backend/node/src/core/platform/services/realtime/services/room-manager.ts +++ b/twake/backend/node/src/core/platform/services/realtime/services/room-manager.ts @@ -79,7 +79,11 @@ export default class RoomManager implements RealtimeRoomManager { try { //Public rooms we just check the user is logged in - if (joinEvent.name.startsWith("/users/online") || joinEvent.name === "/ping") { + if ( + joinEvent.name.startsWith("/users/online") || //User online room + joinEvent.name.startsWith("/users/") || //User update room + joinEvent.name === "/ping" + ) { return !!this.auth.verifyToken(joinEvent.token)?.sub; } diff --git a/twake/backend/node/src/services/channels/entities/channel.ts b/twake/backend/node/src/services/channels/entities/channel.ts index 4924f45030..86c7bbcb23 100644 --- a/twake/backend/node/src/services/channels/entities/channel.ts +++ b/twake/backend/node/src/services/channels/entities/channel.ts @@ -38,6 +38,9 @@ export class Channel { @Column("channel_group", "encoded_string") channel_group: string; + @Column("is_readonly", "boolean") + is_readonly = false; + @Column("visibility", "encoded_string") visibility: ChannelVisibility; diff --git a/twake/backend/node/src/services/channels/services/channel/service.ts b/twake/backend/node/src/services/channels/services/channel/service.ts index 0faefcdb4f..19776178bf 100644 --- a/twake/backend/node/src/services/channels/services/channel/service.ts +++ b/twake/backend/node/src/services/channels/services/channel/service.ts @@ -50,6 +50,8 @@ import { KnowledgeGraphGenericEventPayload, } from "../../../../core/platform/services/knowledge-graph/types"; import { ChannelUserCounterType } from "../../entities/channel-counters"; +import { Readable } from "stream"; +import sharp from "sharp"; const logger = getLogger("channel.service"); @@ -95,6 +97,12 @@ export class ChannelServiceImpl { return this; } + async thumbnail(channelId: string, context?: ExecutionContext): Promise<{ file: Readable }> { + const logoInternalPath = `/channels/${channelId}/thumbnail.jpg`; + const file = await gr.platformServices.storage.read(logoInternalPath, {}, context); + return { file }; + } + @RealtimeSaved((channel, context) => [ { room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)), @@ -141,6 +149,7 @@ export class ChannelServiceImpl { visibility: !isDirectChannel && (isWorkspaceAdmin || isChannelOwner), archived: isWorkspaceAdmin || isChannelOwner, connectors: !isDirectChannel, + is_readonly: isWorkspaceAdmin || isChannelOwner, }; // Diff existing channel and input one, cleanup all the undefined fields for all objects @@ -157,6 +166,26 @@ export class ChannelServiceImpl { throw CrudException.badRequest("Current user can not update requested fields"); } + if (channelToUpdate.icon && channelToUpdate.icon.startsWith("data:")) { + const logoInternalPath = `/channels/${channelToUpdate.id}/thumbnail.jpg`; + const logoPublicPath = `/internal/services/channels/v1/companies/${ + channelToUpdate.company_id + }/workspaces/${channelToUpdate.workspace_id}/channels/${ + channelToUpdate.id + }/thumbnail?t=${new Date().getTime()}`; + + const image = await sharp(Buffer.from(channel.icon.split(",").pop(), "base64")) + .resize(250, 250) + .toBuffer(); + + const s = new Readable(); + s.push(image); + s.push(null); + await gr.platformServices.storage.write(logoInternalPath, s); + + channelToUpdate.icon = logoPublicPath; + } + channelToSave = cloneDeep(channelToUpdate); updatableFields.forEach(field => { @@ -417,6 +446,15 @@ export class ChannelServiceImpl { logger.info(`Update activity for channel ${entity.channel_id} to ${entity.last_activity}`); + localEventBus.publish>( + KnowledgeGraphEvents.CHANNEL_UPSERT, + { + id: channel.id, + resource: channel, + links: [], + }, + ); + await this.activityRepository.save(entity, context); return new UpdateResult("channel_activity", entity); } @@ -832,15 +870,6 @@ export class ChannelServiceImpl { channel, user: context.user, }); - - localEventBus.publish>( - KnowledgeGraphEvents.CHANNEL_CREATED, - { - id: channel.id, - resource: channel, - links: [], - }, - ); } } diff --git a/twake/backend/node/src/services/channels/web/controllers/channel.ts b/twake/backend/node/src/services/channels/web/controllers/channel.ts index 04b65189b3..5299906152 100644 --- a/twake/backend/node/src/services/channels/web/controllers/channel.ts +++ b/twake/backend/node/src/services/channels/web/controllers/channel.ts @@ -216,6 +216,19 @@ export class ChannelCrudController reply.send(channel); } + async thumbnail( + request: FastifyRequest<{ Params: ChannelParameters }>, + response: FastifyReply, + ): Promise { + const data = await gr.services.channels.channels.thumbnail(request.params.id); + const filename = "thumbnail.jpg"; + + response.header("Content-disposition", `inline; filename="${filename}"`); + response.type("image/jpeg"); + + response.send(data.file); + } + async save( request: FastifyRequest<{ Body: CreateChannelBody; diff --git a/twake/backend/node/src/services/channels/web/routes.ts b/twake/backend/node/src/services/channels/web/routes.ts index 7cae0645b7..3e40beb75c 100644 --- a/twake/backend/node/src/services/channels/web/routes.ts +++ b/twake/backend/node/src/services/channels/web/routes.ts @@ -78,6 +78,12 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) handler: channelsController.get.bind(channelsController), }); + fastify.route({ + method: "GET", + url: `${channelsUrl}/:id/thumbnail`, + handler: channelsController.thumbnail.bind(channelsController), + }); + fastify.route({ method: "POST", url: channelsUrl, diff --git a/twake/backend/node/src/services/channels/web/schemas.ts b/twake/backend/node/src/services/channels/web/schemas.ts index d5bbb44599..7fdc896109 100644 --- a/twake/backend/node/src/services/channels/web/schemas.ts +++ b/twake/backend/node/src/services/channels/web/schemas.ts @@ -42,6 +42,7 @@ const channelSchema = { user_member: {}, direct_channel_members: { type: "array" }, stats: {}, + is_readonly: { type: "boolean" }, }, }; diff --git a/twake/backend/node/src/services/console/clients/remote.ts b/twake/backend/node/src/services/console/clients/remote.ts index af40754d2a..48b1a99a51 100644 --- a/twake/backend/node/src/services/console/clients/remote.ts +++ b/twake/backend/node/src/services/console/clients/remote.ts @@ -241,6 +241,7 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { company.plan.limits = { [CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: 10000, // To remove duplicata since we define this in formatCompany function [CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: limits["members"], + [CompanyLimitsEnum.COMPANY_GUESTS_LIMIT]: limits["guests"], }; company.stats = coalesce(companyDTO.stats, company.stats); diff --git a/twake/backend/node/src/services/messages/entities/messages.ts b/twake/backend/node/src/services/messages/entities/messages.ts index 0313f73806..c7f36f138c 100644 --- a/twake/backend/node/src/services/messages/entities/messages.ts +++ b/twake/backend/node/src/services/messages/entities/messages.ts @@ -72,7 +72,15 @@ export class Message { pinned_info: null | MessagePinnedInfo; @Column("quote_message", "encoded_json") - quote_message: null | Partial; + quote_message: + | null + | (Partial & { + id: string; + thread_id: string; + channel_id: string | null; + workspace_id: string | null; + company_id: string | null; + }); @Column("reactions", "encoded_json") reactions: null | MessageReaction[]; diff --git a/twake/backend/node/src/services/messages/services/messages.ts b/twake/backend/node/src/services/messages/services/messages.ts index 9df8456c5e..2880da84d9 100644 --- a/twake/backend/node/src/services/messages/services/messages.ts +++ b/twake/backend/node/src/services/messages/services/messages.ts @@ -194,9 +194,9 @@ export class ThreadMessagesService implements TwakeServiceProvider, Initializabl await this.onSaved(message, { created: messageCreated }, context); - if (messageCreated && context.channel) { + if (context.channel) { localEventBus.publish>( - KnowledgeGraphEvents.MESSAGE_CREATED, + KnowledgeGraphEvents.MESSAGE_UPSERT, { id: message.id, resource: message, @@ -551,10 +551,10 @@ export class ThreadMessagesService implements TwakeServiceProvider, Initializabl const messageWithUsers: MessageWithUsers = { ...message, users, application }; if (message.quote_message && (message.quote_message as any).id) { - messageWithUsers.quote_message = await this.includeUsersInMessage( - message.quote_message as any, - context, - ); + messageWithUsers.quote_message = { + ...(await this.includeUsersInMessage(message.quote_message as any, context)), + ...quoteMessageKeys(message), + }; } return messageWithUsers; @@ -716,15 +716,18 @@ export class ThreadMessagesService implements TwakeServiceProvider, Initializabl async includeQuoteInMessage(message: MessageWithUsers): Promise { if (message.quote_message && (message.quote_message as Message["quote_message"]).id) { - message.quote_message = await this.includeUsersInMessage( - await this.getSingleMessage( - { - thread_id: (message.quote_message as Message["quote_message"]).thread_id, - id: (message.quote_message as Message["quote_message"]).id, - }, - { includeQuoteInMessage: false }, - ), - ); + message.quote_message = { + ...(await this.includeUsersInMessage( + await this.getSingleMessage( + { + thread_id: (message.quote_message as Message["quote_message"]).thread_id, + id: (message.quote_message as Message["quote_message"]).id, + }, + { includeQuoteInMessage: false }, + ), + )), + ...quoteMessageKeys(message), + }; } return message; } @@ -1037,3 +1040,22 @@ export class ThreadMessagesService implements TwakeServiceProvider, Initializabl } } } + +const quoteMessageKeys = ( + message: Message, +): { channel_id: string; workspace_id: string; company_id: string } => { + return { + channel_id: + message.quote_message?.channel_id || + message.quote_message?.cache?.channel_id || + message.cache.channel_id, + workspace_id: + message.quote_message?.workspace_id || + message.quote_message?.cache?.workspace_id || + message.cache.workspace_id, + company_id: + message.quote_message?.company_id || + message.quote_message?.cache?.company_id || + message.cache.company_id, + }; +}; diff --git a/twake/backend/node/src/services/online/service/index.ts b/twake/backend/node/src/services/online/service/index.ts index 6c71bfe554..98e6a360a0 100644 --- a/twake/backend/node/src/services/online/service/index.ts +++ b/twake/backend/node/src/services/online/service/index.ts @@ -108,6 +108,11 @@ export default class OnlineServiceImpl implements TwakeServiceProvider { getInstance({ user_id, last_seen, is_connected }), ); await this.onlineRepository.saveAll(onlineUsers); + + //Send websocket event + onlineUsers.forEach(u => { + gr.services.users.publishPublicUserRealtime(u.user_id); + }); } async isOnline(userId: string, context?: ExecutionContext): Promise { diff --git a/twake/backend/node/src/services/user/realtime.ts b/twake/backend/node/src/services/user/realtime.ts index 228cd9d276..f7f131a348 100644 --- a/twake/backend/node/src/services/user/realtime.ts +++ b/twake/backend/node/src/services/user/realtime.ts @@ -17,6 +17,10 @@ export function getUserRoom(userId: string): string { return `/me/${userId}`; } +export function getPublicUserRoom(userId: string): string { + return `/users/${userId}`; +} + export function getUserName(userId: string): string { return `user-room-${userId}`; } diff --git a/twake/backend/node/src/services/user/services/companies.ts b/twake/backend/node/src/services/user/services/companies.ts index 7ed2bae0f5..ae3fd3ca14 100644 --- a/twake/backend/node/src/services/user/services/companies.ts +++ b/twake/backend/node/src/services/user/services/companies.ts @@ -100,6 +100,15 @@ export class CompanyServiceImpl { await this.externalCompanyRepository.save(extCompany, context); } + localEventBus.publish>( + KnowledgeGraphEvents.COMPANY_UPSERT, + { + id: company.id, + resource: company, + links: [], + }, + ); + return new SaveResult("company", company, OperationType.UPDATE); } @@ -113,14 +122,6 @@ export class CompanyServiceImpl { const result = await this.updateCompany(companyToCreate); - localEventBus.publish>( - KnowledgeGraphEvents.COMPANY_CREATED, - { - id: result.entity.id, - resource: result.entity, - links: [{ relation: "owner", type: "user", id: result.context?.user.id }], - }, - ); return result.entity; } diff --git a/twake/backend/node/src/services/user/services/users/service.ts b/twake/backend/node/src/services/user/services/users/service.ts index b80ccbff65..cd8c60412a 100644 --- a/twake/backend/node/src/services/user/services/users/service.ts +++ b/twake/backend/node/src/services/user/services/users/service.ts @@ -29,13 +29,14 @@ import { localEventBus } from "../../../../core/platform/framework/event-bus"; import { ResourceEventsPayload } from "../../../../utils/types"; import { isNumber, isString } from "lodash"; import { RealtimeSaved } from "../../../../core/platform/framework"; -import { getUserRoom } from "../../realtime"; +import { getPublicUserRoom, getUserRoom } from "../../realtime"; import NodeCache from "node-cache"; import gr from "../../../global-resolver"; import { KnowledgeGraphEvents, KnowledgeGraphGenericEventPayload, } from "../../../../core/platform/services/knowledge-graph/types"; +import { formatUser } from "../../../../utils/users"; export class UserServiceImpl { version: "1"; @@ -91,41 +92,32 @@ export class UserServiceImpl { } async create(user: User, context?: ExecutionContext): Promise> { - this.assignDefaults(user); - - await this.repository.save(user, context); - await this.updateExtRepository(user); - const result = new CreateResult("user", user); - - if (result) { - localEventBus.publish>( - KnowledgeGraphEvents.USER_CREATED, - { - id: result.entity.id, - resource: result.entity, - links: [ - { - // FIXME: We should provide the company id here - id: "", - relation: "parent", - type: "company", - }, - ], - }, - ); - } - return result; + await this.save(user, context); + return new CreateResult("user", user); } update(pk: Partial, item: User, context?: ExecutionContext): Promise> { throw new Error("Method not implemented."); } + @RealtimeSaved((user, _context) => { + return [ + { + room: getPublicUserRoom(user.id), + resource: user, + }, + ]; + }) + async publishPublicUserRealtime(userId: string): Promise { + const user = await this.get({ id: userId }); + new SaveResult("user", formatUser(user, { includeCompanies: true }), OperationType.UPDATE); + } + @RealtimeSaved((user, _context) => { return [ { room: getUserRoom(user.id), - resource: {}, // FIX ME we should formatUser here + resource: formatUser(user), // FIX ME we should formatUser here }, ]; }) @@ -133,6 +125,25 @@ export class UserServiceImpl { this.assignDefaults(user); await this.repository.save(user, context); await this.updateExtRepository(user); + + localEventBus.publish>( + KnowledgeGraphEvents.USER_UPSERT, + { + id: user.id, + resource: user, + links: [ + { + // FIXME: We should provide the company id here + id: "", + relation: "parent", + type: "company", + }, + ], + }, + ); + + await this.publishPublicUserRealtime(user.id); + return new SaveResult("user", user, OperationType.UPDATE); } diff --git a/twake/backend/node/src/services/user/utils.ts b/twake/backend/node/src/services/user/utils.ts index dfa73a87d1..a66ce095e2 100644 --- a/twake/backend/node/src/services/user/utils.ts +++ b/twake/backend/node/src/services/user/utils.ts @@ -40,6 +40,7 @@ export function formatCompany( { [CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: 10000, [CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: -1, + [CompanyLimitsEnum.COMPANY_GUESTS_LIMIT]: -1, }, res.plan?.limits || {}, ); diff --git a/twake/backend/node/src/services/user/web/controller.ts b/twake/backend/node/src/services/user/web/controller.ts index eac193467c..813659829f 100644 --- a/twake/backend/node/src/services/user/web/controller.ts +++ b/twake/backend/node/src/services/user/web/controller.ts @@ -31,7 +31,7 @@ import { import Company from "../entities/company"; import CompanyUser from "../entities/company_user"; import coalesce from "../../../utils/coalesce"; -import { getCompanyRooms, getUserRooms } from "../realtime"; +import { getCompanyRooms, getPublicUserRoom, getUserRooms } from "../realtime"; import { formatCompany, getCompanyStats } from "../utils"; import { formatUser } from "../../../utils/users"; import gr from "../../global-resolver"; @@ -143,7 +143,7 @@ export class UsersCrudController // return users; return { resources: resUsers, - websockets: gr.platformServices.realtime.sign([], context.user.id), // empty for now + websockets: gr.platformServices.realtime.sign([], context.user.id), }; } diff --git a/twake/backend/node/src/services/user/web/schemas.ts b/twake/backend/node/src/services/user/web/schemas.ts index 6f33289a79..f97c5c8111 100644 --- a/twake/backend/node/src/services/user/web/schemas.ts +++ b/twake/backend/node/src/services/user/web/schemas.ts @@ -80,6 +80,7 @@ export const companyObjectSchema = { properties: { [CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: { type: "number" }, [CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: { type: "number" }, + [CompanyLimitsEnum.COMPANY_GUESTS_LIMIT]: { type: "number" }, }, }, features: { diff --git a/twake/backend/node/src/services/user/web/types.ts b/twake/backend/node/src/services/user/web/types.ts index 6c23ea05a2..d3de93ea88 100644 --- a/twake/backend/node/src/services/user/web/types.ts +++ b/twake/backend/node/src/services/user/web/types.ts @@ -68,6 +68,7 @@ export interface UserObject { export enum CompanyLimitsEnum { CHAT_MESSAGE_HISTORY_LIMIT = "chat:message_history_limit", COMPANY_MEMBERS_LIMIT = "company:members_limit", // 100 + COMPANY_GUESTS_LIMIT = "company:guests_limit", } export enum CompanyFeaturesEnum { @@ -91,6 +92,7 @@ export type CompanyFeaturesObject = { export type CompanyLimitsObject = { [CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]?: number; [CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]?: number; + [CompanyLimitsEnum.COMPANY_GUESTS_LIMIT]?: number; }; export interface CompanyPlanObject { diff --git a/twake/backend/node/src/services/workspaces/entities/workspace.ts b/twake/backend/node/src/services/workspaces/entities/workspace.ts index 733dc15945..42836e52dd 100644 --- a/twake/backend/node/src/services/workspaces/entities/workspace.ts +++ b/twake/backend/node/src/services/workspaces/entities/workspace.ts @@ -34,6 +34,11 @@ export default class Workspace { @Column("date_added", "number") dateAdded: number; + + @Column("preferences", "encoded_json") + preferences: null | { + invite_domain?: string; + }; } export type WorkspacePrimaryKey = Pick; diff --git a/twake/backend/node/src/services/workspaces/entities/workspace_invite_domain.ts b/twake/backend/node/src/services/workspaces/entities/workspace_invite_domain.ts new file mode 100644 index 0000000000..03aee6d17d --- /dev/null +++ b/twake/backend/node/src/services/workspaces/entities/workspace_invite_domain.ts @@ -0,0 +1,30 @@ +import { merge } from "lodash"; +import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; + +export const TYPE = "workspace_invite_domain"; + +@Entity(TYPE, { + primaryKey: [["domain"], "company_id", "workspace_id"], + type: TYPE, +}) +export default class WorkspaceInviteDomain { + @Column("company_id", "timeuuid") + company_id: string; + + @Column("workspace_id", "timeuuid") + workspace_id: string; + + @Column("domain", "encoded_string") + domain: string; +} + +export type WorkspaceInviteDomainPrimaryKey = Pick< + WorkspaceInviteDomain, + "domain" | "company_id" | "workspace_id" +>; + +export function getInstance( + workspaceInviteDomain: Partial & Partial, +): WorkspaceInviteDomain { + return merge(new WorkspaceInviteDomain(), workspaceInviteDomain); +} diff --git a/twake/backend/node/src/services/workspaces/entities/workspace_invite_tokens.ts b/twake/backend/node/src/services/workspaces/entities/workspace_invite_tokens.ts index a7565d24d3..23859e2260 100644 --- a/twake/backend/node/src/services/workspaces/entities/workspace_invite_tokens.ts +++ b/twake/backend/node/src/services/workspaces/entities/workspace_invite_tokens.ts @@ -19,6 +19,9 @@ export default class WorkspaceInviteTokens { @Column("invite_token", "string") invite_token: string; + + @Column("channels", "encoded_json") + channels: null | string[]; } export type WorkspaceInviteTokensPrimaryKey = Pick< diff --git a/twake/backend/node/src/services/workspaces/services/workspace.ts b/twake/backend/node/src/services/workspaces/services/workspace.ts index 4cd54ff415..dfb192864c 100644 --- a/twake/backend/node/src/services/workspaces/services/workspace.ts +++ b/twake/backend/node/src/services/workspaces/services/workspace.ts @@ -65,6 +65,10 @@ import { KnowledgeGraphEvents, KnowledgeGraphGenericEventPayload, } from "../../../core/platform/services/knowledge-graph/types"; +import WorkspaceInviteDomain, { + TYPE as WorkspaceInviteDomainType, + getInstance as getWorkspaceInviteDomainInstance, +} from "../entities/workspace_invite_domain"; export class WorkspaceServiceImpl implements TwakeServiceProvider, Initializable { version: "1"; @@ -73,6 +77,7 @@ export class WorkspaceServiceImpl implements TwakeServiceProvider, Initializable private workspacePendingUserRepository: Repository; private workspaceCounter: CounterProvider; private workspaceInviteTokensRepository: Repository; + private workspaceInviteDomainRepository: Repository; async init(): Promise { this.workspaceUserRepository = await gr.database.getRepository( @@ -108,6 +113,11 @@ export class WorkspaceServiceImpl implements TwakeServiceProvider, Initializable WorkspaceInviteTokens, ); + this.workspaceInviteDomainRepository = await gr.database.getRepository( + WorkspaceInviteDomainType, + WorkspaceInviteDomain, + ); + //If user deleted from a company, remove it from all workspace localEventBus.subscribe("company:user:deleted", async data => { if (data?.user?.id && data?.company?.id) @@ -236,17 +246,17 @@ export class WorkspaceServiceImpl implements TwakeServiceProvider, Initializable workspace_id: workspace.id, }, }); - - localEventBus.publish>( - KnowledgeGraphEvents.WORKSPACE_CREATED, - { - id: workspace.id, - resource: workspace, - links: [{ relation: "parent", type: "company", id: workspace.company_id }], - }, - ); } + localEventBus.publish>( + KnowledgeGraphEvents.WORKSPACE_UPSERT, + { + id: workspace.id, + resource: workspace, + links: [{ relation: "parent", type: "company", id: workspace.company_id }], + }, + ); + return new SaveResult( TYPE, workspace, @@ -651,13 +661,14 @@ export class WorkspaceServiceImpl implements TwakeServiceProvider, Initializable companyId: string, workspaceId: string, userId: string, + channels: string[], context?: ExecutionContext, ): Promise { await this.deleteInviteToken(companyId, workspaceId, userId); const token = randomBytes(32).toString("base64"); const pk = { company_id: companyId, workspace_id: workspaceId, user_id: userId }; await this.workspaceInviteTokensRepository.save( - getWorkspaceInviteTokensInstance({ ...pk, invite_token: token }), + getWorkspaceInviteTokensInstance({ ...pk, invite_token: token, channels }), context, ); return { @@ -773,4 +784,91 @@ export class WorkspaceServiceImpl implements TwakeServiceProvider, Initializable } } } + + /** + * Sets the invitation domain for the specified workspace + * + * @param {{ company_id: string; workspace_id: string }} pk - the primary key + * @param {String} domain - domain + * @param {ExecutionContext} context - the execution context + */ + setInviteDomain = async ( + pk: { company_id: string; workspace_id: string }, + domain: string, + context?: ExecutionContext, + ): Promise => { + const workspace = await gr.services.workspaces.get( + { + company_id: pk.company_id, + id: pk.workspace_id, + }, + context, + ); + + if (!workspace) { + logger.error("failed to set invitation domain: workspace not found"); + throw CrudException.notFound("Workspace entity not found"); + } + + if (workspace.preferences && workspace.preferences?.invite_domain !== domain) { + await this.workspaceInviteDomainRepository.remove({ + company_id: workspace.company_id, + domain: workspace.preferences.invite_domain, + workspace_id: workspace.id, + }); + } + + if (workspace.preferences && workspace.preferences?.invite_domain === domain) { + logger.warn("invite domain is already set"); + return; + } + + workspace.preferences = { + invite_domain: domain, + }; + + await this.workspaceRepository.save(workspace); + + const workspaceInvitationDomainEntry = await this.workspaceInviteDomainRepository.findOne( + { + ...pk, + domain, + }, + {}, + context, + ); + + if (!workspaceInvitationDomainEntry) { + await this.workspaceInviteDomainRepository.save( + getWorkspaceInviteDomainInstance({ + ...pk, + domain, + }), + ); + } + }; + + /** + * Get the workspaceinvitedomain entries for the specified domain + * + * @param {String} domain - the desired domain + * @param {ExecutionContext} context - the execution context + * @returns {Promise} + */ + getInviteDomainWorkspaces = async ( + domain: string, + context?: ExecutionContext, + ): Promise => { + const inviteDomainEntry = await this.workspaceInviteDomainRepository.find( + { domain }, + {}, + context, + ); + + if (!inviteDomainEntry) { + throw CrudException.notFound("workspace invite domain not found"); + } + + return inviteDomainEntry.getEntities(); + }; } diff --git a/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts b/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts index ef4653f79e..ade52f34df 100644 --- a/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts +++ b/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts @@ -6,6 +6,7 @@ import { ResourceListResponse, } from "../../../../utils/types"; import { + WorkspaceInviteTokenBody, WorkspaceInviteTokenDeleteRequest, WorkspaceInviteTokenGetRequest, WorkspaceInviteTokenObject, @@ -52,7 +53,10 @@ export class WorkspaceInviteTokensCrudController } async save( - request: FastifyRequest<{ Params: WorkspaceInviteTokenGetRequest }>, + request: FastifyRequest<{ + Params: WorkspaceInviteTokenGetRequest; + Body: WorkspaceInviteTokenBody; + }>, reply: FastifyReply, ): Promise> { const context = getExecutionContext(request); @@ -61,6 +65,7 @@ export class WorkspaceInviteTokensCrudController context.company_id, context.workspace_id, context.user.id, + request.body.channels, ); return { @@ -177,6 +182,47 @@ export class WorkspaceInviteTokensCrudController throw CrudException.badRequest("Unable to add user to the company"); } + const userEmailDomain = user.email_canonical.split("@").pop(); + const inviteDomainEntries = await gr.services.workspaces.getInviteDomainWorkspaces( + userEmailDomain, + ); + + inviteDomainEntries.map(async entry => { + const workspace = await gr.services.workspaces.get({ + company_id: entry.company_id, + id: entry.workspace_id, + }); + + if (!workspace) { + return; + } + + if ( + !workspace.preferences.invite_domain || + workspace.preferences.invite_domain !== userEmailDomain + ) { + return; + } + + const existingUser = await gr.services.workspaces.getUser({ + userId, + workspaceId: entry.workspace_id, + }); + + if (existingUser) { + return; + } + + await gr.services.workspaces.addUser( + { + company_id: entry.company_id, + id: entry.workspace_id, + }, + { id: userId }, + "member", + ); + }); + const workspaceUser = await gr.services.workspaces.getUser({ workspaceId: workspace.id, userId: userId, @@ -188,6 +234,24 @@ export class WorkspaceInviteTokensCrudController "member", ); } + + await gr.services.channelPendingEmail.proccessPendingEmails( + { + company_id, + user_id: userId, + workspace_id, + }, + { + company_id, + workspace_id, + }, + { + user: request.currentUser, + url: request.url, + method: request.routerMethod, + transport: "http", + }, + ); resource.company.id = company.id; resource.workspace.id = workspace.id; } diff --git a/twake/backend/node/src/services/workspaces/web/controllers/workspace-users.ts b/twake/backend/node/src/services/workspaces/web/controllers/workspace-users.ts index 3d5a7f84dc..38ebbc24d6 100644 --- a/twake/backend/node/src/services/workspaces/web/controllers/workspace-users.ts +++ b/twake/backend/node/src/services/workspaces/web/controllers/workspace-users.ts @@ -35,6 +35,7 @@ import WorkspacePendingUser from "../../entities/workspace_pending_users"; import { ConsoleCompany, CreateConsoleUser } from "../../../console/types"; import { hasCompanyAdminLevel } from "../../../../utils/company"; import gr from "../../../global-resolver"; +import { getChannelPendingEmailsInstance } from "../../../channels/entities"; export class WorkspaceUsersCrudController implements @@ -291,6 +292,53 @@ export class WorkspaceUsersCrudController role, ); } + + const user = await gr.services.users.get({ + id: userId, + }); + + if (user) { + const userEmailDomain = user.email_canonical.split("@").pop(); + const inviteDomainEntries = await gr.services.workspaces.getInviteDomainWorkspaces( + userEmailDomain, + ); + + inviteDomainEntries.map(async entry => { + const workspace = await gr.services.workspaces.get({ + company_id: entry.company_id, + id: entry.workspace_id, + }); + + if (!workspace) { + return; + } + + if ( + !workspace.preferences.invite_domain || + workspace.preferences.invite_domain !== userEmailDomain + ) { + return; + } + + const existingUser = await gr.services.workspaces.getUser({ + userId, + workspaceId: entry.workspace_id, + }); + + if (existingUser) { + return; + } + + await gr.services.workspaces.addUser( + { + company_id: entry.company_id, + id: entry.workspace_id, + }, + { id: userId }, + "member", + ); + }); + } } const resource = await this.getForOne(userId, context); @@ -365,6 +413,16 @@ export class WorkspaceUsersCrudController invitation.role, invitation.company_role, ); + (request.body.channels || []).forEach(async channelId => { + const channelPendingEmail = getChannelPendingEmailsInstance({ + channel_id: channelId, + company_id: request.params.company_id, + email: invitation.email, + workspace_id: context.workspace_id, + }); + + await gr.services.channelPendingEmail.create(channelPendingEmail, context); + }); responses.push({ email: invitation.email, status: "ok" }); }; @@ -456,7 +514,21 @@ export class WorkspaceUsersCrudController } await Promise.all( - usersToProcessImmediately.map(user => gr.services.console.processPendingUser(user)), + usersToProcessImmediately.map(user => { + gr.services.console.processPendingUser(user); + gr.services.channelPendingEmail.proccessPendingEmails( + { + company_id: context.company_id, + user_id: user.id, + workspace_id: context.workspace_id, + }, + { + workspace_id: context.workspace_id, + company_id: context.company_id, + }, + context, + ); + }), ); return { diff --git a/twake/backend/node/src/services/workspaces/web/controllers/workspaces.ts b/twake/backend/node/src/services/workspaces/web/controllers/workspaces.ts index 7b049f28d5..2684da8581 100644 --- a/twake/backend/node/src/services/workspaces/web/controllers/workspaces.ts +++ b/twake/backend/node/src/services/workspaces/web/controllers/workspaces.ts @@ -8,6 +8,7 @@ import { import { UpdateWorkspaceBody, WorkspaceBaseRequest, + WorkspaceInviteDomainBody, WorkspaceObject, WorkspaceRequest, WorkspacesListRequest, @@ -71,6 +72,8 @@ export class WorkspacesCrudController created_at: workspace.dateAdded, total_members: usersCount, }, + + preferences: workspace.preferences, }; if (userId) { @@ -231,15 +234,7 @@ export class WorkspacesCrudController ): Promise { const context = getExecutionContext(request); - const workspaceUserRole = await this.getWorkspaceUserRole(request.params.id, context.user.id); - const companyUserRole = await this.getCompanyUserRole(context.company_id, context.user.id); - - if (!hasWorkspaceAdminLevel(workspaceUserRole, companyUserRole)) { - const companyUserRole = await this.getCompanyUserRole(context.company_id, context.user.id); - if (companyUserRole !== "admin") { - throw CrudException.forbidden("You are not a admin of workspace or company"); - } - } + await this.checkWorkspaceModeratorAccess(request, context); const deleteResult = await gr.services.workspaces.delete({ id: request.params.id, @@ -259,6 +254,58 @@ export class WorkspacesCrudController }; } + /** + * Sets the invitation domain for the workspace. + * + * @param {FastifyRequest<{ Params: WorkspaceRequest; Body: WorkspaceInviteDomainBody }>} request - the request + * @param {FastifyReply} _reply - the reply + * @returns {Promise<{ status: string }>} + */ + setInviteDomain = async ( + request: FastifyRequest<{ Params: WorkspaceRequest; Body: WorkspaceInviteDomainBody }>, + _reply: FastifyReply, + ): Promise<{ status: string }> => { + const context = getExecutionContext(request); + + await this.checkWorkspaceModeratorAccess(request, context); + + try { + const { company_id, id } = request.params; + await gr.services.workspaces.setInviteDomain( + { company_id, workspace_id: id }, + request.body.domain, + context, + ); + } catch (error) { + throw CrudException.badRequest("Failed to set invitation domain"); + } + + return { + status: "success", + }; + }; + + /** + * Checks whether the current user have moderator access level to the workspace. + * + * @param {FastifyRequest<{ Params: WorkspaceRequest }>} request - the request + * @param {WorkspaceExecutionContext} context - the workspace execution context + */ + checkWorkspaceModeratorAccess = async ( + request: FastifyRequest<{ Params: WorkspaceRequest }>, + context: WorkspaceExecutionContext, + ): Promise => { + const workspaceUserRole = await this.getWorkspaceUserRole(request.params.id, context.user.id); + const companyUserRole = await this.getCompanyUserRole(context.company_id, context.user.id); + + if (!hasWorkspaceAdminLevel(workspaceUserRole, companyUserRole)) { + const companyUserRole = await this.getCompanyUserRole(context.company_id, context.user.id); + if (companyUserRole !== "admin") { + throw CrudException.forbidden("You are not a admin of workspace or company"); + } + } + }; + constructor() { this.logger = getLogger("Workspaces controller"); } diff --git a/twake/backend/node/src/services/workspaces/web/routes.ts b/twake/backend/node/src/services/workspaces/web/routes.ts index 116d7d0303..9a3064552f 100644 --- a/twake/backend/node/src/services/workspaces/web/routes.ts +++ b/twake/backend/node/src/services/workspaces/web/routes.ts @@ -18,13 +18,20 @@ import { updateWorkspaceSchema, updateWorkspaceUserSchema, } from "./schemas"; -import { WorkspaceBaseRequest, WorkspaceUsersBaseRequest, WorkspaceUsersRequest } from "./types"; +import { + WorkspaceBaseRequest, + WorkspaceInviteDomainBody, + WorkspaceRequest, + WorkspaceUsersBaseRequest, + WorkspaceUsersRequest, +} from "./types"; import { WorkspaceUsersCrudController } from "./controllers/workspace-users"; import { hasWorkspaceAdminLevel, hasWorkspaceMemberLevel } from "../../../utils/workspace"; import { WorkspaceInviteTokensCrudController } from "./controllers/workspace-invite-tokens"; import WorkspaceUser from "../entities/workspace_user"; import { checkUserBelongsToCompany, hasCompanyMemberLevel } from "../../../utils/company"; import gr from "../../global-resolver"; +import freeEmailDomains from "free-email-domains"; const workspacesUrl = "/companies/:company_id/workspaces"; const workspacePendingUsersUrl = "/companies/:company_id/workspaces/:workspace_id/pending"; @@ -132,6 +139,24 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) } }; + const validateDomain = async ( + request: FastifyRequest<{ Params: WorkspaceRequest; Body: WorkspaceInviteDomainBody }>, + ) => { + const { domain } = request.body; + + if ( + /^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/.test( + domain, + ) === false + ) { + throw fastify.httpErrors.badRequest("invalid domain"); + } + + if (freeEmailDomains.includes(domain)) { + throw fastify.httpErrors.badRequest("invalid email provider"); + } + }; + fastify.route({ method: "GET", url: `${workspacesUrl}`, @@ -182,6 +207,14 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) handler: workspacesController.delete.bind(workspacesController), }); + fastify.route({ + method: "POST", + url: `${workspacesUrl}/:id/invite_domain`, + preHandler: [validateDomain, companyCheck], + preValidation: [fastify.authenticate], + handler: workspacesController.setInviteDomain.bind(workspacesController), + }); + fastify.route({ method: "GET", url: `${workspaceUsersUrl}`, diff --git a/twake/backend/node/src/services/workspaces/web/schemas.ts b/twake/backend/node/src/services/workspaces/web/schemas.ts index 4aa6320590..2e2d954343 100644 --- a/twake/backend/node/src/services/workspaces/web/schemas.ts +++ b/twake/backend/node/src/services/workspaces/web/schemas.ts @@ -18,6 +18,14 @@ const workspaceObjectSchema = { }, }, role: { type: "string", enum: ["moderator", "member"] }, + preferences: { + type: ["object", "null"], + properties: { + invite_domain: { + type: ["string", "null"], + }, + }, + }, }, }; @@ -241,6 +249,12 @@ export const inviteWorkspaceUserSchema = { required: ["email", "role", "company_role"], }, }, + channels: { + type: "array", + items: { + type: "string", + }, + }, }, required: ["invitations"], }, @@ -295,6 +309,18 @@ export const getWorkspaceInviteTokenSchema = { }; export const postWorkspaceInviteTokenSchema = { + body: { + type: "object", + properties: { + channels: { + type: "array", + items: { + type: "string", + }, + }, + }, + required: ["channels"], + }, response: { "2xx": { type: "object", diff --git a/twake/backend/node/src/services/workspaces/web/types.ts b/twake/backend/node/src/services/workspaces/web/types.ts index bdde7a2239..8f41349664 100644 --- a/twake/backend/node/src/services/workspaces/web/types.ts +++ b/twake/backend/node/src/services/workspaces/web/types.ts @@ -7,7 +7,6 @@ import { UserObject, } from "../../user/web/types"; import Company from "../../user/entities/company"; - export interface WorkspaceRequest extends WorkspaceBaseRequest { id: uuid; } @@ -46,6 +45,7 @@ export interface WorkspaceUsersInvitationItem { export interface WorkspaceUsersInvitationRequestBody { invitations: WorkspaceUsersInvitationItem[]; + channels?: string[]; } export interface WorkspaceUserInvitationResponseItem { @@ -89,6 +89,10 @@ export interface WorkspaceObject { }; role?: WorkspaceUserRole; + + preferences: null | { + invite_domain?: string; + }; } export interface WorkspaceUserObject { @@ -105,6 +109,10 @@ export interface WorkspaceInviteTokenGetRequest extends WorkspaceBaseRequest { workspace_id: uuid; } +export interface WorkspaceInviteTokenBody { + channels: string[]; +} + export interface WorkspaceInviteTokenDeleteRequest extends WorkspaceBaseRequest { workspace_id: uuid; token: string; @@ -139,3 +147,7 @@ export interface WorkspaceJoinByTokenResponse { }; auth_required: boolean; } + +export interface WorkspaceInviteDomainBody { + domain: string; +} diff --git a/twake/backend/node/src/utils/messages.ts b/twake/backend/node/src/utils/messages.ts index 8aa308061a..942a3eea38 100644 --- a/twake/backend/node/src/utils/messages.ts +++ b/twake/backend/node/src/utils/messages.ts @@ -77,7 +77,14 @@ export function getDefaultMessageInstance(item: Partial, context: Threa pinned_by: context.user.id, } : null, - quote_message: item.quote_message || null, + quote_message: item.quote_message + ? { + channel_id: context.channel.id, + workspace_id: context.workspace.id, + company_id: context.company.id, + ...item.quote_message, + } + : null, reactions: (context?.user?.server_request ? item.reactions : null) || null, // Reactions cannot be set on creation bookmarks: (context?.user?.server_request ? item.bookmarks : null) || null, override: diff --git a/twake/backend/node/test/e2e/workspaces/workspaces.invite-tokens.spec.ts b/twake/backend/node/test/e2e/workspaces/workspaces.invite-tokens.spec.ts index 3f3f25e81a..4b98333447 100644 --- a/twake/backend/node/test/e2e/workspaces/workspaces.invite-tokens.spec.ts +++ b/twake/backend/node/test/e2e/workspaces/workspaces.invite-tokens.spec.ts @@ -173,7 +173,9 @@ describe("The /workspaces API (invite tokens)", () => { method: "POST", url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/tokens`, headers: { authorization: `Bearer ${jwtToken}` }, - payload: {}, + payload: { + channels: ["test"], + }, }); expect(response.statusCode).toBe(403); @@ -184,6 +186,7 @@ describe("The /workspaces API (invite tokens)", () => { const workspaceId = testDbService.workspaces[0].workspace.id; const userId = testDbService.workspaces[0].users[0].id; const companyId = testDbService.company.id; + const channel = await testDbService.createChannel(userId); const jwtToken = await platform.auth.getJWTToken({ sub: userId }); @@ -199,6 +202,9 @@ describe("The /workspaces API (invite tokens)", () => { method: "POST", url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/tokens`, headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + channels: [channel.id], + }, }); expect(response.statusCode).toBe(200); @@ -238,6 +244,7 @@ describe("The /workspaces API (invite tokens)", () => { const workspaceId = testDbService.workspaces[0].workspace.id; const userId = testDbService.workspaces[0].users[0].id; const companyId = testDbService.company.id; + const channel = await testDbService.createChannel(userId); const jwtToken = await platform.auth.getJWTToken({ sub: userId }); @@ -261,6 +268,9 @@ describe("The /workspaces API (invite tokens)", () => { method: "POST", url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/tokens`, headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + channels: [channel.id], + }, }); expect(response.statusCode).toBe(200); diff --git a/twake/frontend/package-lock.json b/twake/frontend/package-lock.json index ce31ddc7b1..1518a74798 100644 --- a/twake/frontend/package-lock.json +++ b/twake/frontend/package-lock.json @@ -13260,6 +13260,15 @@ "@types/react": "^16" } }, + "@types/react-page-visibility": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@types/react-page-visibility/-/react-page-visibility-6.4.1.tgz", + "integrity": "sha512-vNlYAqKhB2SU1HmF9ARFTFZN0NSPzWn8HSjBpFqYuQlJhsb/aSYeIZdygeqfSjAg0PZ70id2IFWHGULJwe59Aw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-redux": { "version": "7.1.24", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz", @@ -30777,6 +30786,15 @@ "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.7.tgz", "integrity": "sha512-VI3TyyHlGkO8uFle0IOibzpO1c1iJDcXcS/zBrQrXQQvJ2tpdwVzVZ7XdKsyRz1NdRmre4dqQkMZzUHaKIG/1w==" }, + "material-ui-chip-input": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/material-ui-chip-input/-/material-ui-chip-input-2.0.0-beta.2.tgz", + "integrity": "sha512-E+Jxv9FdAvYUsZIPohoqxqdW7LwFYOCunInJGh2WSDJ2IlCUdpCOEVazqgLQH6thaFarKDu44uGYpD1v3RdqDg==", + "requires": { + "clsx": "^1.0.4", + "prop-types": "^15.6.1" + } + }, "math-random": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", @@ -32524,6 +32542,11 @@ "source-map-js": "^1.0.2" } }, + "postcss-100vh-fix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/postcss-100vh-fix/-/postcss-100vh-fix-1.0.2.tgz", + "integrity": "sha512-t7vqk9AfjI4fXmWlQCEiMZFFhi1hro4WlECINI1TV6Qh1XapUJE++gCmNr95F5Hen/+bz1OmO+SiSB9TZmFmSg==" + }, "postcss-attribute-case-insensitive": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-4.0.2.tgz", @@ -36336,6 +36359,14 @@ "prop-types": "^15.7.2" } }, + "react-page-visibility": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-page-visibility/-/react-page-visibility-7.0.0.tgz", + "integrity": "sha512-d4Kq/8TtJSr8dQc8EJeAZcSKTrGzC5OPTm6UrMur9BnwP0fgTawI9+Nd+ZGB7vwCfn2yZS0qDF9DR3/QYTGazw==", + "requires": { + "prop-types": "^15.7.2" + } + }, "react-perfect-scrollbar": { "version": "1.5.8", "resolved": "https://registry.npmjs.org/react-perfect-scrollbar/-/react-perfect-scrollbar-1.5.8.tgz", diff --git a/twake/frontend/package.json b/twake/frontend/package.json index 0956e1e60e..f971339a4c 100644 --- a/twake/frontend/package.json +++ b/twake/frontend/package.json @@ -183,6 +183,7 @@ "loglevel-plugin-prefix": "^0.8.4", "markdown-draft-js": "^2.3.0", "markdown-to-jsx": "^7.1.5", + "material-ui-chip-input": "^2.0.0-beta.2", "mini-css-extract-plugin": "0.12.0", "minimongo": "^6.10.0", "moment": "^2.29.4", diff --git a/twake/frontend/public/locales/en.json b/twake/frontend/public/locales/en.json index a1d80d4099..5faca0345f 100644 --- a/twake/frontend/public/locales/en.json +++ b/twake/frontend/public/locales/en.json @@ -104,7 +104,7 @@ "scenes.apps.messages.left_bar.stream.notifications.me": "{{$1}} only", "scenes.apps.messages.left_bar.stream.notifications.never": "Nothing", "scenes.apps.parameters.workspace_sections.workspace": "Workspace", - "scenes.apps.parameters.workspace_sections.members": "Channel participants", + "scenes.apps.parameters.workspace_sections.members": "Members", "scenes.apps.parameters.group_sections.workspaces": "Workspaces", "scenes.apps.parameters.group_sections.apps": "Apps", "scenes.apps.account.title": "Account parameters", @@ -297,12 +297,32 @@ "scenes.app.channelsbar.channelsworkspace.channel_title.favorite": "FAVORITES", "scenes.app.channelsbar.channelsworkspace.create_channel": "Create a channel", "scenes.app.channelsbar.channelsworkspace.no_channel": "You did not join any channel yet", - "scenes.app.channelsbar.modify_channel_menu": "Edit channel", + "scenes.app.channelsbar.modify_channel_menu": "Channel settings", "scenes.app.channelsbar.guest_management": "Guest management", "scenes.app.channelsbar.read_sign": "Mark as read", "scenes.app.channelsbar.unread_sign": "Mark as unread", "scenes.app.channelsbar.channel_copy_link": "Copy channel link", "scenes.app.channelsbar.channel_leaving": "Leave channel", + "scenes.app.channelsbar.channel_access": "Channel access", + "scenes.app.channelsbar.channel_access.save": "Save access settings", + "scenes.app.channelsbar.channel_access.readonly": "Read-only channel", + "scenes.app.channelsbar.channel_access.readonly.info": "Restrict write to moderators, admins and owners.", + "scenes.app.channelsbar.channel_access.visibility": "Public visibility", + "scenes.app.channelsbar.channel_access.visibility.info": "Anyone except company guests can join.", + "scenes.app.channelsbar.channel_access.default": "Auto join", + "scenes.app.channelsbar.channel_access.default.info": "New members will be added here automatically.", + "scenes.app.channelsbar.channel_information": "Edit channel information", + "scenes.app.channelsbar.channel_information.logo.add": "Upload picture", + "scenes.app.channelsbar.channel_information.logo.remove": "Remove picture", + "scenes.app.channelsbar.channel_information.name": "Channel name", + "scenes.app.channelsbar.channel_information.description": "Channel description", + "scenes.app.channelsbar.channel_information.description.placeholder": "Channel description, you can use markdown.", + "scenes.app.channelsbar.channel_information.group": "Channel group", + "scenes.app.channelsbar.channel_information.group.none": "Not selected", + "scenes.app.channelsbar.channel_information.group.new": "Create a new group", + "scenes.app.channelsbar.channel_information.group.save": "Set channel group to $1", + "scenes.app.channelsbar.channel_information.group.save_none": "Do not set channel group", + "scenes.app.channelsbar.channel_information.save": "Save information", "scenes.app.channelsbar.channel_removing": "Delete channel", "scenes.app.channelsbar.company_invitation_alert_subtitle": "You can import these channels into your business to stay organized by clicking on this frame.", "scenes.app.channelsbar.company_invitation_alert_title": "You are invited to the company ", @@ -547,6 +567,11 @@ "scenes.apps.messages.message.new_messages_bar": "New messages", "scenes.apps.messages.message.cancel_button": "Cancel", "scenes.apps.messages.message.modify_button": "Edit", + "scenes.apps.messages.message.forward": "Forward", + "scenes.apps.messages.message.forward.title": "Forward message", + "scenes.apps.messages.message.forward.comment": "Add a message", + "scenes.apps.messages.message.forward.confirm": "Send to {{$1}} channel(s)", + "scenes.apps.messages.message.forward.send": "Message forwarded", "scenes.apps.messages.message.pin_button": "Pin message", "scenes.apps.messages.message.unpin_button": "Unpin message", "scenes.apps.messages.message.copy_link": "Copy link to message", @@ -675,6 +700,9 @@ "components.searchpopup.recent_media": "Recent media", "components.searchpopup.recent_files": "Recent files", "components.searchpopup.recent_channels_and_contacts": "Recent channels and contacts", + "components.channelselector.title": "Select channels", + "components.channelselector.confirm": "Select ${{1}} channel(s)", + "components.channelselector.search": "Search channels and discussions", "general.more": "More", "scenes.apps.board.archived_tasks": "Archived tasks ({{$1}})", "scenes.apps.board.active_tasks": "Active tasks", @@ -734,6 +762,7 @@ "scenes.client.channelbar.channelmemberslist.members_section": "Users in this channel", "scenes.client.channelbar.channelmemberslist.not_members_section": "Users not in this channel", "scenes.client.channelbar.channelmemberslist.menu.option_2": "Remove from channel", + "scenes.client.channelbar.readonly_channel.checkbox": "Restrict write-abitility", "components.leftbar.channel.workspaceschannels.menu.option_1": "Create a channel", "components.leftbar.channel.workspaceschannels.menu.option_2": "Join a channel", "components.channelworkspacelist.title": "Channels", @@ -766,7 +795,8 @@ "scenes.client.channelbar.channelmemberslist.tag": "You", "components.alert.leave_private_channel.title": "Are you sure you want to leave the channel ?", "components.alert.leave_private_channel.description": "You will not be able to join this private channel again unless someone invite you.", - "scenes.client.join_private_channel.info": "This content of this channel is restricted or doesn't exist. Please contact a member of this channel to get invited.", + "scenes.client.join_private_channel.info": "The content of this channel is restricted or doesn't exist. Please contact a member of this channel to get invited.", + "scenes.client.readonly.info": "This channel is a read-only feed but you can comment in the threads.", "scenes.client.join_public_channel": "Join this channel", "scenes.client.join_public_channel.info": "You are not a member of this direct channel. Click this button to start the discussion now.", "scenes.client.channelsbar.modals.workspace_channel_list.workspace_channel_row.tag": "You are not a member", @@ -943,12 +973,34 @@ "molecules.download_banner.download_button": "Download desktop app", "components.channel_attachement_list.medias": "Medias", "components.channel_attachement_list.files": "Files", - "components.channel_attachement_list.title": "Channel files and medias", + "components.channel_attachement_list.title": "Medias and documents", "components.channel_attachement_list.open": "Open gallery", "components.channel_attachement_list.nothing_found": "Nothing here yet", "molecules.message_quote.deleted": "This message was deleted", "molecules.quoted_content.attachements": "{{$1}} attachments", "components.message_seen_by.title": "Users who've seen the message", "components.message_seen_by.none_seen_it": "No one has seen the message yet", - "components.message_seen_by.btn": "Information" + "components.message_seen_by.btn": "Information", + "scenes.client.channelbar.channelmemberslist.invite_to_workspace": "Invite {{$1}} to the workspace ➡", + "components.invitation.reached_limit.text": "you reached the maximum number of users inside your company. Increase your subscription or add these users as guests", + "components.invitation.allow_anyone_by_email.text": "Let anyone with @{{$1}} email join this workspace", + "components.invitation.invitation_channels.button": "Invite to channels", + "components.invitation.invitation_input_list.add": "Add", + "components.invitation.invitation_input_list.member": "Member", + "components.invitation.invitation_input_list.guest": "Guest", + "components.invitation.invitation_input_list.placeholder": "Start typing an email", + "components.invitation.invitation_sent.title": "Invitations have successfully been sent", + "components.invitation.invitation_sent.subtitle_status": "You can track invitaion status in:", + "components.invitation.invitation_sent.subtitle_location": "Workspace settings > Member management", + "components.invitation.invitation_sent.link": "Check invitation status", + "components.invitation.invitation_sent.button": "Send more invitations", + "components.invitation.invitation_target.invite_as_guests": "Invite all as guests", + "components.invitation.invitation_target.invite_as_members": "Invite all as members", + "components.invitation.invitation_target.channels_button": "Channels to invite", + "components.invitation.workspace_link.text": "Workspace invitation link", + "components.invitation.workspace_link.button": "Copy", + "components.invitation.title": "invite people to {{$1}}", + "components.invitation.custom_role": "Custom role invitation", + "components.invitation.bulk_invitation": "Bulk invitation", + "components.invitation.button": "Send invitations" } diff --git a/twake/frontend/public/locales/fr.json b/twake/frontend/public/locales/fr.json index 2dcb5e7c88..a928fd0e97 100644 --- a/twake/frontend/public/locales/fr.json +++ b/twake/frontend/public/locales/fr.json @@ -694,6 +694,7 @@ "components.leftbar.channel.workspaceschannels.menu.option_2": "Rejoindre une chaîne", "components.channelworkspacelist.title": "Chaînes", "scenes.client.channelbar.workspacechannellist.autocomplete": "Rechercher une chaîne de discussion", + "scenes.client.channelbar.readonly_channel.checkbox": "Restreindre l'accès en écriture", "scenes.client.mainview.tabs.tabstemplateeditor.title_tab_creation": "Créer un onglet", "scenes.client.mainview.tabs.tabstemplateeditor.title_tab_edition": "Renommer {{$1}}", "components.connectorslistmanager.add_connectors": "Ajouter des connecteurs", diff --git a/twake/frontend/src/app/atoms/avatar/index.stories.tsx b/twake/frontend/src/app/atoms/avatar/index.stories.tsx index c8df3f05e8..1ce9ab49b4 100644 --- a/twake/frontend/src/app/atoms/avatar/index.stories.tsx +++ b/twake/frontend/src/app/atoms/avatar/index.stories.tsx @@ -1,7 +1,6 @@ -import React from 'react'; import { ComponentStory } from '@storybook/react'; import Avatar from '.'; -import { UserIcon } from '@atoms/icons-agnostic/index'; +import { UsersIcon } from '../icons-agnostic'; export default { title: '@atoms/avatar', @@ -20,8 +19,8 @@ const Template: ComponentStory = (args: { title: string }) => { 'https://images.freeimages.com/images/small-previews/d67/experimenting-with-nature-1547377.jpg', }, { - icon: , - className: 'opacity-50', + icon: , + className: '', }, ]; diff --git a/twake/frontend/src/app/atoms/avatar/index.tsx b/twake/frontend/src/app/atoms/avatar/index.tsx index 697b862a7d..03d0dbd914 100644 --- a/twake/frontend/src/app/atoms/avatar/index.tsx +++ b/twake/frontend/src/app/atoms/avatar/index.tsx @@ -1,41 +1,36 @@ import React from 'react'; import _ from 'lodash'; +import CryptoJS from 'crypto-js'; // @ts-ignore interface AvatarProps extends React.InputHTMLAttributes { type?: 'circle' | 'square'; - size?: 'lg' | 'md' | 'sm' | 'xs'; + size?: 'xl' | 'lg' | 'md' | 'sm' | 'xs'; avatar?: string; - icon?: JSX.Element; + icon?: JSX.Element | false; title?: string; + noGradient?: boolean; } -const gradients = [ - '#9F2EF4 15.98%, #F97D64 50.17%, #F6C533 82.97%', - '#0099C0 12.87%, #0099C0 49.65%, #3DD5A8 84.94%', - '#D3A6FF 15.1%, #B966B5 50.52%, #7E72C6 85.13%', - '#E2EF54 14.56%, #FF5C66 47.57%, #6248D5 83.84%', - '#FF5CF1 14.48%, #B38ADE 51.66%, #56CDDF 84.61%', - '#75C192 14.48%, #70BDA0 51.66%, #21B59C 84.61%', - '#FFA6AF 14.87%, #E62E40 50.07%, #AA0909 84.36%', - '#335F50 14.87%, #47888C 50.07%, #08C992 84.36%', - '#6CD97E 15.23%, #12B312 56.97%, #117600 84.49%', - '#7DF1FA 14.84%, #2BB4D6 49.93%, #008AA2 82.63%', - '#FFBF80 13.66%, #E66B2E 51.13%, #A64300 84.79%', -]; +const sizes = { xl: 24, lg: 14, md: 11, sm: 9, xs: 6 }; +const fontSizes = { xl: '2xl', lg: '2xl', md: 'lg', sm: 'md', xs: 'sm' }; -const getGradient = (title = '') => { - let output = 0; - for (let i = 0; i < title.length; i++) { - output += title[i].charCodeAt(0); - } - return gradients[output % 11]; +export const getGradient = (name: string) => { + const seed = parseInt(CryptoJS.MD5(name).toString().slice(0, 8), 16); + const canvas: HTMLCanvasElement = document.createElement('canvas'); + canvas.width = 254; + canvas.height = 254; + const ctx = canvas.getContext('2d'); + const gradient = ctx!.createLinearGradient(254, 254, 0, 0); + gradient.addColorStop(0, 'hsl(' + seed + ', 90%, 70%)'); + gradient.addColorStop(1, 'hsl(' + Math.abs(seed - 60) + ', 90%, 70%)'); + ctx!.fillStyle = gradient; + ctx!.fillRect(0, 0, 254, 254); + const b64 = canvas.toDataURL('image/jpeg'); + return b64; }; -const sizes = { lg: 14, md: 11, sm: 9, xs: 6 }; -const fontSizes = { lg: '2xl', md: 'lg', sm: 'md', xs: 'sm' }; - export default function Avatar(props: AvatarProps) { const avatarType = props.type || 'circle'; const avatarSize = sizes[props.size || 'md']; @@ -48,10 +43,37 @@ export default function Avatar(props: AvatarProps) { avatarType === 'circle' ? 'rounded-full' : 'rounded-sm' } `; + className += + ' border border-gray flex items-center justify-center bg-center bg-cover ' + + (props.noGradient ? ' bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white ' : ''); + + const spl_title = avatarTitle.split(' '); + + let letters = avatarTitle.slice(0, 1); + if (spl_title.length > 1) { + letters = spl_title[0].slice(0, 1) + spl_title[1].slice(0, 1); + } + + const lettersClass = + `font-medium bg-gray text-${fontSize}` + + (props.noGradient ? ' text-zinc-900 dark:text-white ' : ' text-white'); + + const style = props.noGradient + ? {} + : { backgroundImage: `url('${getGradient(props.title || '')}')` }; + if (props.icon) { - className += ' border border-gray-500 '; + className += ' '; return ( -
+
{props.icon}
); @@ -69,19 +91,6 @@ export default function Avatar(props: AvatarProps) { ); } - className += ' border border-gray flex items-center justify-center bg-gradient-to-r '; - - const spl_title = avatarTitle.split(' '); - - let letters = avatarTitle.slice(0, 1); - if (spl_title.length > 1) { - letters = spl_title[0].slice(0, 1) + spl_title[1].slice(0, 1); - } - - const lettersClass = `font-medium bg-gray text-white text-${fontSize}`; - - const style = { background: `linear-gradient(135deg, ${getGradient(props.title)})` }; - return (
{letters.toUpperCase()}
diff --git a/twake/frontend/src/app/atoms/icons-agnostic/assets/check-outline.svg b/twake/frontend/src/app/atoms/icons-agnostic/assets/check-outline.svg new file mode 100644 index 0000000000..347bf63f5e --- /dev/null +++ b/twake/frontend/src/app/atoms/icons-agnostic/assets/check-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/twake/frontend/src/app/atoms/icons-agnostic/assets/check.svg b/twake/frontend/src/app/atoms/icons-agnostic/assets/check.svg index 69e9bfd4f2..bd25dd1947 100644 --- a/twake/frontend/src/app/atoms/icons-agnostic/assets/check.svg +++ b/twake/frontend/src/app/atoms/icons-agnostic/assets/check.svg @@ -1,3 +1,3 @@ - + diff --git a/twake/frontend/src/app/atoms/icons-agnostic/assets/users.svg b/twake/frontend/src/app/atoms/icons-agnostic/assets/users.svg new file mode 100644 index 0000000000..eb5888807e --- /dev/null +++ b/twake/frontend/src/app/atoms/icons-agnostic/assets/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/twake/frontend/src/app/atoms/icons-agnostic/icons-agnostic.stories.tsx b/twake/frontend/src/app/atoms/icons-agnostic/icons-agnostic.stories.tsx index 741340a903..f00a7e3cda 100644 --- a/twake/frontend/src/app/atoms/icons-agnostic/icons-agnostic.stories.tsx +++ b/twake/frontend/src/app/atoms/icons-agnostic/icons-agnostic.stories.tsx @@ -19,6 +19,8 @@ import { XIcon, StatusCheckDoubleIcon, StatusCheckIcon, + UsersIcon, + CheckOutlineIcon, } from '@atoms/icons-agnostic/index'; export default { @@ -54,6 +56,7 @@ const Template: ComponentStory = (props: ComponentProps<'svg'>) => { } title="InputClear" /> } title="User" /> } title="Check" /> + } title="CheckOutline" /> } title="ZoomIn" /> } title="ZoomOut" /> } title="VerticalDots" /> @@ -64,6 +67,7 @@ const Template: ComponentStory = (props: ComponentProps<'svg'>) => { } title="X" /> } title="StatusCheckDouble" /> } title="StatusCheck" /> + } title="Users" />
); diff --git a/twake/frontend/src/app/atoms/icons-agnostic/index.tsx b/twake/frontend/src/app/atoms/icons-agnostic/index.tsx index 6563b760fa..8145185fc2 100644 --- a/twake/frontend/src/app/atoms/icons-agnostic/index.tsx +++ b/twake/frontend/src/app/atoms/icons-agnostic/index.tsx @@ -9,6 +9,7 @@ import { ReactComponent as ShareSvg } from './assets/share.svg'; import { ReactComponent as EyeSvg } from './assets/eye.svg'; import { ReactComponent as UserAddSvg } from './assets/user-add.svg'; import { ReactComponent as CheckSvg } from './assets/check.svg'; +import { ReactComponent as CheckOutlineSvg } from './assets/check-outline.svg'; import { ReactComponent as ZommInSvg } from './assets/zoom-in.svg'; import { ReactComponent as ZoomOutSvg } from './assets/zoom-out.svg'; import { ReactComponent as VerticalDotsSvg } from './assets/vertical-dots.svg'; @@ -18,6 +19,7 @@ import { ReactComponent as Up } from './assets/up.svg'; import { ReactComponent as X } from './assets/x.svg'; import { ReactComponent as StatusCheckDouble } from './assets/status-check-double.svg'; import { ReactComponent as StatusCheck } from './assets/status-check.svg'; +import { ReactComponent as Users } from './assets/users.svg'; export const CopyIcon = (props: ComponentProps<'svg'>) => ; export const DeleteIcon = (props: ComponentProps<'svg'>) => ; @@ -27,6 +29,7 @@ export const InputClearIcon = (props: ComponentProps<'svg'>) => ) => ; export const UserAddIcon = (props: ComponentProps<'svg'>) => ; export const CheckIcon = (props: ComponentProps<'svg'>) => ; +export const CheckOutlineIcon = (props: ComponentProps<'svg'>) => ; export const ZoomInIcon = (props: ComponentProps<'svg'>) => ; export const ZoomOutIcon = (props: ComponentProps<'svg'>) => ; export const VerticalDotsIcon = (props: ComponentProps<'svg'>) => ; @@ -39,3 +42,4 @@ export const StatusCheckDoubleIcon = (props: ComponentProps<'svg'>) => ( ); export const StatusCheckIcon = (props: ComponentProps<'svg'>) => ; +export const UsersIcon = (props: ComponentProps<'svg'>) => ; diff --git a/twake/frontend/src/app/atoms/icons-colored/assets/remove.svg b/twake/frontend/src/app/atoms/icons-colored/assets/remove.svg new file mode 100644 index 0000000000..76b4e67985 --- /dev/null +++ b/twake/frontend/src/app/atoms/icons-colored/assets/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/twake/frontend/src/app/atoms/icons-colored/assets/sent.svg b/twake/frontend/src/app/atoms/icons-colored/assets/sent.svg new file mode 100644 index 0000000000..23af0eb383 --- /dev/null +++ b/twake/frontend/src/app/atoms/icons-colored/assets/sent.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/twake/frontend/src/app/atoms/icons-colored/icons-colored.stories.tsx b/twake/frontend/src/app/atoms/icons-colored/icons-colored.stories.tsx index 3c6c3c9767..443942e71a 100644 --- a/twake/frontend/src/app/atoms/icons-colored/icons-colored.stories.tsx +++ b/twake/frontend/src/app/atoms/icons-colored/icons-colored.stories.tsx @@ -8,6 +8,8 @@ import { FileTypeSpreadsheetIcon, FileTypeUnknownIcon, NotFoundIcon, + RemoveIcon, + SentIcon, } from '@atoms/icons-colored/index'; export default { @@ -49,6 +51,8 @@ const Template: ComponentStory = (props: ComponentProps<'svg'>) => {

Component specific

} title="NotFound" /> + } title="Remove" /> + } title="Remove" />
); diff --git a/twake/frontend/src/app/atoms/icons-colored/index.tsx b/twake/frontend/src/app/atoms/icons-colored/index.tsx index dfab6aaa37..0742e37234 100644 --- a/twake/frontend/src/app/atoms/icons-colored/index.tsx +++ b/twake/frontend/src/app/atoms/icons-colored/index.tsx @@ -7,6 +7,8 @@ import { ReactComponent as FileTypeDocumentSvg } from './assets/file-type-docume import { ReactComponent as FileTypePdfSvg } from './assets/file-type-pdf.svg'; import { ReactComponent as FileTypeSpreadsheetSvg } from './assets/file-type-spreadsheet.svg'; import { ReactComponent as FileTypeUnknownSvg } from './assets/file-type-unknown.svg'; +import { ReactComponent as RemoveSvg } from './assets/remove.svg'; +import { ReactComponent as SentSvg } from './assets/sent.svg'; export const DismissIcon = (props: ComponentProps<'svg'>) => ; export const NotFoundIcon = (props: ComponentProps<'svg'>) => ; @@ -23,3 +25,11 @@ export const FileTypeSpreadsheetIcon = (props: ComponentProps<'svg'>) => ( export const FileTypeUnknownIcon = (props: ComponentProps<'svg'>) => ( ); + +export const RemoveIcon = (props: ComponentProps<'svg'>) => ( + +); + +export const SentIcon = (props: ComponentProps<'svg'>) => ( + +); diff --git a/twake/frontend/src/app/atoms/input/input-checkbox.tsx b/twake/frontend/src/app/atoms/input/input-checkbox.tsx new file mode 100644 index 0000000000..b5103135fc --- /dev/null +++ b/twake/frontend/src/app/atoms/input/input-checkbox.tsx @@ -0,0 +1,54 @@ +import { CheckOutlineIcon } from '../icons-agnostic'; +import { BaseSmall } from '../text'; + +export const Checkbox = (props: { + label?: string; + onChange?: (status: boolean) => void; + value?: boolean; + className?: string; + disabled?: boolean; +}) => { + const renderSwitch = () => { + const className = props.className || ''; + + return ( +
+ !props.label && !props.disabled && props.onChange && props.onChange(!props.value) + } + > + {props.value && } +
+ ); + }; + + if (props.label) { + return ( +
{ + if (!props.disabled) { + props.onChange && props.onChange(!props.value); + } + }} + > + {renderSwitch()} + + {props.label} + +
+ ); + } else { + return renderSwitch(); + } +}; diff --git a/twake/frontend/src/app/atoms/input/stories/checkbox.stories.tsx b/twake/frontend/src/app/atoms/input/stories/checkbox.stories.tsx new file mode 100644 index 0000000000..b1259eef47 --- /dev/null +++ b/twake/frontend/src/app/atoms/input/stories/checkbox.stories.tsx @@ -0,0 +1,40 @@ +import { ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import { Checkbox } from '../input-checkbox'; + +export default { + title: '@atoms/checkbox', +}; + +const Template: ComponentStory = (props: { label: string; disabled: boolean }) => { + const [checked, setChecked] = useState(false); + + return ( +
+ { + setChecked(e); + }} + value={checked} + disabled={props.disabled} + /> + +
+ + { + setChecked(e); + }} + value={checked} + disabled={props.disabled} + label={props.label} + /> +
+ ); +}; + +export const Default = Template.bind({}); +Default.args = { + label: 'Checkbox label', + disabled: false, +}; diff --git a/twake/frontend/src/app/atoms/modal/index.tsx b/twake/frontend/src/app/atoms/modal/index.tsx index 631ce4b770..483da3d737 100644 --- a/twake/frontend/src/app/atoms/modal/index.tsx +++ b/twake/frontend/src/app/atoms/modal/index.tsx @@ -144,7 +144,7 @@ export const Modal = (props: { }; export const ModalContent = (props: { - title: string; + title: ReactNode | string; text?: string; textCenter?: boolean; buttons?: ReactNode; diff --git a/twake/frontend/src/app/components/add-user-button/add-user-button.tsx b/twake/frontend/src/app/components/add-user-button/add-user-button.tsx index 0380858109..bbbcd0e3b8 100644 --- a/twake/frontend/src/app/components/add-user-button/add-user-button.tsx +++ b/twake/frontend/src/app/components/add-user-button/add-user-button.tsx @@ -2,15 +2,23 @@ import React from 'react'; import Icon from 'components/icon/icon.js'; import Languages from 'app/features/global/services/languages-service'; import './add-user-button.scss'; -import popupManager from 'app/deprecated/popupManager/popupManager.js'; -import AddUserByEmail from 'app/views/client/popup/AddUser/AddUserByEmail'; +import { useRecoilState } from 'recoil'; +import { invitationState } from 'app/features/invitation/state/invitation'; +import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users'; +import AccessRightsService from 'app/features/workspace-members/services/workspace-members-access-rights-service'; +import { useCurrentWorkspace } from 'app/features/workspaces/hooks/use-workspaces'; export default () => { - return ( + const [, setInvitationOpen] = useRecoilState(invitationState); + const { allowed_guests, allowed_members } = useInvitationUsers(); + const { workspace } = useCurrentWorkspace(); + + return AccessRightsService.hasLevel(workspace?.id, 'moderator') && + (allowed_guests > 0 || allowed_members > 0) ? (
{ - return popupManager.open(); + setInvitationOpen(true); }} >
@@ -26,5 +34,7 @@ export default () => { )}
+ ) : ( + <> ); }; diff --git a/twake/frontend/src/app/components/channel-members-list/channel-members-modal.tsx b/twake/frontend/src/app/components/channel-members-list/channel-members-modal.tsx index c0398c6812..90b58d23d6 100644 --- a/twake/frontend/src/app/components/channel-members-list/channel-members-modal.tsx +++ b/twake/frontend/src/app/components/channel-members-list/channel-members-modal.tsx @@ -1,22 +1,24 @@ -import { InformationCircleIcon, MailOpenIcon } from '@heroicons/react/outline'; -import { PlusIcon, SearchIcon } from '@heroicons/react/solid'; +import { InformationCircleIcon } from '@heroicons/react/outline'; +import { SearchIcon } from '@heroicons/react/solid'; import { Alert } from 'app/atoms/alert'; -import { Button } from 'app/atoms/button/button'; -import { ButtonConfirm } from 'app/atoms/button/confirm'; import { InputDecorationIcon } from 'app/atoms/input/input-decoration-icon'; import { Input } from 'app/atoms/input/input-text'; -import { Info } from 'app/atoms/text'; -import { usePendingEmail } from 'app/features/channel-members-search/hooks/use-pending-email-hook'; +import Text, { Info } from 'app/atoms/text'; import { useSearchChannelMembersAll } from 'app/features/channel-members-search/hooks/use-search-all'; import Languages from 'app/features/global/services/languages-service'; import { delayRequest } from 'app/features/global/utils/managedSearchRequest'; import Strings from 'app/features/global/utils/strings'; +import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users'; +import { invitationState } from 'app/features/invitation/state/invitation'; import useRouterChannel from 'app/features/router/hooks/use-router-channel'; import { useEffect, useState } from 'react'; import PerfectScrollbar from 'react-perfect-scrollbar'; +import { useRecoilState } from 'recoil'; import { EmailItem } from './email-item'; import { MemberItem } from './member-item'; import { UserItem } from './user-item'; +import AccessRightsService from 'app/features/workspace-members/services/workspace-members-access-rights-service'; +import { useCurrentWorkspace } from 'app/features/workspaces/hooks/use-workspaces'; export const ChannelMembersListModal = (): JSX.Element => { const channelId = useRouterChannel(); @@ -145,33 +147,32 @@ export const ChannelMembersListModal = (): JSX.Element => { }; const EmailSuggestion = ({ email }: { email: string }) => { - const { addInvite } = usePendingEmail(email); + const [, setInvitationOpen] = useRecoilState(invitationState); + const { addInvitation, allowed_guests, allowed_members } = useInvitationUsers(); + const { workspace } = useCurrentWorkspace(); + const invite = () => { + addInvitation(email); + setInvitationOpen(true); + }; if (!email || !Strings.verifyMail(email)) { return <>; } - return ( + return AccessRightsService.hasLevel(workspace?.id, 'moderator') && + (allowed_guests > 0 || allowed_members > 0) ? (
- {email} + invite()}> + {Languages.t( + 'scenes.client.channelbar.channelmemberslist.invite_to_workspace', + [email], + `Invite ${email} to the workspace âž¡`, + )} + - -
- addInvite('member')} - > - {Languages.t('scenes.client.channelbar.channelmemberslist.invite_email_button_workspace')} -
+ ) : ( + <> ); }; diff --git a/twake/frontend/src/app/components/channels-selector/index.tsx b/twake/frontend/src/app/components/channels-selector/index.tsx new file mode 100644 index 0000000000..1e13c54c4e --- /dev/null +++ b/twake/frontend/src/app/components/channels-selector/index.tsx @@ -0,0 +1,164 @@ +import { SearchIcon } from '@heroicons/react/outline'; +import Avatar from 'app/atoms/avatar'; +import { InputDecorationIcon } from 'app/atoms/input/input-decoration-icon'; +import { Input } from 'app/atoms/input/input-text'; +import { Loader } from 'app/atoms/loader'; +import { Modal, ModalContent } from 'app/atoms/modal'; +import { Base, Info } from 'app/atoms/text'; +import { + useSearchChannels, + useSearchChannelsLoading, +} from 'app/features/search/hooks/use-search-channels'; +import { SearchInputState } from 'app/features/search/state/search-input'; +import Block from 'app/molecules/grouped-rows/base'; +import { useEffect, useState } from 'react'; +import PerfectScrollbar from 'react-perfect-scrollbar'; +import { atom, useRecoilState, useSetRecoilState } from 'recoil'; +import Button from '../buttons/button'; +import Emojione from '../emojione/emojione'; +import UsersService from 'app/features/users/services/current-user-service'; +import { ChannelType } from 'app/features/channels/types/channel'; +import { Checkbox } from 'app/atoms/input/input-checkbox'; +import Languages from '../../features/global/services/languages-service'; +import Icon from '../icon/icon'; + +export const SelectChannelModalAtom = atom({ + key: 'SelectChannelModalAtom', + default: false, +}); + +export const ChannelSelectorModal = (props: { + initialChannels: ChannelType[]; + onChange: (channels: ChannelType[]) => void; + lockDefaultChannels?: boolean; +}) => { + const [open, setOpen] = useRecoilState(SelectChannelModalAtom); + const [channels, setChannels] = useState([]); + + return ( + setOpen(false)}> + + { + setChannels(channels); + }} + lockDefaultChannels={props.lockDefaultChannels} + /> + + + + ); +}; + +export const ChannelSelector = (props: { + initialChannels: ChannelType[]; + onChange: (channels: ChannelType[]) => void; + lockDefaultChannels?: boolean; +}) => { + const [selectedChannels, setSelectedChannels] = useState(props.initialChannels); + const setSearch = useSetRecoilState(SearchInputState); + const { channels } = useSearchChannels(); + const loading = useSearchChannelsLoading(); + + const displayedChannels = props.lockDefaultChannels + ? [ + ...channels.filter(channel => channel.is_default), + ...channels.filter(channel => !channel.is_default), + ] + : channels; + + useEffect(() => { + props.onChange(selectedChannels); + }, [selectedChannels]); + + return ( + <> + ( +
+ +
+ ) + : SearchIcon + } + input={({ className }) => ( + setSearch({ query: e.target.value })} + /> + )} + /> + + + {displayedChannels.map(channel => { + const name = + channel.name || channel.users?.map(u => UsersService.getFullName(u)).join(', '); + + return ( + + +
+ ) + } + title={name || ''} + /> + } + title={{name}} + subtitle={ + {`${ + channel.stats?.members || channel.members?.length || 0 + } members`} + } + suffix={ +
+ {props.lockDefaultChannels && + props.initialChannels.find(({ id }) => channel.id === id) && + channel.is_default ? ( +
+ +
+ ) : ( + channel.id === id)} + onChange={() => { + if (selectedChannels.includes(channel)) { + setSelectedChannels(selectedChannels.filter(c => c.id !== channel.id)); + } else if (channel.id) { + setSelectedChannels([...selectedChannels, channel]); + } + }} + /> + )} +
+ } + className="py-2" + /> + ); + })} + + + ); +}; diff --git a/twake/frontend/src/app/components/components-tester/group/inputs.js b/twake/frontend/src/app/components/components-tester/group/inputs.js index effcde1387..39f6bb9ab4 100755 --- a/twake/frontend/src/app/components/components-tester/group/inputs.js +++ b/twake/frontend/src/app/components/components-tester/group/inputs.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; -import Checkbox from 'components/inputs/checkbox.js'; +import Checkbox from 'app/components/inputs/deprecated_checkbox.js'; import Switch from 'components/inputs/switch'; import Button from 'components/buttons/button.js'; import Input from 'components/inputs/input.js'; diff --git a/twake/frontend/src/app/components/edit-channel/channel-access.tsx b/twake/frontend/src/app/components/edit-channel/channel-access.tsx new file mode 100644 index 0000000000..f15be9a37b --- /dev/null +++ b/twake/frontend/src/app/components/edit-channel/channel-access.tsx @@ -0,0 +1,100 @@ +import { Button } from 'app/atoms/button/button'; +import { BaseSmall, Info, Subtitle } from 'app/atoms/text'; +import Switch from 'app/components/inputs/switch'; +import { ChannelType } from 'app/features/channels/types/channel'; +import Languages from 'app/features/global/services/languages-service'; +import Block from 'app/molecules/grouped-rows/base'; +import { useState } from 'react'; + +export const ChannelAccessForm = (props: { + channel?: ChannelType; + onChange: (change: { + visibility: 'private' | 'public'; + is_default: boolean; + is_readonly: boolean; + }) => void; +}) => { + const [visibility, setVisibility] = useState<'private' | 'public'>( + props.channel?.visibility || ('private' as any), + ); + const [isDefault, setIsDefault] = useState(props.channel?.is_default || false); + const [isReadOnly, setIsReadOnly] = useState(props.channel?.is_readonly || false); + const [loading, setLoading] = useState(false); + + return ( +
+ } + title={Languages.t('scenes.app.channelsbar.channel_access.visibility')} + subtitle={ + + {Languages.t('scenes.app.channelsbar.channel_access.visibility.info')} + + } + suffix={ + { + if (visibility === 'public') setIsDefault(false); + setVisibility(visibility === 'public' ? 'private' : 'public'); + }} + /> + } + /> + + } + title={Languages.t('scenes.app.channelsbar.channel_access.default')} + subtitle={ + + {Languages.t('scenes.app.channelsbar.channel_access.default.info')} + + } + suffix={ + { + setIsDefault(!isDefault); + if (visibility === 'private' && !isDefault) setVisibility('public'); + }} + /> + } + /> + + } + title={Languages.t('scenes.app.channelsbar.channel_access.readonly')} + subtitle={ + + {Languages.t('scenes.app.channelsbar.channel_access.readonly.info')} + + } + suffix={ + { + setIsReadOnly(!isReadOnly); + }} + /> + } + /> + +
+ +
+
+ ); +}; diff --git a/twake/frontend/src/app/components/edit-channel/channel-information.tsx b/twake/frontend/src/app/components/edit-channel/channel-information.tsx new file mode 100644 index 0000000000..2953a7fd47 --- /dev/null +++ b/twake/frontend/src/app/components/edit-channel/channel-information.tsx @@ -0,0 +1,248 @@ +import Avatar from 'app/atoms/avatar'; +import { Button } from 'app/atoms/button/button'; +import { Checkbox } from 'app/atoms/input/input-checkbox'; +import { InputLabel } from 'app/atoms/input/input-decoration-label'; +import Select from 'app/atoms/input/input-select'; +import { Input } from 'app/atoms/input/input-text'; +import A from 'app/atoms/link'; +import { Modal, ModalContent } from 'app/atoms/modal'; +import { usePublicOrPrivateChannels } from 'app/features/channels/hooks/use-public-or-private-channels'; +import { ChannelType } from 'app/features/channels/types/channel'; +import Languages from 'app/features/global/services/languages-service'; +import { downscaleImage, getBase64 } from 'app/features/global/utils/strings'; +import Block from 'app/molecules/grouped-rows/base'; +import _ from 'lodash'; +import { useEffect, useState } from 'react'; +import Emojione from '../emojione/emojione'; + +const ChannelGroupSelector = (props: { group: string; onChange: (str: string) => void }) => { + const clean = (str?: string) => str?.toLocaleLowerCase().trim().replace(/ +/, ' '); + + const [group, setGroup] = useState(clean(props.group) || ''); + const [newGroup, setNewGroup] = useState(''); + const { publicChannels, privateChannels } = usePublicOrPrivateChannels(); + const groups = _.uniq( + [...publicChannels, ...privateChannels] + .filter(p => p.channel_group) + .map(p => clean(p.channel_group) || ''), + ).sort(); + + useEffect(() => { + if (!groups.includes(group) && !newGroup) setNewGroup(group); + }, [groups]); + + return ( +
+
+ + {groups.map(g => ( + } + title={_.capitalize(g)} + suffix={ + { + setGroup(v ? g : ''); + }} + value={g === group} + /> + } + subtitle={<>} + /> + ))} + + } + title={ +
+ { + setNewGroup(e.target.value); + setGroup(clean(e.target.value) || ''); + }} + value={newGroup} + className="" + placeholder={Languages.t('scenes.app.channelsbar.channel_information.group.new')} + /> +
+ } + suffix={ + { + setGroup(v ? newGroup : ''); + }} + value={!!group && clean(newGroup) === clean(group)} + /> + } + subtitle={<>} + /> + +
+ +
+
+ ); +}; + +export const ChannelInformationForm = (props: { + channel?: ChannelType; + onChange: (change: { + name: string; + icon: string; + description: string; + channel_group: string; + }) => void; +}) => { + const [channelGroupModal, setChannelGroupModal] = useState(false); + + const [group, setGroup] = useState(props.channel?.channel_group || ''); + const [name, setName] = useState(props.channel?.name || ''); + const [description, setDescription] = useState(props.channel?.description || ''); + const [icon, setIcon] = useState(props.channel?.icon || ''); + + return ( +
+ setChannelGroupModal(false)}> + + { + setChannelGroupModal(false); + setGroup(group); + }} + /> + + + + + + setName(e.target.value)} + /> + } + /> + + setDescription(e.target.value)} + multiline + /> + } + /> + + { + e.preventDefault(); + e.stopPropagation(); + setChannelGroupModal(group || ''); + }} + > + +
+ } + /> + +
+ +
+ + ); +}; diff --git a/twake/frontend/src/app/components/edit-channel/channel-notifications.tsx b/twake/frontend/src/app/components/edit-channel/channel-notifications.tsx new file mode 100644 index 0000000000..f28e6a01b4 --- /dev/null +++ b/twake/frontend/src/app/components/edit-channel/channel-notifications.tsx @@ -0,0 +1,88 @@ +import { Checkbox } from 'app/atoms/input/input-checkbox'; +import { useChannelMemberCurrentUser } from 'app/features/channel-members-search/hooks/member-hook'; +import { ChannelType } from 'app/features/channels/types/channel'; +import Languages from 'app/features/global/services/languages-service'; +import { ToasterService } from 'app/features/global/services/toaster-service'; +import Block from 'app/molecules/grouped-rows/base'; +import { useState } from 'react'; + +export const ChannelNotificationsForm = (props: { channel?: ChannelType; onBack: () => void }) => { + const { member, setNotificationPreference } = useChannelMemberCurrentUser( + props.channel?.id || '', + ); + const [value, _setValue] = useState(member?.notification_level || 'all'); + + const setValue = (value: any) => { + _setValue(value); + setNotificationPreference(value).finally(() => ToasterService.success('Updated')); + props.onBack(); + }; + + return ( +
+ } + title={Languages.t('scenes.apps.messages.left_bar.stream.notifications.all')} + subtitle={<>} + suffix={ + { + setValue('all'); + }} + /> + } + /> + + } + title={Languages.t('scenes.apps.messages.left_bar.stream.notifications.mentions', [ + '@all', + '@here', + `@[you]`, + ])} + subtitle={<>} + suffix={ + { + setValue('mentions'); + }} + /> + } + /> + + } + title={Languages.t('scenes.apps.messages.left_bar.stream.notifications.me', [`@[you]`])} + subtitle={<>} + suffix={ + { + setValue('me'); + }} + /> + } + /> + + } + title={Languages.t('scenes.apps.messages.left_bar.stream.notifications.never')} + subtitle={<>} + suffix={ + { + setValue('none'); + }} + /> + } + /> +
+ ); +}; diff --git a/twake/frontend/src/app/components/edit-channel/channel-settings-menu.tsx b/twake/frontend/src/app/components/edit-channel/channel-settings-menu.tsx new file mode 100644 index 0000000000..0c75c330d1 --- /dev/null +++ b/twake/frontend/src/app/components/edit-channel/channel-settings-menu.tsx @@ -0,0 +1,366 @@ +import { + BellIcon, + ChevronRightIcon, + HandIcon, + LinkIcon, + LogoutIcon, + PhotographIcon, + StarIcon, + TrashIcon, +} from '@heroicons/react/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/solid'; +import Avatar from 'app/atoms/avatar'; +import { UsersIcon } from 'app/atoms/icons-agnostic'; +import A from 'app/atoms/link'; +import { Loader } from 'app/atoms/loader'; +import { Base, Info } from 'app/atoms/text'; +import { useChannelMemberCurrentUser } from 'app/features/channel-members-search/hooks/member-hook'; +import ChannelsMineAPIClient from 'app/features/channels/api/channels-mine-api-client'; +import { useFavoriteChannels } from 'app/features/channels/hooks/use-favorite-channels'; +import { ChannelType } from 'app/features/channels/types/channel'; +import { isDirectChannel, isPrivateChannel } from 'app/features/channels/utils/utils'; +import AlertManager from 'app/features/global/services/alert-manager-service'; +import Languages from 'app/features/global/services/languages-service'; +import { ToasterService } from 'app/features/global/services/toaster-service'; +import { copyToClipboard } from 'app/features/global/utils/CopyClipboard'; +import RouterServices from 'app/features/router/services/router-service'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; +import workspaceUserRightsService from 'app/features/workspaces/services/workspace-user-rights-service'; +import Block from 'app/molecules/grouped-rows/base'; +import { addUrlTryDesktop } from 'app/views/desktop-redirect'; +import { useState } from 'react'; + +export const ChannelSettingsMenu = (props: { + channel?: ChannelType; + onEditChannel?: Function; + onAccess?: Function; + onMedias?: Function; + onNotifications?: Function; + onMembers?: Function; + onFavorite?: Function; + onClose?: Function; +}) => { + const icon = props.channel?.icon || ''; + const name = props.channel?.name || ''; + + const { user } = useCurrentUser(); + const canEdit = + props.channel?.owner === user || workspaceUserRightsService.hasWorkspacePrivilege(); + const isGuest = workspaceUserRightsService.isInvite(); + const isDirect = isDirectChannel(props.channel?.visibility || ''); + const canLeave = !isGuest || isDirect; + const canRemove = + user?.id === props.channel?.owner || workspaceUserRightsService.hasWorkspacePrivilege(); + + return ( +
+ {!isDirect && ( + <> + { + canEdit && props.onEditChannel && props.onEditChannel(); + }} + className={ + '-mx-2 my-2 p-2 rounded-md ' + + (canEdit ? 'cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800' : '') + } + title={{name || ''}} + subtitle={ + canEdit ? ( + + {Languages.t('scenes.app.channelsbar.channel_information')} + + ) : ( + <> + ) + } + avatar={ 20 ? icon : ''} title={name || ''} />} + suffix={canEdit ? : <>} + /> + {canRemove && } + +
+ { + canEdit && props.onAccess && props.onAccess(); + }} + className={ + '-mx-2 my-1 p-2 rounded-md ' + + (canEdit ? 'cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800' : '') + } + title={ + + {Languages.t('scenes.app.channelsbar.channel_access')} + + } + subtitle={<>} + avatar={} + suffix={ +
+ + {props.channel?.visibility === 'public' ? 'Public' : 'Private'} + + {canEdit ? : <> } +
+ } + /> + {!isGuest && ( + { + canEdit && props.onMembers && props.onMembers(); + }} + className={ + '-mx-2 my-1 p-2 rounded-md ' + + (canEdit ? 'cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800' : '') + } + title={ + + {Languages.t('scenes.apps.parameters.workspace_sections.members')} + + } + subtitle={<>} + avatar={} + suffix={} + /> + )} + + )} + { + props.onMedias && props.onMedias(); + }} + className="-mx-2 my-1 p-2 rounded-md cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800" + title={ + + {Languages.t('components.channel_attachement_list.title')} + + } + subtitle={<>} + avatar={} + suffix={} + /> +
+ + { + const url = addUrlTryDesktop( + `${document.location.origin}${RouterServices.generateRouteFromState({ + workspaceId: props.channel?.workspace_id || '', + companyId: props.channel?.company_id, + channelId: props.channel?.id, + })}`, + ); + copyToClipboard(url); + ToasterService.success(Languages.t('components.input.copied')); + }} + className={ + '-mx-2 my-1 p-2 rounded-md cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800' + } + title={ + + {Languages.t('scenes.app.channelsbar.channel_copy_link')} + + } + subtitle={<>} + avatar={} + suffix={<>} + /> + + {!isDirect && ( + { + props.onNotifications && props.onNotifications(); + }} + /> + )} + + {canLeave && } +
+
+ ); +}; + +const RemoveBlock = (props: { channel?: ChannelType; onLeave?: Function }) => { + const { refresh: refreshAllChannels } = useFavoriteChannels(); + const [loading, setLoading] = useState(false); + + return ( + { + AlertManager.confirm(async () => { + setLoading(true); + await ChannelsMineAPIClient.removeChannel( + props.channel?.company_id || '', + props.channel?.workspace_id || '', + props.channel?.id || '', + ); + await refreshAllChannels(); + + RouterServices.push( + RouterServices.generateRouteFromState({ + companyId: props.channel?.company_id || '', + workspaceId: props.channel?.workspace_id || '', + channelId: '', + }), + ); + + props.onLeave && props.onLeave(); + }); + }} + className="-mx-2 my-1 p-2 rounded-md cursor-pointer hover:bg-red-50 dark:hover:bg-red-800" + title={ + + {Languages.t('scenes.app.channelsbar.channel_removing')} + + } + subtitle={<>} + avatar={ + loading ? ( + + ) : ( + + ) + } + /> + ); +}; + +const LeaveBlock = (props: { channel?: ChannelType; onLeave?: Function }) => { + const { refresh: refreshAllChannels } = useFavoriteChannels(); + const { user } = useCurrentUser(); + const [loading, setLoading] = useState(false); + + const leaveChannel = async (isDirectChannel = false) => { + setLoading(true); + if (props.channel?.id && props.channel?.company_id && props.channel.workspace_id) { + const res = await ChannelsMineAPIClient.removeUser(user?.id || '', { + companyId: props.channel.company_id, + workspaceId: isDirectChannel ? 'direct' : props.channel.workspace_id, + channelId: props.channel.id, + }); + + if (res?.error?.length && res?.message?.length) { + ToasterService.error(`${res.error} - ${res.message}`); + } else { + await refreshAllChannels(); + + RouterServices.push( + RouterServices.generateRouteFromState({ + companyId: props.channel?.company_id || '', + workspaceId: props.channel?.workspace_id || '', + channelId: '', + }), + ); + + props.onLeave && props.onLeave(); + } + await refreshAllChannels(); + } + + setLoading(false); + }; + + return ( + { + if (props.channel!.visibility) { + if (isPrivateChannel(props.channel!.visibility)) { + return AlertManager.confirm(() => leaveChannel(), undefined, { + title: Languages.t('components.alert.leave_private_channel.title'), + text: Languages.t('components.alert.leave_private_channel.description'), + }); + } + if (isDirectChannel(props.channel!.visibility)) { + return leaveChannel(true); + } + } + + return leaveChannel(); + }} + className="-mx-2 my-1 p-2 rounded-md cursor-pointer hover:bg-red-50 dark:hover:bg-red-800" + title={ + + {Languages.t('scenes.app.channelsbar.channel_leaving')} + + } + subtitle={<>} + avatar={ + loading ? ( + + ) : ( + + ) + } + /> + ); +}; + +const NotificationsBlock = (props: { channel?: ChannelType; onClick: Function }) => { + const { member } = useChannelMemberCurrentUser(props.channel?.id || ''); + + return ( + { + props.onClick(); + }} + className="-mx-2 my-1 p-2 rounded-md cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800" + title={ + + {Languages.t('scenes.app.channelsbar.currentuser.user_parameter')} + + } + subtitle={<>} + avatar={} + suffix={ +
+ + {member?.notification_level === 'all' && + Languages.t('scenes.apps.messages.left_bar.stream.notifications.all')} + {member?.notification_level === 'mentions' && '@all, @[you]'} + {member?.notification_level === 'me' && + Languages.t('scenes.apps.messages.left_bar.stream.notifications.me', [`@[you]`])} + {member?.notification_level === 'none' && + Languages.t('scenes.apps.messages.left_bar.stream.notifications.never')} + + +
+ } + /> + ); +}; + +const FavoriteBlock = (props: { channel?: ChannelType }) => { + const { favorite, setFavorite } = useChannelMemberCurrentUser(props.channel?.id || ''); + const [loading, setLoading] = useState(false); + + return ( + { + setLoading(true); + await setFavorite(!favorite); + setLoading(false); + }} + className="-mx-2 my-1 p-2 rounded-md cursor-pointer hover:bg-blue-50 dark:hover:bg-zinc-800" + title={ + + {Languages.t( + favorite + ? 'scenes.apps.messages.left_bar.stream.remove_from_favorites' + : 'scenes.apps.messages.left_bar.stream.add_to_favorites', + )} + + } + subtitle={<>} + avatar={ + loading ? ( + + ) : favorite ? ( + + ) : ( + + ) + } + /> + ); +}; diff --git a/twake/frontend/src/app/components/edit-channel/index.tsx b/twake/frontend/src/app/components/edit-channel/index.tsx new file mode 100644 index 0000000000..ad5e42c0a1 --- /dev/null +++ b/twake/frontend/src/app/components/edit-channel/index.tsx @@ -0,0 +1,262 @@ +import { Transition } from '@headlessui/react'; +import { ChevronLeftIcon } from '@heroicons/react/outline'; +import A from 'app/atoms/link'; +import { Loader } from 'app/atoms/loader'; +import { Modal, ModalContent } from 'app/atoms/modal'; +import { useUsersSearchModal } from 'app/features/channel-members-search/state/search-channel-member'; +import ChannelAPIClient from 'app/features/channels/api/channel-api-client'; +import { useChannel } from 'app/features/channels/hooks/use-channel'; +import { useFavoriteChannels } from 'app/features/channels/hooks/use-favorite-channels'; +import { channelAttachmentListState } from 'app/features/channels/state/channel-attachment-list'; +import { ChannelType } from 'app/features/channels/types/channel'; +import useRouteState from 'app/features/router/hooks/use-route-state'; +import { useEffect, useState } from 'react'; +import { atom, useRecoilState, useSetRecoilState } from 'recoil'; +import { slideXTransition, slideXTransitionReverted } from 'src/utils/transitions'; +import { ChannelAccessForm } from './channel-access'; +import { ChannelInformationForm } from './channel-information'; +import { ChannelNotificationsForm } from './channel-notifications'; +import { ChannelSettingsMenu } from './channel-settings-menu'; +import RouterServices from '@features/router/services/router-service'; +import Languages from 'app/features/global/services/languages-service'; + +const EditChannelModalAtom = atom({ + key: 'EditChannelModalAtom', + default: { + open: false, + channelId: '', + }, +}); + +export const useOpenChannelModal = () => { + const setModal = useSetRecoilState(EditChannelModalAtom); + return (channelId: string) => setModal({ open: true, channelId }); +}; + +export const EditChannelModal = () => { + const [channelModal, setChannelModal] = useRecoilState(EditChannelModalAtom); + + return ( + setChannelModal({ ...channelModal, open: false })} + className="int-channel-edit-modal" + > + {!channelModal.channelId && } + {!!channelModal.channelId && } + + ); +}; + +const EditChannelForm = () => { + const [channelModal, setChannelModal] = useRecoilState(EditChannelModalAtom); + const [page, setPage] = useState<'information' | 'access' | 'notifications' | 'menu'>('menu'); + const { setOpen: setParticipantsOpen } = useUsersSearchModal(); + const [, setChannelAttachmentState] = useRecoilState(channelAttachmentListState); + const { refresh } = useFavoriteChannels(); + + const { channel } = useChannel(channelModal.channelId); + + return ( + + {page !== 'menu' && ( + setPage('menu')}> + + + )} + {Languages.t('scenes.app.channelsbar.modify_channel_menu')} +
+ } + > +
+ +
+ + setPage('information')} + onAccess={() => setPage('access')} + onNotifications={() => setPage('notifications')} + onClose={() => { + setChannelModal({ ...channelModal, open: false }); + }} + onMembers={() => { + setChannelModal({ ...channelModal, open: false }); + setTimeout(() => setParticipantsOpen(true), 500); + }} + onMedias={() => { + setChannelModal({ ...channelModal, open: false }); + setTimeout(() => setChannelAttachmentState(true), 500); + }} + channel={channel} + /> + + + { + setPage('menu'); + await ChannelAPIClient.save( + { ...channel, ...changes }, + { + companyId: channel.company_id!, + workspaceId: channel.workspace_id!, + channelId: channel.id, + }, + ); + await refresh(); + }} + channel={channel} + /> + + + { + await ChannelAPIClient.save( + { ...channel, ...changes }, + { + companyId: channel.company_id!, + workspaceId: channel.workspace_id!, + channelId: channel.id, + }, + ); + await refresh(); + setPage('menu'); + }} + channel={channel} + /> + + + setPage('menu')} channel={channel} /> + +
+ + ); +}; + +const CreateChannelForm = () => { + const setChannelModal = useSetRecoilState(EditChannelModalAtom); + const { setOpen: setParticipantsOpen } = useUsersSearchModal(); + const { companyId, workspaceId } = useRouteState(); + const { refresh } = useFavoriteChannels(); + + const [step, setStep] = useState(0); + const [channel, setChannel] = useState>({}); + + useEffect(() => { + if (step === 2) { + (async () => { + const created = await ChannelAPIClient.save(channel, { + companyId: companyId!, + workspaceId: workspaceId!, + }); + await refresh(); + RouterServices.push(RouterServices.generateRouteFromState({ channelId: created.id })); + setChannelModal({ open: false, channelId: '' }); + setTimeout(() => { + setParticipantsOpen(true); + }, 500); + })(); + } + }, [step]); + + return ( + +
+ +
+ + { + setStep(1); + setChannel({ ...channel, ...changes }); + }} + /> + + + + { + setStep(2); + setChannel({ ...channel, ...changes }); + }} + /> + + + +
+ +
+
+
+
+ ); +}; diff --git a/twake/frontend/src/app/components/forward-message/index.tsx b/twake/frontend/src/app/components/forward-message/index.tsx new file mode 100644 index 0000000000..70b87468cc --- /dev/null +++ b/twake/frontend/src/app/components/forward-message/index.tsx @@ -0,0 +1,103 @@ +import { Button } from 'app/atoms/button/button'; +import { Input } from 'app/atoms/input/input-text'; +import { Modal, ModalContent } from 'app/atoms/modal'; +import { ToasterService } from 'app/features/global/services/toaster-service'; +import { useState } from 'react'; +import { atom, useRecoilState } from 'recoil'; +import { ChannelSelector } from '../channels-selector'; +import MessageThreadAPIClient from 'features/messages/api/message-thread-api-client'; +import { ChannelType } from 'app/features/channels/types/channel'; +import { NodeMessage } from 'app/features/messages/types/message'; +import Login from 'app/features/auth/login-service'; +import { v1 as uuidv1 } from 'uuid'; +import Languages from '../../features/global/services/languages-service'; + +export const ForwardMessageAtom = atom({ + key: 'ForwardMessageAtom', + default: null, +}); + +export const ForwardMessageModal = () => { + const [message, setMessage] = useRecoilState(ForwardMessageAtom); + + return ( + setMessage(null)} + style={{ maxWidth: '600px', width: '100vw' }} + > + + + ); +}; + +export const ForwardMessage = () => { + const [message, setMessage] = useRecoilState(ForwardMessageAtom); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(false); + const [comment, setComment] = useState(''); + + return ( + + { + setChannels(channels); + }} + /> + + setComment(e.target.value)} + /> + + + ); +}; diff --git a/twake/frontend/src/app/components/inputs/checkbox.js b/twake/frontend/src/app/components/inputs/deprecated_checkbox.js similarity index 86% rename from twake/frontend/src/app/components/inputs/checkbox.js rename to twake/frontend/src/app/components/inputs/deprecated_checkbox.js index 9e671aaeb1..8226e45777 100755 --- a/twake/frontend/src/app/components/inputs/checkbox.js +++ b/twake/frontend/src/app/components/inputs/deprecated_checkbox.js @@ -31,10 +31,12 @@ export default class Checkbox extends React.Component { return (
{ + const [isOpen, setOpen] = useRecoilState(invitationState); + const [activeTab, setInvitationTab] = useRecoilState(invitationActiveTab); + const [invitations] = useRecoilState(invitationEmailsState); + const workspace = useCurrentWorkspace(); + const [isInvitationSent] = useRecoilState(invitationSentState); + const { send, reset } = useInvitation(); + const [sending, setSending] = useState(false); + + const handleSend = async () => { + setSending(true); + try { + await send(); + } catch (error) { + console.debug(error); + } finally { + setSending(false); + } + }; + + return ( + { + setOpen(false); + reset(); + }} + className="sm:w-[60vw] sm:max-w-2xl" + style={{ minHeight: 'calc(70vh - 98px)' }} + > + + {!isInvitationSent ? ( + <> + + {Languages.t( + 'components.invitation.custom_role_invitation', + [], + 'Custom role invitation', + )} +
, +
+ {Languages.t('components.invitation.bulk_invitation', [], 'Bulk invitation')} +
, + ]} + selected={activeTab} + onClick={index => setInvitationTab(index)} + className="w-full" + parentClassName="basis-1/2 justify-center" + /> + {activeTab === InvitationTabs.custom && } + {activeTab === InvitationTabs.bulk && } + + + + ) : ( + + )} + + + ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/allow-anyone-by-email.tsx b/twake/frontend/src/app/components/invitation/parts/allow-anyone-by-email.tsx new file mode 100644 index 0000000000..6a058a7186 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/allow-anyone-by-email.tsx @@ -0,0 +1,56 @@ +import Switch from 'app/components/inputs/switch'; +import Languages from 'app/features/global/services/languages-service'; +import { ToasterService } from 'app/features/global/services/toaster-service'; +import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; +import workspaceApiClient from 'app/features/workspaces/api/workspace-api-client'; +import { useCurrentWorkspace } from 'app/features/workspaces/hooks/use-workspaces'; +import React, { useState } from 'react'; + +export default (): React.ReactElement => { + const { user } = useCurrentUser(); + const { members_limit_reached } = useInvitationUsers(); + const { workspace } = useCurrentWorkspace(); + const [allow, setAllow] = useState(!!workspace?.preferences?.invite_domain); + + const currentUserDomain = user?.email.split('@').pop(); + + const handleChange = async (value: boolean): Promise => { + if (value) { + try { + await workspaceApiClient.setInvitationDomain( + workspace?.company_id as string, + workspace?.id as string, + currentUserDomain as string, + ); + setAllow(true); + ToasterService.success('Invitation domain updated'); + } catch (error) { + ToasterService.error('Failed to set invitation domain'); + } + } + }; + + return !members_limit_reached ? ( +
+
+ {Languages.t( + 'components.invitation.allow_anyone_by_email.text', + [workspace?.preferences?.invite_domain || currentUserDomain], + `Let anyone with @${ + workspace?.preferences?.invite_domain || currentUserDomain + } email join this workspace`, + )} +
+
+ +
+
+ ) : ( + <> + ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/bulk-invitation.tsx b/twake/frontend/src/app/components/invitation/parts/bulk-invitation.tsx new file mode 100644 index 0000000000..4c2cbde1a3 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/bulk-invitation.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import InvitationInputBulk from './invitation-input-bulk'; +import InvitationTarget from './invitation-target'; +import WorkspaceLink from './workspace-link'; + +export default (): React.ReactElement => { + return ( +
+ + +
+ +
+ ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/custom-role-invitation.tsx b/twake/frontend/src/app/components/invitation/parts/custom-role-invitation.tsx new file mode 100644 index 0000000000..5d74541133 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/custom-role-invitation.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import AllowAnyoneByEmail from './allow-anyone-by-email'; +import InvitationInputList from './invitation-input-list'; +import InvitationTarget from './invitation-target'; +import Workspace_link from './workspace-link'; + +export default(): React.ReactElement => { + return ( +
+ + + +
+ +
+ ); +} diff --git a/twake/frontend/src/app/components/invitation/parts/invitation-channels.tsx b/twake/frontend/src/app/components/invitation/parts/invitation-channels.tsx new file mode 100644 index 0000000000..648c42d538 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/invitation-channels.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Button } from 'app/atoms/button/button'; +import { Modal, ModalContent } from 'app/atoms/modal'; +import { ChannelSelector } from 'app/components/channels-selector'; +import { ChannelType } from 'app/features/channels/types/channel'; +import { useInvitationChannels } from 'app/features/invitation/hooks/use-invitation-channels'; +import { uniqBy } from 'lodash'; +import Languages from 'app/features/global/services/languages-service'; + +export default (): React.ReactElement => { + const { selectedChannels, closeSelection, setChannels, open } = + useInvitationChannels(); + + const handleSelectionChange = (channels: ChannelType[]) => { + setChannels(uniqBy(channels, 'id')); + }; + + return ( + closeSelection()} className="sm:w-[20vw] sm:max-w-xl"> + + + + + + + ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/invitation-input-bulk.tsx b/twake/frontend/src/app/components/invitation/parts/invitation-input-bulk.tsx new file mode 100644 index 0000000000..103bd1ec96 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/invitation-input-bulk.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { + invitationEmailsState, + invitationTypeState, + InvitedUser, +} from 'app/features/invitation/state/invitation'; +import { useRecoilState } from 'recoil'; +import ChipInput from 'material-ui-chip-input'; +import { RemoveIcon } from 'app/atoms/icons-colored'; +import PerfectScrollbar from 'react-perfect-scrollbar'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; +import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users'; +import ReachedLimit from './reached-limit'; + +const emailRegex = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; + +export default (): React.ReactElement => { + const [invitations, setInvitations] = useRecoilState(invitationEmailsState); + const [role] = useRecoilState(invitationTypeState); + const { user } = useCurrentUser(); + const { can_add_invitations, members_limit_reached } = useInvitationUsers(); + + const emails = (invitations || []).map(invitation => invitation.email); + const currentUserEmailDomain = user?.email.split('@').pop(); + + const handleChange = (chips: string[]) => { + const newInvitations: InvitedUser[] = chips + .filter(chip => !invitations.find(invitation => invitation.email === chip)) + .map(chip => ({ email: chip, role })); + const updatedInvitations = [...invitations, ...newInvitations]; + + setInvitations(updatedInvitations); + }; + + const validate = (chip: string): boolean => { + return emailRegex.test(chip) && currentUserEmailDomain === chip.split('@').pop(); + }; + + const handleDelete = (chip: string) => { + const updatedInvitations = invitations.filter(({ email }) => email !== chip); + + setInvitations(updatedInvitations); + }; + + const rederChip = ( + { chip, handleDelete }: { chip: string; handleDelete: React.EventHandler }, + key: string, + ): React.ReactElement => { + return ( +
+ +
{chip}
+
+ ); + }; + + return ( + + { members_limit_reached && } + + + ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/invitation-input-list.tsx b/twake/frontend/src/app/components/invitation/parts/invitation-input-list.tsx new file mode 100644 index 0000000000..1f6a295089 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/invitation-input-list.tsx @@ -0,0 +1,158 @@ +import { Button } from 'app/atoms/button/button'; +import { RemoveIcon } from 'app/atoms/icons-colored'; +import { Input } from 'app/atoms/input/input-text'; +import { + invitationEmailsState, + InvitationType, + invitationTypeState, +} from 'app/features/invitation/state/invitation'; +import React, { useState } from 'react'; +import { useRecoilState } from 'recoil'; +import PerfectScrollbar from 'react-perfect-scrollbar'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; +import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users'; +import ReachedLimit from './reached-limit'; +import Languages from 'app/features/global/services/languages-service'; + +export default (): React.ReactElement => { + const [invitations, setInvitations] = useRecoilState(invitationEmailsState); + const [currentInput, setCurrentInput] = useState(''); + const [notValidEmail, setNotValidEmail] = useState(false); + const [invitationTargetType] = useRecoilState(invitationTypeState); + const { user } = useCurrentUser(); + const emailRegex = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; + const currentUserEmailDomain = user?.email.split('@').pop(); + const { can_add_invitations, members_limit_reached, allowed_members, allowed_guests } = + useInvitationUsers(); + + const emailExists = (target: string): boolean => + !!invitations.find(invitation => invitation.email === target); + + const handleEnter = (event: unknown): void => { + if ((event as KeyboardEvent).key === 'Enter') { + handleAdd(); + } + }; + + const handleAdd = (): void => { + const role = invitationTargetType; + if ( + emailRegex.test(currentInput) && + !emailExists(currentInput) && + currentInput.split('@').pop() === currentUserEmailDomain + ) { + setInvitations([...invitations, { email: currentInput, role }]); + setCurrentInput(''); + setNotValidEmail(false); + } else { + setNotValidEmail(true); + } + }; + + const removeEmail = (targetEmail: string): void => { + setInvitations(invitations.filter(invitation => invitation.email !== targetEmail)); + }; + + const handleInput = (event: any): void => { + setCurrentInput(event.target.value); + setNotValidEmail(!emailRegex.test(event.target.value) || emailExists(event.target.value)); + }; + + const handleRoleChange = (email: string, role: InvitationType): void => { + if (role === InvitationType.guest && allowed_guests <= 0) return; + if (role === InvitationType.member && allowed_members <= 0) return; + + const changedInvitations = invitations.map(invitation => { + if (invitation.email === email) { + return { + email, + role, + }; + } + + return invitation; + }); + + setInvitations(changedInvitations); + }; + + return ( +
+ {can_add_invitations ? ( +
+
+
+ +
+
+ +
+
+ ) : members_limit_reached ? ( + + ) : ( + <> + )} + + {invitations.map(invitation => ( +
+
+ removeEmail(invitation.email)} + /> +
+
+ +
+
+ +
+
+ ))} +
+
+ ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/invitation-sent.tsx b/twake/frontend/src/app/components/invitation/parts/invitation-sent.tsx new file mode 100644 index 0000000000..d1313351d6 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/invitation-sent.tsx @@ -0,0 +1,53 @@ +import { SentIcon } from 'app/atoms/icons-colored'; +import { Title, Base } from 'app/atoms/text'; +import Link from 'app/atoms/link'; +import React from 'react'; +import { Button } from 'app/atoms/button/button'; +import { useInvitation } from 'app/features/invitation/hooks/use-invitation'; +import Languages from 'app/features/global/services/languages-service'; + +export default (): React.ReactElement => { + const { reset } = useInvitation(); + + return ( +
+
+ +
+
+ + {Languages.t( + 'components.invitation.invitation_sent.title', + [], + 'Invitations have successfully been sent', + )} + +
+
+ + {Languages.t( + 'components.invitation.invitation_sent.subtitle_status', + [], + 'You can track invitaion status in:', + )} + + {Languages.t( + 'components.invitation.invitation_sent.subtitle_location', + [], + 'Workspace settings > Member management', + )} + +
+
+ + {Languages.t('components.invitation.invitation_sent.link', [], 'Check invitation status')} + +
+
+ +
+
+ ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/invitation-target.tsx b/twake/frontend/src/app/components/invitation/parts/invitation-target.tsx new file mode 100644 index 0000000000..6f7db799d8 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/invitation-target.tsx @@ -0,0 +1,75 @@ +import { BaseSmall } from 'app/atoms/text'; +import Switch from 'app/components/inputs/switch'; +import Languages from 'app/features/global/services/languages-service'; +import { useInvitationChannels } from 'app/features/invitation/hooks/use-invitation-channels'; +import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users'; +import { + invitationEmailsState, + InvitationType, + invitationTypeState, +} from 'app/features/invitation/state/invitation'; +import React from 'react'; +import { useRecoilState } from 'recoil'; + +export default (): React.ReactElement => { + const [invitationType, setInvitationType] = useRecoilState(invitationTypeState); + const { openSelection, selectedChannels } = useInvitationChannels(); + const [invitations, setInvitations] = useRecoilState(invitationEmailsState); + const { allowed_guests, allowed_members } = useInvitationUsers(); + + const changeInvitationType = (type: InvitationType) => { + setInvitationType(type); + + if (type === InvitationType.guest && allowed_guests <= 0) return; + if (type === InvitationType.member && allowed_members <= 0) return; + setInvitations(invitations.map(({ email }) => ({ email, role: type }))); + }; + + return ( +
+
+ + {Languages.t( + 'components.invitation.invitation_target.invite_as_guests', + [], + 'Invite all as guests', + )} + + changeInvitationType(InvitationType.guest)} + /> +
+
+ + {Languages.t( + 'components.invitation.invitation_target.invite_as_members', + [], + 'Invite all as members', + )} + + changeInvitationType(InvitationType.member)} + /> +
+
+
openSelection()} + > + {Languages.t( + 'components.invitation.invitation_target.channels_button', + [], + 'Channels to invite', + )} +
+ {selectedChannels.length} +
+
+
+
+ ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/reached-limit.tsx b/twake/frontend/src/app/components/invitation/parts/reached-limit.tsx new file mode 100644 index 0000000000..3e9a63b108 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/reached-limit.tsx @@ -0,0 +1,15 @@ +import Text from 'app/atoms/text'; +import Languages from 'app/features/global/services/languages-service'; +import React from 'react'; + +export default (): React.ReactElement => { + return ( + + {Languages.t( + 'components.invitation.reached_limit.text', + [], + 'you reached the maximum number of users inside your company. Increase your subscription or add these users as guests.', + )} + + ); +}; diff --git a/twake/frontend/src/app/components/invitation/parts/workspace-link.tsx b/twake/frontend/src/app/components/invitation/parts/workspace-link.tsx new file mode 100644 index 0000000000..4018fda135 --- /dev/null +++ b/twake/frontend/src/app/components/invitation/parts/workspace-link.tsx @@ -0,0 +1,51 @@ +import { Button } from 'app/atoms/button/button'; +import React, { useState } from 'react'; +import { CopyIcon } from '@atoms/icons-agnostic'; +import { Link } from 'react-feather'; +import { Base } from 'app/atoms/text'; +import { useInvitation } from 'app/features/invitation/hooks/use-invitation'; +import Languages from 'app/features/global/services/languages-service'; + +export default (): React.ReactElement => { + const { generateInvitationLink } = useInvitation(); + const [loading, setLoading] = useState(false); + + const copyLink = async () => { + setLoading(true); + const link = await generateInvitationLink(); + + if (link) { + navigator.clipboard.writeText(link); + } + setLoading(false); + }; + + return ( +
+
+ +
+
+ + {Languages.t( + 'components.invitation.workspace_link.text', + [], + 'Workspace invitation link', + )} + +
+
+ +
+
+ ); +}; diff --git a/twake/frontend/src/app/deprecated/Apps/Drive/Drive.js b/twake/frontend/src/app/deprecated/Apps/Drive/Drive.js index abb1065a4e..59c0735324 100755 --- a/twake/frontend/src/app/deprecated/Apps/Drive/Drive.js +++ b/twake/frontend/src/app/deprecated/Apps/Drive/Drive.js @@ -6,6 +6,7 @@ import LocalStorage from 'app/features/global/framework/local-storage-service'; import AceModeList from './utils/ace_modelist.js'; import { getCompanyApplications } from 'app/features/applications/state/company-applications'; import Groups from 'app/deprecated/workspaces/groups.js'; +import _ from 'lodash'; import Globals from 'app/features/global/services/globals-twake-app-service'; @@ -512,6 +513,9 @@ class Drive extends Observable { is_url_file: true, url: (current.hidden_data || {}).editor_url, name: (current.hidden_data || {}).editor_name || 'web link', + app: { + name: (current.hidden_data || {}).editor_name || 'web link', + }, }); } @@ -529,7 +533,7 @@ class Drive extends Observable { ) >= 0 ) { if (app.display?.twake?.files?.editor?.edition_url) { - editor_candidate.push(app); + editor_candidate.push({ app }); } if (app.display?.twake?.files?.editor?.preview_url) { preview_candidate.push({ @@ -588,7 +592,7 @@ class Drive extends Observable { ) >= 0 ) { if (app.display?.twake?.files?.editor?.edition_url) { - editor_candidate.push(app); + editor_candidate.push({ app }); } if (app.display?.twake?.files?.editor?.preview_url) { preview_candidate.push({ @@ -600,8 +604,8 @@ class Drive extends Observable { }); return { - preview_candidate: preview_candidate, - editor_candidate: editor_candidate, + preview_candidate: _.uniqBy(preview_candidate, a => a.app.id), + editor_candidate: _.uniqBy(editor_candidate, a => a.app.id), }; } diff --git a/twake/frontend/src/app/features/channel-members-search/hooks/member-hook.ts b/twake/frontend/src/app/features/channel-members-search/hooks/member-hook.ts index 9173c1621d..0684243720 100644 --- a/twake/frontend/src/app/features/channel-members-search/hooks/member-hook.ts +++ b/twake/frontend/src/app/features/channel-members-search/hooks/member-hook.ts @@ -1,14 +1,68 @@ import ChannelMembersAPIClient from 'app/features/channel-members-search/api/members-api-client'; -import { useRefreshPublicOrPrivateChannels } from 'app/features/channels/hooks/use-public-or-private-channels'; +import ChannelCurrentMemberAPIClient from 'app/features/channel-members/api/channel-members-api-client'; +import { useChannel } from 'app/features/channels/hooks/use-channel'; +import { + useDirectChannels, + useRefreshDirectChannels, +} from 'app/features/channels/hooks/use-direct-channels'; +import { useFavoriteChannels } from 'app/features/channels/hooks/use-favorite-channels'; +import { + usePublicOrPrivateChannels, + useRefreshPublicOrPrivateChannels, +} from 'app/features/channels/hooks/use-public-or-private-channels'; import useRouterChannel from 'app/features/router/hooks/use-router-channel'; import useRouterCompany from 'app/features/router/hooks/use-router-company'; import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { ChannelMemberSelector } from '../state/store'; -import { ParamsChannelMember } from '../types/channel-members'; +import { ChannelMemberType, ParamsChannelMember } from '../types/channel-members'; import { useRefreshChannelMembers } from './members-hook'; +export function useChannelMemberCurrentUser(channelId: string) { + const { favoriteChannels, refresh: refreshAllChannels } = useFavoriteChannels(); + const { directChannels } = useDirectChannels(); + const { publicChannels, privateChannels } = usePublicOrPrivateChannels(); + const { channel } = useChannel(channelId); + const { user } = useCurrentUser(); + const member = [...publicChannels, ...privateChannels, ...directChannels].find( + c => c.id === channelId, + )?.user_member; + return { + member, + favorite: favoriteChannels?.find(favoriteChannel => favoriteChannel.id === channel?.id), + setFavorite: async (favorite: boolean) => { + await ChannelCurrentMemberAPIClient.updateChannelMemberPreferences( + member as ChannelMemberType, + { favorite }, + { + companyId: channel.company_id || '', + workspaceId: channel.workspace_id || '', + channelId: channel.id || '', + userId: user?.id || '', + }, + ); + await refreshAllChannels(); + }, + setNotificationPreference: async (preference: 'all' | 'none' | 'mentions' | 'me') => { + if (channel.company_id && channel.workspace_id && channel.id && user?.id) { + await ChannelCurrentMemberAPIClient.updateChannelMemberPreferences( + member as ChannelMemberType, + { notification_level: preference }, + { + companyId: channel.company_id, + workspaceId: channel.workspace_id, + channelId: channel.id, + userId: user?.id, + }, + ); + await refreshAllChannels(); + } + }, + }; +} + export function useChannelMember(userId: string, params?: ParamsChannelMember) { const channelId = params?.channelId ? params.channelId : useRouterChannel(); const workspaceId = params?.workspaceId ? params.workspaceId : useRouterWorkspace(); diff --git a/twake/frontend/src/app/features/channel-members/api/channel-members-api-client.ts b/twake/frontend/src/app/features/channel-members/api/channel-members-api-client.ts index e2d337fd17..5051b43803 100644 --- a/twake/frontend/src/app/features/channel-members/api/channel-members-api-client.ts +++ b/twake/frontend/src/app/features/channel-members/api/channel-members-api-client.ts @@ -11,7 +11,7 @@ type ChannelMembersSaveRequest = { resource: Partial }; type ChannelMembersSaveResponse = { resource: ChannelMemberType }; @TwakeService('ChannelMembersAPIClientService') -class ChannelMembersAPIClientService { +class ChannelCurrentMemberAPIClientService { private readonly prefix = '/internal/services/channels/v1/companies'; private realtime: Map< { companyId: string; workspaceId: string; channelId: string }, @@ -75,13 +75,15 @@ class ChannelMembersAPIClientService { /** * Get the channel members read sections. - * + * * @param context - channel members read sections context * @returns {Promise} */ - async getChannelMembersReadSections( - context: { companyId: string; workspaceId: string; channelId: string }, - ): Promise { + async getChannelMembersReadSections(context: { + companyId: string; + workspaceId: string; + channelId: string; + }): Promise { return Api.get<{ resources: ChannelMemberReadSectionType[] }>( `${this.prefix}/${context.companyId}/workspaces/${context.workspaceId}/channels/${context.channelId}/members/read_sections`, ).then(({ resources }) => resources); @@ -89,18 +91,21 @@ class ChannelMembersAPIClientService { /** * Get the read sections for a specific member. - * + * * @param context - channel member read sections context * @returns {Promise} */ - async getChannelMemberReadSection( - context: { companyId: string; workspaceId: string; channelId: string; userId: string }, - ): Promise { + async getChannelMemberReadSection(context: { + companyId: string; + workspaceId: string; + channelId: string; + userId: string; + }): Promise { return Api.get<{ resource: ChannelMemberReadSectionType }>( `${this.prefix}/${context.companyId}/workspaces/${context.workspaceId}/channels/${context.channelId}/members/${context.userId}/read_sections`, ).then(({ resource }) => resource); } } -const ChannelMembersAPIClient = new ChannelMembersAPIClientService(); +const ChannelCurrentMemberAPIClient = new ChannelCurrentMemberAPIClientService(); -export default ChannelMembersAPIClient; +export default ChannelCurrentMemberAPIClient; diff --git a/twake/frontend/src/app/features/channels/hooks/use-channel.ts b/twake/frontend/src/app/features/channels/hooks/use-channel.ts index 38b8bcf290..2dcf4932d9 100644 --- a/twake/frontend/src/app/features/channels/hooks/use-channel.ts +++ b/twake/frontend/src/app/features/channels/hooks/use-channel.ts @@ -65,6 +65,10 @@ export const useIsChannelMember = (channelId: string) => { return !!useChannel(channelId)?.channel?.user_member?.user_id; }; +export const useIsReadOnlyChannel = (channelId: string) => { + return useChannel(channelId)?.channel?.is_readonly; +}; + export function getChannel(channelId: string) { return channelsKeeper.find(ch => ch.id === channelId); } diff --git a/twake/frontend/src/app/features/channels/hooks/use-favorite-channels.ts b/twake/frontend/src/app/features/channels/hooks/use-favorite-channels.ts index f2df875b13..e8b94e4e1b 100644 --- a/twake/frontend/src/app/features/channels/hooks/use-favorite-channels.ts +++ b/twake/frontend/src/app/features/channels/hooks/use-favorite-channels.ts @@ -6,7 +6,7 @@ import { } from './use-public-or-private-channels'; export function useRefreshFavoriteChannels(): { - refresh: () => void; + refresh: () => Promise; } { const { refresh: refreshPublicOrPrivateChannels } = useRefreshPublicOrPrivateChannels(); const { refresh: refreshDirectChannels } = useRefreshDirectChannels(); @@ -21,7 +21,7 @@ export function useRefreshFavoriteChannels(): { export function useFavoriteChannels(): { favoriteChannels: ChannelType[]; - refresh: () => void; + refresh: () => Promise; } { const { publicChannels, privateChannels } = usePublicOrPrivateChannels(); const { directChannels } = useDirectChannels(); diff --git a/twake/frontend/src/app/features/channels/types/channel.ts b/twake/frontend/src/app/features/channels/types/channel.ts index 4aad1ff978..a095a67217 100644 --- a/twake/frontend/src/app/features/channels/types/channel.ts +++ b/twake/frontend/src/app/features/channels/types/channel.ts @@ -34,6 +34,7 @@ export type ChannelType = { messages: number; }; users?: UserType[]; + is_readonly?: boolean; }; export const createDirectChannelFromUsers = (companyId: string, users: UserType[]): ChannelType => { diff --git a/twake/frontend/src/app/features/companies/types/company.ts b/twake/frontend/src/app/features/companies/types/company.ts index 9ee4418cb0..ceed04a935 100644 --- a/twake/frontend/src/app/features/companies/types/company.ts +++ b/twake/frontend/src/app/features/companies/types/company.ts @@ -12,11 +12,13 @@ export type CompanyBillingObjectType = { export enum CompanyLimitsEnum { CHAT_MESSAGE_HISTORY_LIMIT = 'chat:message_history_limit', COMPANY_MEMBERS_LIMIT = 'company:members_limit', // 100 + COMPANY_GUESTS_LIMIT = 'company:guests_limit' } export type CompanyLimitsObjectType = { [CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: number; [CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: number; + [CompanyLimitsEnum.COMPANY_GUESTS_LIMIT]: number; }; export type CompanyStatsObjectType = { diff --git a/twake/frontend/src/app/features/global/utils/strings.ts b/twake/frontend/src/app/features/global/utils/strings.ts index add81e8d58..9ce5a257ff 100755 --- a/twake/frontend/src/app/features/global/utils/strings.ts +++ b/twake/frontend/src/app/features/global/utils/strings.ts @@ -1,5 +1,18 @@ import { isString } from 'lodash'; +export const getBase64 = (file: File): Promise => { + return new Promise((result, fail) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = function () { + result(`${reader.result}`); + }; + reader.onerror = function (error) { + fail(error); + }; + }); +}; + export default class Strings { static verifyMail(email: string) { const re = diff --git a/twake/frontend/src/app/features/invitation/api/invitation-api-client.ts b/twake/frontend/src/app/features/invitation/api/invitation-api-client.ts new file mode 100644 index 0000000000..bbe07f4963 --- /dev/null +++ b/twake/frontend/src/app/features/invitation/api/invitation-api-client.ts @@ -0,0 +1,108 @@ +import Api from 'app/features/global/framework/api-service'; +import { TwakeService } from 'app/features/global/framework/registry-decorator-service'; +import { InvitationType as InvitationRoleType, InvitedUser } from '../state/invitation'; +import Logger from 'app/features/global/framework/logger-service'; + +type InvitationType = { + email: string; + role: string; + company_role: InvitationRoleType; +}; + +type WorkspaceUserInvitationResponseType = { + email: string; + status: 'ok' | 'error'; + message?: string; +}; + +type WorkspaceUserInvitationPayloadType = { + invitations: InvitationType[]; + channels: string[]; +}; + +type WorkspaceInvitationTokenResponseType = { + resource: { + token: string; + }; +}; + +type WorkspaceInvitationTokenPayloadType = { + channels: string[]; +}; + +@TwakeService('InvitationApiClientService') +class InvitationApiClient { + private readonly apiBaseUrl: string = '/internal/services/workspaces/v1/companies'; + logger: Logger.Logger; + + constructor() { + this.logger = Logger.getLogger('Invitation'); + } + + /** + * Creates an invitation token to the workspace + * + * @param {String} companyId - the company + * @param {String} workspaceId - the workspace to be invited in. + * @param {String[]} channels - the channels to be invited in. + * @returns {String} - the invitation token. + */ + async createInvitationToken( + companyId: string, + workspaceId: string, + channels: string[], + ): Promise { + return Api.post( + `${this.apiBaseUrl}/${companyId}/workspaces/${workspaceId}/users/tokens`, + { channels }, + ).then(({ resource }) => resource.token); + } + + /** + * Invite users to workspace by email + * + * @param {String} companyId - the company id. + * @param {String} workspaceId - the workspace id. + * @param {InvitedUser[]} invitedUsers - the array of emails to invite with their roles. + */ + async inviteToWorkspace(companyId: string, workspaceId: string, invitedUsers: InvitedUser[], channels: string[]) { + const response = await Api.post< + WorkspaceUserInvitationPayloadType, + { resources: WorkspaceUserInvitationResponseType[] } + >(`${this.apiBaseUrl}/${companyId}/workspaces/${workspaceId}/users/invite`, { + invitations: [ + ...invitedUsers.map(({ email, role }) => ({ + email, + role: 'member', + company_role: role, + })), + ], + channels, + }); + + if (!response.resources || !response.resources.length) { + this.logger.error('Failed to invite users to the workspace'); + throw Error('Failed to invite users to the workspace'); + } + + if (response.resources.filter(({ message }) => message && message.includes('403')).length) { + this.logger.error('No access rights to invite to this company'); + throw Error('Failed to invite users: No access rights to invite to this company'); + } + + if (response.resources.filter(({ status }) => status === 'error').length === response.resources.length) { + this.logger.error('Failed to invite users to the company'); + throw Error('Failed to invite users'); + } + + response.resources + .filter(({ status }) => status === 'error') + .forEach(({ email, message }) => { + this.logger.error(`Failed to invite ${email}: ${message}`); + }); + + return response; + } +} + +export default new InvitationApiClient(); diff --git a/twake/frontend/src/app/features/invitation/hooks/use-invitation-channels.ts b/twake/frontend/src/app/features/invitation/hooks/use-invitation-channels.ts new file mode 100644 index 0000000000..beb971915e --- /dev/null +++ b/twake/frontend/src/app/features/invitation/hooks/use-invitation-channels.ts @@ -0,0 +1,30 @@ +import { usePublicOrPrivateChannels } from 'app/features/channels/hooks/use-public-or-private-channels'; +import { ChannelType } from 'app/features/channels/types/channel'; +import { uniqBy } from 'lodash'; +import { useEffect } from 'react'; +import { useRecoilState } from 'recoil'; +import { invitationChannelListState, invitationChannelSelectionState } from '../state/invitation'; + +export const useInvitationChannels = () => { + const [channels, setSelectedChannels] = useRecoilState(invitationChannelListState); + const [open, setOpen] = useRecoilState(invitationChannelSelectionState); + const { publicChannels } = usePublicOrPrivateChannels(); + const defaultChannels = (publicChannels || []).filter(({ is_default }) => is_default); + + const setChannels = (channels: ChannelType[]): void => { + setSelectedChannels(uniqBy([...channels, ...defaultChannels], 'id')); + }; + + useEffect(() => { + setSelectedChannels(uniqBy([...channels, ...defaultChannels], 'id')); + }, []); + + return { + openSelection: () => setOpen(true), + closeSelection: () => setOpen(false), + setChannels, + selectedChannels: channels, + open, + reset: () => setSelectedChannels(defaultChannels), + }; +}; diff --git a/twake/frontend/src/app/features/invitation/hooks/use-invitation-users.ts b/twake/frontend/src/app/features/invitation/hooks/use-invitation-users.ts new file mode 100644 index 0000000000..3ffea14208 --- /dev/null +++ b/twake/frontend/src/app/features/invitation/hooks/use-invitation-users.ts @@ -0,0 +1,71 @@ +import { useCurrentCompany } from 'app/features/companies/hooks/use-companies'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; +import { useEffect, useState } from 'react'; +import { useRecoilState } from 'recoil'; +import { invitationEmailsState, InvitationType, invitationTypeState } from '../state/invitation'; + +export const useInvitationUsers = () => { + const { company } = useCurrentCompany(); + const [invitedUsers, setInvitedUsers] = useRecoilState(invitationEmailsState); + const [role] = useRecoilState(invitationTypeState); + const { user: currentUser } = useCurrentUser(); + const currentUserEmailDomain = currentUser?.email.split('@').pop(); + const emailRegex = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; + + const [allowedMembers, setAllowedMembers] = useState( + company.plan?.limits?.['company:members_limit'] === -1 + ? Infinity + : company.plan?.limits?.['company:members_limit'] || Infinity, + ); + const [allowedGuests, setAllowedGuests] = useState( + company.plan?.limits?.['company:guests_limit'] === -1 + ? Infinity + : company.plan?.limits?.['company:guests_limit'] || Infinity, + ); + const [canAddInvitations, setCanAddInvitations] = useState( + role === InvitationType.member ? allowedMembers > 0 : allowedGuests > 0, + ); + const [membersLimitReached, setMembersLimitReached] = useState(false); + + useEffect(() => { + const invitedMembers = invitedUsers.filter(({ role }) => role === InvitationType.member); + const invitedGuests = invitedUsers.filter(({ role }) => role === InvitationType.guest); + + setAllowedMembers( + (company.plan?.limits?.['company:members_limit'] === -1 + ? Infinity + : company.plan?.limits?.['company:members_limit'] || Infinity) - invitedMembers.length, + ); + setAllowedGuests( + (company.plan?.limits?.['company:guests_limit'] === -1 + ? Infinity + : company.plan?.limits?.['company:guests_limit'] || Infinity) - invitedGuests.length, + ); + + if (role === InvitationType.member) { + setMembersLimitReached(allowedMembers <= 0); + } else { + setMembersLimitReached(false); + } + + setCanAddInvitations(role === InvitationType.member ? allowedMembers > 0 : allowedGuests > 0); + }, [company.plan?.limits, invitedUsers, role]); + + const addInvitation = (email: string): void => { + if (!emailRegex.test(email)) return; + if (email.split('@').pop() !== currentUserEmailDomain) return; + if (role === InvitationType.member && allowedMembers <= 0) return; + if (role === InvitationType.guest && allowedGuests <= 0) return; + if (invitedUsers.find(invitation => invitation.email === email)) return; + + setInvitedUsers([...invitedUsers, { email, role }]); + }; + + return { + allowed_members: allowedMembers, + allowed_guests: allowedGuests, + addInvitation, + can_add_invitations: canAddInvitations, + members_limit_reached: membersLimitReached, + }; +}; diff --git a/twake/frontend/src/app/features/invitation/hooks/use-invitation.ts b/twake/frontend/src/app/features/invitation/hooks/use-invitation.ts new file mode 100644 index 0000000000..2af313178b --- /dev/null +++ b/twake/frontend/src/app/features/invitation/hooks/use-invitation.ts @@ -0,0 +1,54 @@ +import { ToasterService } from 'app/features/global/services/toaster-service'; +import useRouterCompany from 'app/features/router/hooks/use-router-company'; +import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace'; +import { useRecoilState } from 'recoil'; +import invitationApiClient from '../api/invitation-api-client'; +import { invitationChannelListState, invitationEmailsState, invitationSentState } from '../state/invitation'; +import { useInvitationChannels } from './use-invitation-channels'; + +export const useInvitation = () => { + const companyId = useRouterCompany(); + const workspaceId = useRouterWorkspace(); + const [invitedUsers, setInvitedUsers] = useRecoilState(invitationEmailsState); + const [channels] = useRecoilState(invitationChannelListState); + const [, setSentState] = useRecoilState(invitationSentState); + const { reset: resetChannels } = useInvitationChannels(); + + const send = async () => { + try { + await invitationApiClient.inviteToWorkspace( + companyId, + workspaceId, + invitedUsers, + channels.map(channel => channel.id as string), + ); + setSentState(true); + } + catch(error) { + ToasterService.error((error as Error).toString()); + } + } + + const generateInvitationLink = async () => { + try { + const token = await invitationApiClient.createInvitationToken( + companyId, + workspaceId, + channels.map(channel => channel.id as string), + ); + + return `${window.location.origin}/?join=${token}`; + } + catch(error) { + ToasterService.error((error as Error).toString()); + } + } + + const reset = () => { + setSentState(false); + setInvitedUsers([]); + resetChannels(); + } + + return { send, generateInvitationLink, reset }; +}; diff --git a/twake/frontend/src/app/features/invitation/state/invitation.ts b/twake/frontend/src/app/features/invitation/state/invitation.ts new file mode 100644 index 0000000000..ba2aa87287 --- /dev/null +++ b/twake/frontend/src/app/features/invitation/state/invitation.ts @@ -0,0 +1,47 @@ +import { ChannelType } from 'app/features/channels/types/channel'; +import { atom } from 'recoil'; + +export enum InvitationType { + guest = "guest", + member = "member", +} + +export type InvitedUser = { + email: string, + role: InvitationType +} + +export const invitationState = atom({ + default: false, + key: 'invitationState', +}); + +export const invitationActiveTab = atom({ + default: 0, + key: 'invitationActiveTabState', +}); + +export const invitationTypeState = atom({ + default: InvitationType.member, + key: 'invitationTypeState', +}); + +export const invitationEmailsState = atom({ + default: [], + key: 'invitationEmailsState', +}); + +export const invitationChannelSelectionState = atom({ + default: false, + key: 'invitationChannelSelectionState', +}); + +export const invitationChannelListState = atom({ + default: [], + key: 'invitationChannelListState', +}); + +export const invitationSentState = atom({ + default: false, + key: 'invitationSentState', +}); diff --git a/twake/frontend/src/app/features/messages/types/message.ts b/twake/frontend/src/app/features/messages/types/message.ts index 32122650d0..220100cbe8 100644 --- a/twake/frontend/src/app/features/messages/types/message.ts +++ b/twake/frontend/src/app/features/messages/types/message.ts @@ -229,6 +229,11 @@ export type NodeMessage = { quote_message?: NodeMessage & { users?: UserType[]; + company_id: string; + workspace_id: string; + channel_id: string; + thread_id: string; + id: string; }; status?: MessageDeliveryStatusType | null; @@ -261,4 +266,4 @@ export type MessageSeenType = { thread_id: string; }[]; channel_id: string; -} +}; diff --git a/twake/frontend/src/app/features/users/services/current-user-service.ts b/twake/frontend/src/app/features/users/services/current-user-service.ts index bf81b67fcf..1e527cceac 100755 --- a/twake/frontend/src/app/features/users/services/current-user-service.ts +++ b/twake/frontend/src/app/features/users/services/current-user-service.ts @@ -6,6 +6,8 @@ import { UserType } from 'app/features/users/types/user'; import { TwakeService } from 'app/features/global/framework/registry-decorator-service'; import { addApiUrlIfNeeded, getAsFrontUrl } from 'app/features/global/utils/URLUtils'; import { getUser } from '../hooks/use-user-list'; +import CryptoJS from 'crypto-js'; +import { getGradient } from 'app/atoms/avatar'; type SearchQueryType = { searching: boolean; @@ -64,13 +66,9 @@ class User { if (user && (user.thumbnail || user.picture)) { thumbnail = addApiUrlIfNeeded(user.picture || user.thumbnail || ''); } else { - let output = 0; - const string = user?.id || ''; - for (let i = 0; i < string.length; i++) { - output += string[i].charCodeAt(0); - } - const i = output % 100; - thumbnail = getAsFrontUrl(`/public/identicon/${i}.png`); + //Generate gradient thumbnail + //TODO: move me to backend ? + thumbnail = getGradient(user.username); } if (user.deleted) { diff --git a/twake/frontend/src/app/features/workspaces/api/workspace-api-client.ts b/twake/frontend/src/app/features/workspaces/api/workspace-api-client.ts index 75d660b11e..90f92fe62e 100644 --- a/twake/frontend/src/app/features/workspaces/api/workspace-api-client.ts +++ b/twake/frontend/src/app/features/workspaces/api/workspace-api-client.ts @@ -3,7 +3,7 @@ import { CompanyType } from 'app/features/companies/types/company'; import { WorkspaceType } from 'app/features/workspaces/types/workspace'; import { TwakeService } from '../../global/framework/registry-decorator-service'; import { WebsocketRoom } from '../../global/types/websocket-types'; -import _ from 'lodash'; +import _, { result } from 'lodash'; const PREFIX = '/internal/services/workspaces/v1/companies'; @@ -13,6 +13,14 @@ export type UpdateWorkspaceBody = { resource: WorkspaceUpdateResource; }; +export type UpdateWorkspaceInviteDomainBody = { + domain: string; +} + +type UpdateWorkspaceInviteDomainResponse = { + status: string; +} + @TwakeService('WorkspaceAPIClientService') class WorkspaceAPIClient { private realtime: Map = new Map(); @@ -108,6 +116,16 @@ class WorkspaceAPIClient { `/internal/services/users/v1/users/${userId}/companies`, ).then(result => result.resources); } + + setInvitationDomain = async (companyId: string, workspaceId: string, domain: string): Promise => { + const response = await Api.post( + `${PREFIX}/${companyId}/workspaces/${workspaceId}/invite_domain`, { domain } + ); + + if(response.status !== "success") { + throw Error("failed to set invitation domain") + } + } } export default new WorkspaceAPIClient(); diff --git a/twake/frontend/src/app/features/workspaces/types/workspace.ts b/twake/frontend/src/app/features/workspaces/types/workspace.ts index c36432ebb2..a6f944f955 100644 --- a/twake/frontend/src/app/features/workspaces/types/workspace.ts +++ b/twake/frontend/src/app/features/workspaces/types/workspace.ts @@ -13,6 +13,7 @@ export type WorkspaceType = { created_at: Date; total_members: number; }; + preferences: WorkspaceInviteDomainType | null; }; export type WorkspaceUserRole = 'moderator' | 'member'; @@ -29,3 +30,7 @@ export type WorkspacePendingUserType = { role: 'member' | 'moderator'; email: string; }; + +export type WorkspaceInviteDomainType = { + invite_domain: string | null; +} diff --git a/twake/frontend/src/app/molecules/grouped-rows/base/index.tsx b/twake/frontend/src/app/molecules/grouped-rows/base/index.tsx index 710952810f..877502d39b 100644 --- a/twake/frontend/src/app/molecules/grouped-rows/base/index.tsx +++ b/twake/frontend/src/app/molecules/grouped-rows/base/index.tsx @@ -16,7 +16,7 @@ export default function Block(props: BlockProps) { const className = props.className || ''; return ( -
+
{props.avatar}
diff --git a/twake/frontend/src/app/molecules/quoted-content/index.tsx b/twake/frontend/src/app/molecules/quoted-content/index.tsx index afd29dd7c8..d07abb1aa7 100644 --- a/twake/frontend/src/app/molecules/quoted-content/index.tsx +++ b/twake/frontend/src/app/molecules/quoted-content/index.tsx @@ -8,18 +8,74 @@ import { import { Base } from 'app/atoms/text'; import fileUploadApiClient from 'app/features/files/api/file-upload-api-client'; import Languages from 'app/features/global/services/languages-service'; -import { MessageWithReplies } from 'app/features/messages/types/message'; -import PossiblyPendingAttachment from 'app/views/applications/messages/message/parts/PossiblyPendingAttachment'; -import React from 'react'; -import { Image } from 'react-feather'; +import { useMessage, useSetMessage } from 'app/features/messages/hooks/use-message'; +import { NodeMessage } from 'app/features/messages/types/message'; +import { MessageContext } from 'app/views/applications/messages/message/message-with-replies'; +import MessageContent from 'app/views/applications/messages/message/parts/MessageContent'; +import React, { useContext, useEffect } from 'react'; type PropsType = { - message: MessageWithReplies; + message: NodeMessage['quote_message']; +}; + +export const useQuotedMessage = ( + message: NodeMessage, + context: { + companyId: string; + workspaceId: string; + channelId: string; + threadId: string; + id: string; + }, +) => { + const quotedMessageFromStore = useMessage({ + ...context, + threadId: message.quote_message?.thread_id as string, + id: message.quote_message?.id as string, + }).message; + + const quotedMessage = { + ...message.quote_message, + ...(quotedMessageFromStore || message.quote_message), + } as NodeMessage['quote_message']; + + const setMessage = useSetMessage(quotedMessage?.company_id || context.companyId); + useEffect(() => { + if (!quotedMessageFromStore?.id && quotedMessage?.id) { + setMessage(quotedMessage); + } + }, []); + + return quotedMessage; }; export default ({ message }: PropsType): React.ReactElement => { + const context = useContext(MessageContext); + + if (!message) { + return <>; + } + const attachmentType = fileUploadApiClient.mimeToType(message.files?.[0]?.metadata?.mime || ''); + if (message.channel_id !== context.channelId) { + return ( + <> + + + + + ); + } + return ( <> {message.text && message.text.length ? ( diff --git a/twake/frontend/src/app/molecules/tabs/index.tsx b/twake/frontend/src/app/molecules/tabs/index.tsx index 9bad985fff..2b4e3362f2 100644 --- a/twake/frontend/src/app/molecules/tabs/index.tsx +++ b/twake/frontend/src/app/molecules/tabs/index.tsx @@ -5,6 +5,7 @@ interface TabsProps extends React.InputHTMLAttributes { tabs: JSX.Element[] | string[]; selected: number; onClick: (idx: number) => void; + parentClassName?: string; } const defaultTabClassName = @@ -19,11 +20,12 @@ export default function Tab(props: TabsProps) { return ( <> -
+
{props.tabs.map((tab, idx) => { const cl = defaultTabClassName + - (idx === props.selected ? activeTabClassName : inactiveTabClassName); + (idx === props.selected ? activeTabClassName : inactiveTabClassName) + + props.parentClassName; return (
props.onClick(idx)}> {tab} diff --git a/twake/frontend/src/app/styles/ui.less b/twake/frontend/src/app/styles/ui.less index 848ad900d5..0011fb451b 100755 --- a/twake/frontend/src/app/styles/ui.less +++ b/twake/frontend/src/app/styles/ui.less @@ -1230,3 +1230,7 @@ FORMS -webkit-hyphens: auto; hyphens: auto; } + +.MuiInputBase-input, .MuiInputBase-input:hover, .MuiInputBase-input:focus { + --tw-ring-shadow: 0 0 #000 !important; +} diff --git a/twake/frontend/src/app/views/applications/calendar/calendar-content.js b/twake/frontend/src/app/views/applications/calendar/calendar-content.js index 178bcdcdc0..c758bc64ab 100755 --- a/twake/frontend/src/app/views/applications/calendar/calendar-content.js +++ b/twake/frontend/src/app/views/applications/calendar/calendar-content.js @@ -24,7 +24,7 @@ import WorkspacesService from 'app/deprecated/workspaces/workspaces.js'; import popupManager from 'app/deprecated/popupManager/popupManager.js'; import ConnectorsListManager from 'components/connectors-list-manager/connectors-list-manager.js'; import WorkspaceUserRights from 'app/features/workspaces/services/workspace-user-rights-service'; -import Checkbox from 'components/inputs/checkbox.js'; +import Checkbox from 'app/components/inputs/deprecated_checkbox.js'; import InputWithClipBoard from 'components/input-with-clip-board/input-with-clip-board.js'; import Select from 'components/select/select.js'; import WorkspaceParameter from 'app/views/client/popup/WorkspaceParameter/WorkspaceParameter.js'; diff --git a/twake/frontend/src/app/views/applications/calendar/modals/Part/DateSelector.js b/twake/frontend/src/app/views/applications/calendar/modals/Part/DateSelector.js index 2dd5d6a85d..ca07cc27c2 100755 --- a/twake/frontend/src/app/views/applications/calendar/modals/Part/DateSelector.js +++ b/twake/frontend/src/app/views/applications/calendar/modals/Part/DateSelector.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import DateSelectorInput from 'components/calendar/date-picker.js'; import TimeSelector from 'components/calendar/time-selector.js'; -import Checkbox from 'components/inputs/checkbox.js'; +import Checkbox from 'app/components/inputs/deprecated_checkbox.js'; import Icon from 'components/icon/icon.js'; import './DateSelector.scss'; import Languages from 'app/features/global/services/languages-service'; diff --git a/twake/frontend/src/app/views/applications/drive/viewer/drive-deprecated-viewer.tsx b/twake/frontend/src/app/views/applications/drive/viewer/drive-deprecated-viewer.tsx index 071547f074..6f04c52ccd 100755 --- a/twake/frontend/src/app/views/applications/drive/viewer/drive-deprecated-viewer.tsx +++ b/twake/frontend/src/app/views/applications/drive/viewer/drive-deprecated-viewer.tsx @@ -67,13 +67,13 @@ export default class Viewer extends Component { Collections.get('drive').removeListener(this); DriveService.removeListener(this); } - openFile(app: AppType) { - if (app.url && app.is_url_file) { - window.open(app.url); + openFile(editor: AppType | { app: AppType }) { + if ((editor as AppType).url && (editor as AppType).is_url_file) { + window.open((editor as AppType).url); } DriveService.getFileUrlForEdition( - app.display?.twake?.files?.editor?.edition_url, - app, + editor.app.display?.twake?.files?.editor?.edition_url, + editor.app, this.viewed_document.id, (url: string) => window.open(url), ); @@ -142,7 +142,7 @@ export default class Viewer extends Component { > {Languages.t( 'scenes.apps.drive.viewer.edit_with_button', - [editor_candidate[0].name], + [editor_candidate[0]?.app?.identity?.name], 'Editer avec $1', )} @@ -152,7 +152,7 @@ export default class Viewer extends Component { menu={editor_candidate.map((editor: { [key: string]: any }) => { return { type: 'menu', - text: editor?.app?.identity?.name || editor?.app?.name || editor.name, + text: editor?.app?.identity?.name, onClick: () => { this.openFile(editor); }, diff --git a/twake/frontend/src/app/views/applications/messages/message/parts/MessageAttachments.tsx b/twake/frontend/src/app/views/applications/messages/message/parts/MessageAttachments.tsx index 665650d071..85aff423d4 100644 --- a/twake/frontend/src/app/views/applications/messages/message/parts/MessageAttachments.tsx +++ b/twake/frontend/src/app/views/applications/messages/message/parts/MessageAttachments.tsx @@ -1,10 +1,10 @@ -import React, { useContext, useEffect } from 'react'; -import 'moment-timezone'; import { Row } from 'antd'; -import { MessageContext } from '../message-with-replies'; +import { useUploadZones } from 'app/features/files/hooks/use-upload-zones'; import { useMessage } from 'app/features/messages/hooks/use-message'; +import 'moment-timezone'; +import { useContext, useEffect } from 'react'; +import { MessageContext } from '../message-with-replies'; import PossiblyPendingAttachment from './PossiblyPendingAttachment'; -import { useUploadZones } from 'app/features/files/hooks/use-upload-zones'; export default () => { const context = useContext(MessageContext); diff --git a/twake/frontend/src/app/views/applications/messages/message/parts/MessageContent.tsx b/twake/frontend/src/app/views/applications/messages/message/parts/MessageContent.tsx index 9662b0d235..4712a67737 100644 --- a/twake/frontend/src/app/views/applications/messages/message/parts/MessageContent.tsx +++ b/twake/frontend/src/app/views/applications/messages/message/parts/MessageContent.tsx @@ -1,29 +1,31 @@ -import React, { useContext, useEffect, useState } from 'react'; -import 'moment-timezone'; -import classNames from 'classnames'; -import Reactions from './Reactions'; -import Options from './Options'; -import MessageHeader from './MessageHeader'; import WorkspacesApps from 'app/deprecated/workspaces/workspaces_apps.js'; -import MessageEdition from './MessageEdition'; -import DeletedContent from './DeletedContent'; -import RetryButtons from './RetryButtons'; -import { MessageContext } from '../message-with-replies'; +import { useChannel, useIsChannelMember } from 'app/features/channels/hooks/use-channel'; +import PseudoMarkdownCompiler from 'app/features/global/services/pseudo-markdown-compiler-service'; import { useMessage } from 'app/features/messages/hooks/use-message'; -import Blocks from './Blocks'; import { useVisibleMessagesEditorLocation } from 'app/features/messages/hooks/use-message-editor'; -import { ViewContext } from 'app/views/client/main-view/MainContent'; -import MessageAttachments from './MessageAttachments'; -import PseudoMarkdownCompiler from 'app/features/global/services/pseudo-markdown-compiler-service'; -import LinkPreview from './LinkPreview'; -import { useChannel, useIsChannelMember } from 'app/features/channels/hooks/use-channel'; -import MessageQuote from 'app/molecules/message-quote'; +import { MessageWithReplies } from 'app/features/messages/types/message'; +import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace'; import { useUser } from 'app/features/users/hooks/use-user'; import User from 'app/features/users/services/current-user-service'; -import { gotoMessage } from 'src/utils/messages'; -import useRouterWorkspace from 'app/features/router/hooks/use-router-workspace'; -import QuotedContent from 'app/molecules/quoted-content'; +import MessageQuote from 'app/molecules/message-quote'; import MessageStatus from 'app/molecules/message-status'; +import QuotedContent, { useQuotedMessage } from 'app/molecules/quoted-content'; +import { ViewContext } from 'app/views/client/main-view/MainContent'; +import classNames from 'classnames'; +import 'moment-timezone'; +import { ReactNode, useContext, useEffect, useState } from 'react'; +import { gotoMessage } from 'src/utils/messages'; +import { MessageContext } from '../message-with-replies'; +import Blocks from './Blocks'; +import DeletedContent from './DeletedContent'; +import LinkPreview from './LinkPreview'; +import MessageForward from './message-forward'; +import MessageAttachments from './MessageAttachments'; +import MessageEdition from './MessageEdition'; +import MessageHeader from './MessageHeader'; +import Options from './Options'; +import Reactions from './Reactions'; +import RetryButtons from './RetryButtons'; type Props = { linkToThread?: boolean; @@ -40,15 +42,12 @@ export default (props: Props) => { const context = useContext(MessageContext); const channelId = context.channelId; const { message } = useMessage(context); - const quotedMessage = useMessage({ - ...context, - threadId: message.quote_message?.thread_id as string, - id: message.quote_message?.id as string, - }).message; + + // Quoted message logic + const quotedMessage = useQuotedMessage(message, context); const { channel } = useChannel(channelId); - const showQuotedMessage = - quotedMessage && quotedMessage.thread_id && channel.visibility === 'direct'; + const showQuotedMessage = quotedMessage && quotedMessage.thread_id; let authorName = ''; const currentRouterWorkspace = useRouterWorkspace(); const workspaceId = @@ -56,7 +55,7 @@ export default (props: Props) => { const deletedQuotedMessage = quotedMessage && quotedMessage.subtype === 'deleted'; if (showQuotedMessage) { - const author = useUser(quotedMessage.user_id); + const author = useUser(quotedMessage.user_id || ''); authorName = author ? User.getFullName(author) : 'Anonymous'; } @@ -117,7 +116,7 @@ export default (props: Props) => { key={`message_container_${message.id}`} > - {showQuotedMessage && !showEdition && ( + {showQuotedMessage && !showEdition && quotedMessage.channel_id === context.channelId && ( { closable={false} deleted={deletedQuotedMessage} goToMessage={() => - gotoMessage(quotedMessage, context.companyId, context.channelId, workspaceId) + gotoMessage( + quotedMessage, + quotedMessage.company_id || context.companyId, + quotedMessage.channel_id || context.channelId, + quotedMessage.workspace_id || workspaceId, + ) + } + /> + )} + {showQuotedMessage && !showEdition && quotedMessage.channel_id !== context.channelId && ( + { + if (isChannelMember) onAction(type, id, context, passives); + }} + className="mb-1" + author={authorName} + message={quotedMessage} + closable={false} + deleted={deletedQuotedMessage} + goToMessage={() => + gotoMessage( + quotedMessage, + quotedMessage.company_id || context.companyId, + quotedMessage.channel_id || context.channelId, + quotedMessage.workspace_id || workspaceId, + ) } /> )} @@ -135,45 +159,25 @@ export default (props: Props) => {
)} {!showEdition && ( -
- {deleted === true ? ( -
- -
- ) : ( + { + if (isChannelMember) onAction(type, id, context, passives); + }} + deleted={deleted} + linkToThread={props.linkToThread} + message={message} + className={classNames({ + message_is_loading: messageIsLoading, + 'message-not-sent': messageSaveFailed, + })} + suffix={ <> -
- {!!props.linkToThread && message.text} - {!props.linkToThread && ( - <> - { - if (isChannelMember) onAction(type, id, context, passives); - }} - allowAdvancedBlocks={message.subtype === 'application'} - /> - - )} -
- {message?.files && (message?.files?.length || 0) > 0 && } - {message?.links && - (message?.links?.length || 0) > 0 && - message.links - .filter(link => link && (link.title || link.description || link.img)) - .map((preview, i) => )} {!messageSaveFailed && } {messageSaveFailed && !messageIsLoading && } - )} -
+ } + /> )} {isChannelMember && !showEdition && @@ -194,3 +198,53 @@ export default (props: Props) => {
); }; + +export const MessageBlockContent = ({ + deleted, + message, + linkToThread, + suffix, + className, + onAction, +}: { + deleted: boolean; + linkToThread?: boolean; + message: MessageWithReplies; + suffix?: ReactNode; + className?: string; + onAction: (type: string, id: string, context: unknown, passives: unknown) => void; +}) => { + return ( +
+ {deleted === true ? ( +
+ +
+ ) : ( + <> +
+ {!!linkToThread && message.text} + {!linkToThread && ( + <> + + + )} +
+ + {message?.links && + (message?.links?.length || 0) > 0 && + message.links + .filter(link => link && (link.title || link.description || link.img)) + .map((preview, i) => )} + + {suffix} + + )} +
+ ); +}; diff --git a/twake/frontend/src/app/views/applications/messages/message/parts/Options.tsx b/twake/frontend/src/app/views/applications/messages/message/parts/Options.tsx index e4849b74db..08efe31093 100644 --- a/twake/frontend/src/app/views/applications/messages/message/parts/Options.tsx +++ b/twake/frontend/src/app/views/applications/messages/message/parts/Options.tsx @@ -30,6 +30,8 @@ import { useMessageQuoteReply } from 'app/features/messages/hooks/use-message-qu import { useMessageSeenBy } from 'app/features/messages/hooks/use-message-seen-by'; import { EmojiSuggestionType } from 'app/components/rich-text-editor/plugins/emoji'; import { MessagesListContext } from '../../messages-list'; +import { useSetRecoilState } from 'recoil'; +import { ForwardMessageAtom } from 'app/components/forward-message'; type Props = { onOpen?: () => void; @@ -58,6 +60,7 @@ export default (props: Props) => { const { set: setVisibleEditor } = useVisibleMessagesEditorLocation(location, subLocation); const { set: setQuoteReply } = useMessageQuoteReply(channelId); + const setForwardMessage = useSetRecoilState(ForwardMessageAtom); const { openSeenBy } = useMessageSeenBy(); @@ -168,6 +171,22 @@ export default (props: Props) => { }, }); + menu.push({ + type: 'menu', + icon: 'envelope-send', + text: Languages.t('scenes.apps.messages.message.forward'), + className: 'option_button', + onClick: () => { + setForwardMessage({ + id: message.id, + thread_id: message.thread_id, + channel_id: channelId, + workspace_id: context.workspaceId, + company_id: context.companyId, + }); + }, + }); + const apps = getCompanyApplications(Groups.currentGroupId).filter( (app: Application) => app.display?.twake?.chat?.actions?.length, diff --git a/twake/frontend/src/app/views/applications/messages/message/parts/message-forward/files.tsx b/twake/frontend/src/app/views/applications/messages/message/parts/message-forward/files.tsx new file mode 100644 index 0000000000..2027996ae7 --- /dev/null +++ b/twake/frontend/src/app/views/applications/messages/message/parts/message-forward/files.tsx @@ -0,0 +1,19 @@ +import { Row } from 'antd'; +import { MessageFileType } from 'app/features/messages/types/message'; +import PossiblyPendingAttachment from '../PossiblyPendingAttachment'; + +export const ForwardedFiles = (props: { files: MessageFileType[] }) => { + return ( + + {props.files + .filter(f => f.metadata) + .map(file => ( + + ))} + + ); +}; diff --git a/twake/frontend/src/app/views/applications/messages/message/parts/message-forward/index.tsx b/twake/frontend/src/app/views/applications/messages/message/parts/message-forward/index.tsx new file mode 100644 index 0000000000..0db5e61cdb --- /dev/null +++ b/twake/frontend/src/app/views/applications/messages/message/parts/message-forward/index.tsx @@ -0,0 +1,78 @@ +import { Info } from 'app/atoms/text'; +import Languages from 'app/features/global/services/languages-service'; +import { XIcon } from '@atoms/icons-agnostic'; +import { NodeMessage } from 'app/features/messages/types/message'; +import { UserType } from 'app/features/users/types/user'; +import { MessageBlockContent } from '../MessageContent'; +import { ForwardedFiles } from './files'; + +type PropsType = { + message: NodeMessage & { + users?: UserType[] | undefined; + company_id: string; + workspace_id: string; + channel_id: string; + thread_id: string; + id: string; + }; + author: string; + closable?: boolean; + deleted?: boolean; + goToMessage?: () => void; + onClose?: () => void; + className?: string; + onAction: (type: string, id: string, context: unknown, passives: unknown) => void; +}; + +export default ({ + author, + message, + closable = true, + onClose, + deleted = false, + goToMessage, + className = '', + onAction, +}: PropsType) => { + const clickable = !closable; + + return ( +
{}} + > +
+
+

{author}

+
+ {deleted ? ( + {Languages.t('molecules.message_quote.deleted')} + ) : ( + {message.files && }} + /> + )} +
+
+ {closable && onClose && ( +
+ { + e.stopPropagation(); + onClose(); + }} + /> +
+ )} +
+ ); +}; diff --git a/twake/frontend/src/app/views/applications/messages/messages.tsx b/twake/frontend/src/app/views/applications/messages/messages.tsx index 35843c6581..12ce06cab8 100644 --- a/twake/frontend/src/app/views/applications/messages/messages.tsx +++ b/twake/frontend/src/app/views/applications/messages/messages.tsx @@ -5,7 +5,11 @@ import NewThread from './input/new-thread'; import MessagesList from './messages-list'; import ThreadMessagesList from './thread-messages-list'; import IsWriting from './input/parts/IsWriting'; -import { useChannel, useIsChannelMember } from 'app/features/channels/hooks/use-channel'; +import { + useChannel, + useIsChannelMember, + useIsReadOnlyChannel, +} from 'app/features/channels/hooks/use-channel'; import { Button } from 'app/atoms/button/button'; import ChannelsReachableAPIClient from 'app/features/channels/api/channels-reachable-api-client'; import UserService from 'app/features/users/services/current-user-service'; @@ -14,6 +18,8 @@ import Languages from 'app/features/global/services/languages-service'; import MessageSeenBy from 'app/components/message-seen-by/message-seen-by'; import { useUser } from 'app/features/users/hooks/use-user'; import { UserType } from 'app/features/users/types/user'; +import { ForwardMessageModal } from 'app/components/forward-message'; +import AccessRightsService from 'app/features/workspace-members/services/workspace-members-access-rights-service'; type Props = { channel: ChannelType; @@ -45,8 +51,15 @@ export default (props: Props) => { userIsNotInCompany = true; } + const channelIsRestricted = + useIsReadOnlyChannel(channelId) && + currentUser.id !== props.channel.owner && + !AccessRightsService.hasLevel(workspaceId, 'moderator'); + return (
+ + }> {!threadId ? ( { - {isChannelMember && !userIsNotInCompany && ( + {isChannelMember && channelIsRestricted && } + {isChannelMember && !channelIsRestricted && !userIsNotInCompany && ( {
); }; + +const ChannelIsRestricted = () => { + return ( +
+ {Languages.t('scenes.client.readonly.info')} +
+ ); +}; diff --git a/twake/frontend/src/app/views/applications/tasks/board/task/TaskEditor.js b/twake/frontend/src/app/views/applications/tasks/board/task/TaskEditor.js index 543508770b..c3a9c65bd5 100755 --- a/twake/frontend/src/app/views/applications/tasks/board/task/TaskEditor.js +++ b/twake/frontend/src/app/views/applications/tasks/board/task/TaskEditor.js @@ -8,7 +8,7 @@ import Menu from 'components/menus/menu.js'; import Input from 'components/inputs/input.js'; import DateSelectorInput from 'components/calendar/date-picker.js'; import TimeSelector from 'components/calendar/time-selector.js'; -import Checkbox from 'components/inputs/checkbox.js'; +import Checkbox from 'app/components/inputs/deprecated_checkbox.js'; import MediumPopupManager from 'app/components/modal/modal-manager'; import Checklist from './parts/Checklist.js'; import TagPicker from 'components/tag-picker/tag-picker.js'; diff --git a/twake/frontend/src/app/views/applications/tasks/board/task/parts/Checklist.js b/twake/frontend/src/app/views/applications/tasks/board/task/parts/Checklist.js index 769574d645..c687dfdccd 100755 --- a/twake/frontend/src/app/views/applications/tasks/board/task/parts/Checklist.js +++ b/twake/frontend/src/app/views/applications/tasks/board/task/parts/Checklist.js @@ -1,6 +1,6 @@ import React from 'react'; import Icon from 'components/icon/icon.js'; -import Checkbox from 'components/inputs/checkbox.js'; +import Checkbox from 'app/components/inputs/deprecated_checkbox.js'; import InputEnter from 'components/inputs/input-enter.js'; import Button from 'components/buttons/button.js'; import Languages from 'app/features/global/services/languages-service'; diff --git a/twake/frontend/src/app/views/applications/viewer/other/controls.tsx b/twake/frontend/src/app/views/applications/viewer/other/controls.tsx index 3f17781686..7757bc4764 100644 --- a/twake/frontend/src/app/views/applications/viewer/other/controls.tsx +++ b/twake/frontend/src/app/views/applications/viewer/other/controls.tsx @@ -42,7 +42,9 @@ export default (props: { name: string }) => { } }} > - {Languages.t('scenes.apps.drive.viewer.edit_with_button', [candidates[0].name])} + {Languages.t('scenes.apps.drive.viewer.edit_with_button', [ + candidates[0].app?.identity.name, + ])} )} {candidates.length > 1 && ( @@ -55,7 +57,7 @@ export default (props: { name: string }) => { candidates.map((editor: { [key: string]: any }) => { return { type: 'menu', - text: editor?.app?.identity?.name || editor?.app?.name || editor.name, + text: editor?.app?.identity?.name, onClick: () => { openFile(editor); }, diff --git a/twake/frontend/src/app/views/client/channels-bar/ChannelsWorkspace/WorkspaceChannel.tsx b/twake/frontend/src/app/views/client/channels-bar/ChannelsWorkspace/WorkspaceChannel.tsx index 65ce97277f..99a34c91ce 100644 --- a/twake/frontend/src/app/views/client/channels-bar/ChannelsWorkspace/WorkspaceChannel.tsx +++ b/twake/frontend/src/app/views/client/channels-bar/ChannelsWorkspace/WorkspaceChannel.tsx @@ -8,8 +8,6 @@ import ModalManager from 'app/components/modal/modal-manager'; import ChannelCategory from '../Parts/Channel/ChannelCategory'; import ChannelIntermediate from '../Parts/Channel/ChannelIntermediate'; -import ChannelWorkspaceEditor from 'app/views/client/channels-bar/Modals/ChannelWorkspaceEditor'; - import Menu from 'components/menus/menu.js'; import Icon from 'app/components/icon/icon'; import AccessRightsService from 'app/features/workspace-members/services/workspace-members-access-rights-service'; @@ -17,6 +15,7 @@ import RouterServices from 'app/features/router/services/router-service'; import { useSearchModal } from 'app/features/search/hooks/use-search'; import { SearchInputState, SearchTabsState } from 'app/features/search/state/search-input'; import { useSetRecoilState } from 'recoil'; +import { useOpenChannelModal } from 'app/components/edit-channel'; type Props = { sectionTitle: string; @@ -27,15 +26,10 @@ type Props = { export default (props: Props) => { const { workspaceId, companyId } = RouterServices.getStateFromRoute(); + const openChannelModal = useOpenChannelModal(); const addChannel = () => { - return ModalManager.open( - , - { - position: 'center', - size: { width: '600px' }, - }, - ); + openChannelModal(''); }; const { setOpen: setSearchopen } = useSearchModal(); diff --git a/twake/frontend/src/app/views/client/channels-bar/Modals/ChannelTemplateEditor.tsx b/twake/frontend/src/app/views/client/channels-bar/Modals/ChannelTemplateEditor.tsx deleted file mode 100755 index 8ca9fd2fa7..0000000000 --- a/twake/frontend/src/app/views/client/channels-bar/Modals/ChannelTemplateEditor.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import Languages from 'app/features/global/services/languages-service'; -import InputWithIcon from 'components/inputs/input-with-icon'; -import { ChannelType } from 'app/features/channels/types/channel'; -import { Select, Typography, Checkbox, Input } from 'antd'; -import InputWithSelect from 'app/components/inputs/input-with-select'; -import RouterServices from 'app/features/router/services/router-service'; -import AccessRightsService from 'app/features/workspace-members/services/workspace-members-access-rights-service'; -import { usePublicOrPrivateChannels } from 'app/features/channels/hooks/use-public-or-private-channels'; - -type PropsType = { - channel: ChannelType | undefined; - onChange: (channelEntries: Partial) => void; - currentUserId?: string; - defaultVisibility?: ChannelType['visibility']; -}; - -const { TextArea } = Input; -const { Option } = Select; -const { Title } = Typography; -const ChannelTemplateEditor = ({ - channel, - onChange, - currentUserId, - defaultVisibility, -}: PropsType) => { - const [icon, setIcon] = useState(channel?.icon || ''); - const [name, setName] = useState(channel?.name || ''); - const [description, setDescription] = useState(channel?.description || ''); - const [visibility, setVisibility] = useState<'public' | 'direct' | 'private'>( - channel?.visibility || defaultVisibility || 'public', - ); - const [defaultChannel, setDefaultChannel] = useState(channel?.is_default || false); - const [group, setGroup] = useState(channel?.channel_group || ''); - const { workspaceId } = RouterServices.getStateFromRoute(); - const { privateChannels, publicChannels } = usePublicOrPrivateChannels(); - useEffect(() => { - onChange({ - icon, - name, - description, - visibility, - channel_group: group, - is_default: defaultChannel, - }); - }); - - const getGroups = () => { - const groupsNames: string[] = []; - [...privateChannels, ...publicChannels] - .sort((a, b) => (a.channel_group || '').localeCompare(b.channel_group || '')) - .forEach(channel => { - if (channel.channel_group && !groupsNames.includes(channel.channel_group)) - groupsNames.push(channel.channel_group); - }); - return groupsNames; - }; - - const isAbleToEditVisibilityOrDefault = () => { - const isNewChannel = !channel; - const editable = - (channel && - channel.id && - (AccessRightsService.hasLevel(workspaceId || '', 'moderator') || - currentUserId === channel.owner)) || - false; - return isNewChannel || editable ? true : false; - }; - - return ( - <> -
- setIcon(value[0])} - > - { - setGroup((values[0] || '').toLocaleUpperCase().trim()); - setName(values[1]); - }} - /> - -
-
-
- - {Languages.t( - 'scenes.app.popup.appsparameters.pages.description_label', - [], - 'Description', - )} - -