├── .nvmrc ├── webapp ├── i18n │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── pl.json │ ├── ro.json │ ├── tr.json │ ├── uk.json │ ├── pt_BR.json │ ├── zh_Hans.json │ ├── zh_Hant.json │ ├── en.json │ ├── cs.json │ ├── nl.json │ ├── de.json │ ├── es.json │ ├── ru.json │ ├── hu.json │ ├── fr.json │ └── index.ts ├── .npmrc ├── tests │ ├── i18n_mock.json │ └── setup.js ├── .gitignore ├── src │ ├── client │ │ ├── index.ts │ │ └── client.ts │ ├── manifest.test.tsx │ ├── action_types │ │ └── index.ts │ ├── components │ │ ├── i18n_provider │ │ │ ├── index.ts │ │ │ └── i18n_provider.tsx │ │ ├── icon.tsx │ │ ├── conference │ │ │ ├── index.ts │ │ │ └── conference.test.tsx │ │ ├── root_portal.tsx │ │ └── post_type_jitsi │ │ │ ├── index.ts │ │ │ ├── post_type_jitsi.test.tsx │ │ │ └── post_type_jitsi.tsx │ ├── utils │ │ ├── date_utils.test.ts │ │ ├── user_utils.ts │ │ ├── date_utils.ts │ │ └── user_utils.test.ts │ ├── types.ts │ ├── reducers │ │ └── index.ts │ ├── constants │ │ └── svgs.ts │ ├── index.tsx │ └── actions │ │ └── index.ts ├── tsconfig.json ├── babel.config.js ├── webpack.config.js ├── package.json └── .eslintrc.json ├── .gitpod.yml ├── .gitattributes ├── assets ├── icon.png ├── i18n │ ├── active.en.json │ ├── active.ru.json │ ├── active.de.json │ ├── active.es.json │ └── active.fr.json └── icon.svg ├── public └── app-bar-icon.png ├── server ├── main.go ├── plugin_test.go ├── configuration.go ├── randomNameGenerator.go ├── command_test.go ├── api.go └── command.go ├── docker-make ├── .github ├── dependabot.yml ├── workflows │ ├── cd.yml │ └── ci.yml └── ISSUE_TEMPLATE │ └── issue.md ├── .gitignore ├── Dockerfile ├── .editorconfig ├── .golangci.yml ├── go.mod ├── CONTRIBUTING.md ├── plugin.json ├── README.md ├── Makefile └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.1 2 | -------------------------------------------------------------------------------- /webapp/i18n/it.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/ja.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/ko.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/pl.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/ro.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/tr.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/uk.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /webapp/i18n/pt_BR.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/zh_Hans.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/i18n/zh_Hant.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/tests/i18n_mock.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .npminstall 3 | junit.xml 4 | .eslintcache 5 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | server/manifest.go linguist-generated=true 2 | webapp/src/manifest.js linguist-generated=true 3 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattermost-community/mattermost-plugin-jitsi/HEAD/assets/icon.png -------------------------------------------------------------------------------- /public/app-bar-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattermost-community/mattermost-plugin-jitsi/HEAD/public/app-bar-icon.png -------------------------------------------------------------------------------- /webapp/src/client/index.ts: -------------------------------------------------------------------------------- 1 | import ClientClass from './client'; 2 | 3 | const Client = new ClientClass(); 4 | 5 | export default Client; 6 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/mattermost/mattermost/server/public/plugin" 4 | 5 | func main() { 6 | plugin.ClientMain(&Plugin{}) 7 | } 8 | -------------------------------------------------------------------------------- /docker-make: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | sudo docker build -t mattermost-plugin-jitsi-builder . 6 | sudo docker run --rm -it -v $(pwd):/src -w /src mattermost-plugin-jitsi-builder make "$@" 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for GOLang dependencies 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | bin/ 3 | webapp/src/manifest.ts 4 | server/manifest.go 5 | 6 | # Mac 7 | .DS_Store 8 | 9 | # VSCode 10 | .vscode 11 | 12 | assets/i18n/translate.*.json 13 | 14 | # Jetbrains 15 | .idea/ 16 | 17 | -------------------------------------------------------------------------------- /webapp/src/manifest.test.tsx: -------------------------------------------------------------------------------- 1 | import manifest from './manifest'; 2 | 3 | test('Plugin manifest, id and version are defined', () => { 4 | expect(manifest).toBeDefined(); 5 | expect(manifest.id).toBeDefined(); 6 | expect(manifest.version).toBeDefined(); 7 | }); 8 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | plugin-cd: 9 | uses: mattermost/actions-workflows/.github/workflows/community-plugin-cd.yml@d9defa3e455bdbf889573e112ad8d05b91d66b4c 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /webapp/tests/setup.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import Enzyme from 'enzyme'; 5 | import Adapter from 'enzyme-adapter-react-16'; 6 | 7 | Enzyme.configure({adapter: new Adapter()}); 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | 9 | jobs: 10 | plugin-ci: 11 | uses: mattermost/actions-workflows/.github/workflows/community-plugin-ci.yml@139a051e8651e6246e3764fe342297b73120e590 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /webapp/src/action_types/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import manifest from '../manifest'; 5 | 6 | const {id: pluginId} = manifest; 7 | 8 | export default { 9 | OPEN_MEETING: pluginId + '_open_meeting', 10 | CONFIG_RECEIVED: pluginId + '_config_received', 11 | USER_STATUS_CHANGED: pluginId + '_user_status_changed' 12 | }; 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21 2 | 3 | RUN apt update && \ 4 | apt -y install build-essential npm && \ 5 | npm install n -g && \ 6 | n v16.13.1 && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | RUN addgroup --gid 1000 node \ 10 | && useradd --create-home --uid 1000 --gid node --shell /bin/sh node 11 | 12 | RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 13 | 14 | USER node 15 | 16 | CMD /bin/sh 17 | -------------------------------------------------------------------------------- /webapp/src/components/i18n_provider/index.ts: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux'; 2 | 3 | import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n'; 4 | import {GlobalState} from 'mattermost-redux/types/store'; 5 | 6 | import {I18nProvider} from './i18n_provider'; 7 | 8 | function mapStateToProps(state: GlobalState) { 9 | return { 10 | currentLocale: getCurrentUserLocale(state) 11 | }; 12 | } 13 | 14 | export default connect(mapStateToProps)(I18nProvider); 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_style = tab 13 | 14 | [*.{js,jsx,json,html}] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [webapp/package.json] 19 | indent_size = 2 20 | 21 | [Makefile,*.mk] 22 | indent_style = tab 23 | 24 | [*.md] 25 | indent_style = space 26 | indent_size = 4 27 | trim_trailing_whitespace = false 28 | 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Mattermost Version** 11 | 12 | **Plugin Version** 13 | 14 | **Describe the issue** 15 | A clear and concise description of what the problem is. 16 | 17 | **Server Error Logs** 18 | 19 | **Google Chrome Error Logs** 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /webapp/src/utils/date_utils.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from '@jest/globals'; 2 | 3 | import {formatDate} from './date_utils'; 4 | 5 | describe('formatDate', () => { 6 | const sampleDate = new Date(2020, 5, 29, 15, 32, 0); 7 | 8 | it('should return a valid formated date with military time', () => { 9 | expect(formatDate(sampleDate, true)).toBe('Jun 29 at 15:32'); 10 | }); 11 | 12 | it('should return a valid formated date without military time', () => { 13 | expect(formatDate(sampleDate, false)).toBe('Jun 29 at 3:32 PM'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /webapp/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Close", 3 | "jitsi.creator-has-started-a-meeting": "{creator} has started a meeting", 4 | "jitsi.default-title": "Jitsi Meeting", 5 | "jitsi.join-meeting": "JOIN MEETING", 6 | "jitsi.link-valid-until": "Meeting link valid until: ", 7 | "jitsi.maximize": "Maximize", 8 | "jitsi.meeting-id": "Meeting ID: ", 9 | "jitsi.minimize": "Minimize", 10 | "jitsi.move-down": "Move down", 11 | "jitsi.move-up": "Move up", 12 | "jitsi.open-in-new-tab": "Open in new tab", 13 | "jitsi.personal-meeting-id": "Personal Meeting ID (PMI): " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Zavřít", 3 | "jitsi.creator-has-started-a-meeting": "{creator} zahájil schůzku", 4 | "jitsi.default-title": "Jitsi Meeting", 5 | "jitsi.join-meeting": "PŘIPOJIT SE KE SCHŮZCE", 6 | "jitsi.link-valid-until": "Odkaz na schůzku platný do: ", 7 | "jitsi.maximize": "Maximalizovat", 8 | "jitsi.meeting-id": "ID schůzky: ", 9 | "jitsi.minimize": "Minimalizovat", 10 | "jitsi.move-down": "Posunout dolů", 11 | "jitsi.move-up": "Posunout nahoru", 12 | "jitsi.open-in-new-tab": "Otevřít v novém tabu", 13 | "jitsi.personal-meeting-id": "ID osobní schůzky (PMI): " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Sluiten", 3 | "jitsi.creator-has-started-a-meeting": "{creator} is een vergadering gestart", 4 | "jitsi.default-title": "Jitsi Vergadering", 5 | "jitsi.join-meeting": "DEELNEMEN", 6 | "jitsi.link-valid-until": "Vergaderlink geldig tot: ", 7 | "jitsi.maximize": "Maximaliseren", 8 | "jitsi.meeting-id": "Vergaderings-ID: ", 9 | "jitsi.minimize": "Minimaliseren", 10 | "jitsi.move-down": "Naar beneden", 11 | "jitsi.move-up": "Omhoog", 12 | "jitsi.open-in-new-tab": "Openen in nieuw tabblad", 13 | "jitsi.personal-meeting-id": "Persoonlijke vergadering-ID: " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Schließen", 3 | "jitsi.creator-has-started-a-meeting": "{creator} hat ein Meeting gestartet", 4 | "jitsi.default-title": "Jitsi Meeting", 5 | "jitsi.join-meeting": "Tritt Meeting bei", 6 | "jitsi.link-valid-until": "Meeting-Link gültig bis: ", 7 | "jitsi.maximize": "Maximieren", 8 | "jitsi.meeting-id": "Meeting ID: ", 9 | "jitsi.minimize": "Minimieren", 10 | "jitsi.move-down": "Nach unten verschieben", 11 | "jitsi.move-up": "Nach oben verschieben", 12 | "jitsi.open-in-new-tab": "In einem neuen Tab öffnen", 13 | "jitsi.personal-meeting-id": "Persönliche Meeting ID: " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Cerrar", 3 | "jitsi.creator-has-started-a-meeting": "{creator} ha iniciado una reunión", 4 | "jitsi.default-title": "Reunión Jitsi", 5 | "jitsi.join-meeting": "UNIRSE A LA REUNIÓN", 6 | "jitsi.link-valid-until": "El enlace a la reunión es válido hasta: ", 7 | "jitsi.maximize": "Maximizar", 8 | "jitsi.meeting-id": "ID de reunión: ", 9 | "jitsi.minimize": "Minimizar", 10 | "jitsi.move-down": "Mover abajo", 11 | "jitsi.move-up": "Mover arriba", 12 | "jitsi.open-in-new-tab": "Abrir en nueva pestaña", 13 | "jitsi.personal-meeting-id": "ID de sala personal de reuniones (PMI): " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Закрыть", 3 | "jitsi.creator-has-started-a-meeting": "{creator} начал совещание", 4 | "jitsi.default-title": "Совещание Jitsi", 5 | "jitsi.join-meeting": "ПРИСОЕДИНИТЬСЯ", 6 | "jitsi.link-valid-until": "Ссылка на совещание действительна до: ", 7 | "jitsi.maximize": "Развернуть", 8 | "jitsi.meeting-id": "Идентификатор совещания: ", 9 | "jitsi.minimize": "Свернуть", 10 | "jitsi.move-down": "Переместить вниз", 11 | "jitsi.move-up": "Переместить вверх", 12 | "jitsi.open-in-new-tab": "Открыть в новой вкладке", 13 | "jitsi.personal-meeting-id": "Идентификатор личной встречи (PMI): " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/hu.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Bezárás", 3 | "jitsi.creator-has-started-a-meeting": "{creator} elindított egy megbeszélést", 4 | "jitsi.default-title": "Jitsi megbeszélés", 5 | "jitsi.join-meeting": "BECSATLAKOZÁS A MEGBESZÉLÉSBE", 6 | "jitsi.link-valid-until": "Megbeszélés linkjének érvényessége: ", 7 | "jitsi.maximize": "Maximalizálás", 8 | "jitsi.meeting-id": "Megbeszélés azonosító: ", 9 | "jitsi.minimize": "Kicsinyítés", 10 | "jitsi.move-down": "Mozgatás fel", 11 | "jitsi.move-up": "Mozgatás le", 12 | "jitsi.open-in-new-tab": "Megnyitás új tabon", 13 | "jitsi.personal-meeting-id": "Személyes megbeszélés azonosító (PMI): " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/i18n/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.close": "Fermer", 3 | "jitsi.creator-has-started-a-meeting": "{creator} a démarré une réunion", 4 | "jitsi.default-title": "Réunion Jitsi", 5 | "jitsi.join-meeting": "REJOINDRE LA RÉUNION", 6 | "jitsi.link-valid-until": "Le lien de la réunion est valide jusqu'au : ", 7 | "jitsi.maximize": "Maximiser", 8 | "jitsi.meeting-id": "Identifiant de réunion : ", 9 | "jitsi.minimize": "Minimiser", 10 | "jitsi.move-down": "Déplacer vers le bas", 11 | "jitsi.move-up": "Déplacer vers le haut", 12 | "jitsi.open-in-new-tab": "Ouvrir dans un nouvel onglet", 13 | "jitsi.personal-meeting-id": "Identifiant de réunion personel (PMI) : " 14 | } 15 | -------------------------------------------------------------------------------- /webapp/src/components/icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Svgs from 'constants/svgs'; 3 | 4 | export default class Icon extends React.PureComponent { 5 | render() { 6 | const style = getStyle(); 7 | return ( 8 | 14 | ); 15 | } 16 | } 17 | 18 | function getStyle(): { [key: string]: React.CSSProperties } { 19 | return { 20 | iconStyle: { 21 | position: 'relative', 22 | top: '-1px' 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /webapp/src/components/i18n_provider/i18n_provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {IntlProvider} from 'react-intl'; 3 | 4 | import {getTranslations} from '../../../i18n'; 5 | 6 | export type Props = { 7 | currentLocale: string, 8 | children: React.ReactNode 9 | } 10 | 11 | export class I18nProvider extends React.PureComponent { 12 | render() { 13 | return ( 14 | 19 | {this.props.children} 20 | 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "strictNullChecks": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "experimentalDecorators": true, 15 | "jsx": "react", 16 | "baseUrl": "./src", 17 | "types": ["jest", "node"] 18 | }, 19 | "include": [ 20 | "src" 21 | ], 22 | "exclude": [ 23 | "dist", 24 | "node_modules", 25 | "!node_modules/@types", 26 | "**/*.test.ts", 27 | "**/*.test.tsx" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /webapp/src/types.ts: -------------------------------------------------------------------------------- 1 | import {GlobalState as ReduxGlobalState} from 'mattermost-redux/types/store'; 2 | import {Post} from 'mattermost-redux/types/posts'; 3 | 4 | export type Config = { 5 | TeammateNameDisplay?: string 6 | } 7 | 8 | export type GlobalState = ReduxGlobalState & { 9 | 'plugins-jitsi': { 10 | openMeeting: Post | null, 11 | openMeetingJwt: string | null, 12 | config: { 13 | embedded?: boolean, 14 | // eslint-disable-next-line camelcase 15 | naming_scheme?: 'ask' | 'words' | 'mattermost' | 'uuid', 16 | // eslint-disable-next-line camelcase 17 | show_prejoin_page: boolean 18 | } 19 | } 20 | } 21 | 22 | export type plugin = 'plugins-jitsi' 23 | -------------------------------------------------------------------------------- /webapp/src/utils/user_utils.ts: -------------------------------------------------------------------------------- 1 | import {getFullName} from 'mattermost-redux/utils/user_utils'; 2 | import {UserProfile} from 'mattermost-redux/types/users'; 3 | 4 | import {Config} from '../types'; 5 | 6 | export function displayUsernameForUser(user: UserProfile | null, config: Config): string { 7 | if (user) { 8 | const nameFormat = config.TeammateNameDisplay; 9 | let name = user.username; 10 | if (nameFormat === 'nickname_full_name' && user.nickname && user.nickname !== '') { 11 | name = user.nickname; 12 | } else if ((user.first_name || user.last_name) && (nameFormat === 'nickname_full_name' || nameFormat === 'full_name')) { 13 | name = getFullName(user); 14 | } 15 | 16 | return name; 17 | } 18 | 19 | return ''; 20 | } 21 | -------------------------------------------------------------------------------- /webapp/src/utils/date_utils.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(date: Date, useMilitaryTime: boolean = false): string { 2 | const monthNames = [ 3 | 'Jan', 'Feb', 'Mar', 4 | 'Apr', 'May', 'Jun', 'Jul', 5 | 'Aug', 'Sep', 'Oct', 6 | 'Nov', 'Dec' 7 | ]; 8 | 9 | const day = date.getDate(); 10 | const monthIndex = date.getMonth(); 11 | let hours = date.getHours(); 12 | const minutes = date.getMinutes(); 13 | 14 | let ampm = ''; 15 | if (!useMilitaryTime) { 16 | ampm = ' AM'; 17 | if (hours >= 12) { 18 | ampm = ' PM'; 19 | } 20 | 21 | hours %= 12; 22 | if (!hours) { 23 | hours = 12; 24 | } 25 | } 26 | 27 | let minutesText = minutes.toString(); 28 | if (minutes < 10) { 29 | minutesText = '0' + minutes; 30 | } 31 | 32 | return monthNames[monthIndex] + ' ' + day + ' at ' + hours + ':' + minutesText + ampm; 33 | } 34 | -------------------------------------------------------------------------------- /webapp/src/components/conference/index.ts: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux'; 2 | import {bindActionCreators, Dispatch} from 'redux'; 3 | 4 | import {GenericAction} from 'mattermost-redux/types/actions'; 5 | import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; 6 | 7 | import {GlobalState, plugin} from 'types'; 8 | import {openJitsiMeeting, setUserStatus} from 'actions'; 9 | import manifest from 'manifest'; 10 | import Conference from './conference'; 11 | 12 | function mapStateToProps(state: GlobalState) { 13 | const config = state[`plugins-${manifest.id}` as plugin].config; 14 | 15 | return { 16 | currentUser: getCurrentUser(state), 17 | post: state[`plugins-${manifest.id}` as plugin].openMeeting, 18 | jwt: state[`plugins-${manifest.id}` as plugin].openMeetingJwt, 19 | showPrejoinPage: config.show_prejoin_page, 20 | meetingEmbedded: config.embedded 21 | }; 22 | } 23 | 24 | function mapDispatchToProps(dispatch: Dispatch) { 25 | return { 26 | actions: bindActionCreators({ 27 | openJitsiMeeting, 28 | setUserStatus 29 | }, dispatch) 30 | }; 31 | } 32 | 33 | export default connect(mapStateToProps, mapDispatchToProps)(Conference); 34 | -------------------------------------------------------------------------------- /webapp/src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import {combineReducers} from 'redux'; 5 | 6 | import {Post} from 'mattermost-redux/types/posts'; 7 | 8 | import ActionTypes from 'action_types'; 9 | 10 | function openMeeting(state: Post | null = null, action: {type: string, data: {post: Post | null, jwt: string | null}}) { 11 | switch (action.type) { 12 | case ActionTypes.OPEN_MEETING: 13 | return action.data.post; 14 | default: 15 | return state; 16 | } 17 | } 18 | 19 | function openMeetingJwt(state: string | null = null, action: {type: string, data: {post: Post | null, jwt: string | null}}) { 20 | switch (action.type) { 21 | case ActionTypes.OPEN_MEETING: 22 | return action.data.jwt; 23 | default: 24 | return state; 25 | } 26 | } 27 | 28 | function config(state: object = {}, action: {type: string, data: object}) { 29 | switch (action.type) { 30 | case ActionTypes.CONFIG_RECEIVED: 31 | return action.data; 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | export default combineReducers({ 38 | openMeeting, 39 | openMeetingJwt, 40 | config 41 | }); 42 | -------------------------------------------------------------------------------- /webapp/babel.config.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | const config = { 5 | presets: [ 6 | ['@babel/preset-env', { 7 | targets: { 8 | chrome: 66, 9 | firefox: 60, 10 | edge: 42, 11 | safari: 12 12 | }, 13 | modules: false, 14 | corejs: 3, 15 | debug: false, 16 | useBuiltIns: 'usage', 17 | shippedProposals: true 18 | }], 19 | ['@babel/preset-react', { 20 | useBuiltIns: true 21 | }], 22 | ['@babel/typescript', { 23 | allExtensions: true, 24 | isTSX: true 25 | }] 26 | ], 27 | plugins: [ 28 | '@babel/plugin-proposal-class-properties', 29 | '@babel/plugin-syntax-dynamic-import', 30 | '@babel/proposal-object-rest-spread', 31 | 'babel-plugin-typescript-to-proptypes' 32 | ] 33 | }; 34 | 35 | // Jest needs module transformation 36 | config.env = { 37 | test: { 38 | presets: config.presets, 39 | plugins: config.plugins 40 | } 41 | }; 42 | config.env.test.presets[0][1].modules = 'auto'; 43 | 44 | module.exports = config; 45 | -------------------------------------------------------------------------------- /webapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | entry: [ 5 | './src/index.tsx' 6 | ], 7 | resolve: { 8 | modules: [ 9 | 'src', 10 | 'node_modules' 11 | ], 12 | extensions: ['*', '.js', '.jsx', '.ts', '.tsx'] 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(js|jsx|ts|tsx)$/, 18 | exclude: /node_modules/, 19 | use: { 20 | loader: 'babel-loader', 21 | options: { 22 | cacheDirectory: true 23 | 24 | // Babel configuration is in babel.config.js because jest requires it to be there. 25 | } 26 | } 27 | } 28 | ] 29 | }, 30 | externals: { 31 | react: 'React', 32 | 'react-dom': 'ReactDOM', 33 | 'react-intl': 'ReactIntl', 34 | redux: 'Redux', 35 | 'react-redux': 'ReactRedux', 36 | 'prop-types': 'PropTypes', 37 | 'react-bootstrap': 'ReactBootstrap', 38 | 'react-router-dom': 'ReactRouterDom' 39 | }, 40 | output: { 41 | path: path.join(__dirname, '/dist'), 42 | publicPath: '/', 43 | filename: 'main.js' 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: readonly 4 | 5 | linters-settings: 6 | goconst: 7 | min-len: 2 8 | min-occurrences: 2 9 | gofmt: 10 | simplify: true 11 | goimports: 12 | local-prefixes: github.com/mattermost/mattermost-plugin-jitsi 13 | govet: 14 | check-shadowing: true 15 | enable-all: true 16 | disable: 17 | - fieldalignment 18 | misspell: 19 | locale: US 20 | 21 | linters: 22 | disable-all: true 23 | enable: 24 | - bodyclose 25 | - errcheck 26 | - gocritic 27 | - gofmt 28 | - goimports 29 | - gosec 30 | - gosimple 31 | - govet 32 | - ineffassign 33 | - misspell 34 | - nakedret 35 | - revive 36 | - staticcheck 37 | - stylecheck 38 | - typecheck 39 | - unconvert 40 | - unused 41 | - whitespace 42 | 43 | issues: 44 | exclude-rules: 45 | - path: build/manifest/main.go 46 | linters: 47 | - gosec 48 | - path: server/plugin_test.go 49 | linters: 50 | - gosec 51 | - path: server/manifest.go 52 | linters: 53 | - unused 54 | - varcheck 55 | - path: server/configuration.go 56 | linters: 57 | - unused 58 | - path: _test\.go 59 | linters: 60 | - goconst 61 | - scopelint # https://github.com/kyoh86/scopelint/issues/4 62 | - path: server/plugin.go 63 | linters: 64 | - govet 65 | -------------------------------------------------------------------------------- /webapp/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import * as cs from './cs.json'; 2 | import * as de from './de.json'; 3 | import * as en from './en.json'; 4 | import * as es from './es.json'; 5 | import * as fr from './fr.json'; 6 | import * as hu from './hu.json'; 7 | import * as it from './it.json'; 8 | import * as ja from './ja.json'; 9 | import * as ko from './ko.json'; 10 | import * as nl from './nl.json'; 11 | import * as pl from './pl.json'; 12 | import * as ptBR from './pt_BR.json'; 13 | import * as ro from './ro.json'; 14 | import * as ru from './ru.json'; 15 | import * as tr from './tr.json'; 16 | import * as uk from './uk.json'; 17 | import * as zhHans from './zh_Hans.json'; 18 | import * as zhHant from './zh_Hant.json'; 19 | 20 | export function getTranslations(locale: string): {[key: string]: string} { 21 | switch (locale) { 22 | case 'cs': 23 | return cs; 24 | case 'de': 25 | return de; 26 | case 'en': 27 | return en; 28 | case 'es': 29 | return es; 30 | case 'fr': 31 | return fr; 32 | case 'hu': 33 | return hu; 34 | case 'it': 35 | return it; 36 | case 'ja': 37 | return ja; 38 | case 'ko': 39 | return ko; 40 | case 'nl': 41 | return nl; 42 | case 'pl': 43 | return pl; 44 | case 'pt-BR': 45 | return ptBR; 46 | case 'ro': 47 | return ro; 48 | case 'ru': 49 | return ru; 50 | case 'tr': 51 | return tr; 52 | case 'uk': 53 | return uk; 54 | case 'zh-CN': 55 | return zhHans; 56 | case 'zh-TW': 57 | return zhHant; 58 | } 59 | return {}; 60 | } 61 | 62 | -------------------------------------------------------------------------------- /webapp/src/components/root_portal.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import * as React from 'react'; 5 | import * as ReactDOM from 'react-dom'; 6 | import {Provider} from 'react-redux'; 7 | 8 | import Conference from './conference/'; 9 | import I18nProvider from './i18n_provider/'; 10 | 11 | export class InjectionProvider extends React.Component { 12 | public render(): React.ReactElement { 13 | const stores = {...this.props}; 14 | delete stores.children; 15 | return React.createElement(Provider as any, stores, this.props.children); 16 | } 17 | } 18 | 19 | export default class RootPortal { 20 | el: HTMLElement; 21 | store: any; 22 | 23 | constructor(registry: any, store: any) { 24 | this.el = document.createElement('div'); 25 | this.store = store; 26 | const rootPortal = document.getElementById('root-portal'); 27 | if (rootPortal) { 28 | rootPortal.appendChild(this.el); 29 | } else { 30 | registry.registerRootComponent(Conference); 31 | } 32 | } 33 | 34 | cleanup() { 35 | const rootPortal = document.getElementById('root-portal'); 36 | if (rootPortal) { 37 | rootPortal.removeChild(this.el); 38 | } 39 | } 40 | 41 | render() { 42 | const rootPortal = document.getElementById('root-portal'); 43 | if (rootPortal) { 44 | ReactDOM.render(( 45 | 46 | 47 | 48 | 49 | 50 | ), this.el); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /webapp/src/components/post_type_jitsi/index.ts: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux'; 2 | import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; 3 | 4 | import {getBool, getTheme} from 'mattermost-redux/selectors/entities/preferences'; 5 | import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; 6 | import {GenericAction, ActionFunc, ActionResult} from 'mattermost-redux/types/actions'; 7 | import {Post} from 'mattermost-redux/types/posts'; 8 | 9 | import {GlobalState, plugin} from 'types'; 10 | import {displayUsernameForUser} from 'utils/user_utils'; 11 | import {enrichMeetingJwt, openJitsiMeeting, setUserStatus} from 'actions'; 12 | import manifest from 'manifest'; 13 | import {PostTypeJitsi} from './post_type_jitsi'; 14 | 15 | type OwnProps = { 16 | post: Post, 17 | } 18 | 19 | function mapStateToProps(state: GlobalState, ownProps: OwnProps) { 20 | const post = ownProps.post; 21 | const creator = state.entities.users.profiles[post.user_id]; 22 | const config = state[`plugins-${manifest.id}` as plugin].config; 23 | 24 | return { 25 | ...ownProps, 26 | currentUser: getCurrentUser(state), 27 | theme: getTheme(state), 28 | creatorName: displayUsernameForUser(creator, state.entities.general.config), 29 | useMilitaryTime: getBool(state, 'display_settings', 'use_military_time', false), 30 | meetingEmbedded: Boolean(config.embedded) 31 | }; 32 | } 33 | 34 | type Actions = { 35 | enrichMeetingJwt: (jwt: string) => Promise, 36 | openJitsiMeeting: (post: Post | null, jwt: string | null) => ActionResult, 37 | setUserStatus: (userId: string, status: string) => Promise, 38 | } 39 | 40 | function mapDispatchToProps(dispatch: Dispatch) { 41 | return { 42 | actions: bindActionCreators, Actions>({ 43 | enrichMeetingJwt, 44 | openJitsiMeeting, 45 | setUserStatus 46 | }, dispatch) 47 | }; 48 | } 49 | 50 | export default connect(mapStateToProps, mapDispatchToProps)(PostTypeJitsi); 51 | -------------------------------------------------------------------------------- /webapp/src/client/client.ts: -------------------------------------------------------------------------------- 1 | import {Client4} from 'mattermost-redux/client'; 2 | import {ClientError} from 'mattermost-redux/client/client4'; 3 | 4 | import manifest from '../manifest'; 5 | 6 | export default class Client { 7 | private serverUrl: string | undefined; 8 | private url: string | undefined; 9 | 10 | setServerRoute(url: string) { 11 | this.serverUrl = url; 12 | this.url = url + '/plugins/' + manifest.id; 13 | } 14 | 15 | startMeeting = async (channelId: string, personal: boolean = false, topic: string = '', meetingId: string = '') => { 16 | return this.doPost(`${this.url}/api/v1/meetings`, {channel_id: channelId, personal, topic, meeting_id: meetingId}); 17 | }; 18 | 19 | enrichMeetingJwt = async (meetingJwt: string) => { 20 | return this.doPost(`${this.url}/api/v1/meetings/enrich`, {jwt: meetingJwt}); 21 | }; 22 | 23 | setUserStatus = async (userId: string | null, status: string) => { 24 | return this.doPut(`${this.serverUrl}/api/v4/users/${userId}/status`, {user_id: userId, status}); 25 | }; 26 | 27 | loadConfig = async () => { 28 | return this.doPost(`${this.url}/api/v1/config`, {}); 29 | }; 30 | 31 | doPost = async (url: string, body: any, headers: any = {}) => { 32 | const options = { 33 | method: 'post', 34 | body: JSON.stringify(body), 35 | headers 36 | }; 37 | 38 | const response = await fetch(url, Client4.getOptions(options)); 39 | 40 | if (response.ok) { 41 | return response.json(); 42 | } 43 | 44 | const text = await response.text(); 45 | 46 | throw new ClientError(Client4.url, { 47 | message: text || '', 48 | status_code: response.status, 49 | url 50 | }); 51 | }; 52 | 53 | doPut = async (url: string, body: any, headers: any = {}) => { 54 | const options = { 55 | method: 'put', 56 | body: JSON.stringify(body), 57 | headers 58 | }; 59 | 60 | options.headers['X-Requested-With'] = 'XMLHttpRequest'; 61 | 62 | const response = await fetch(url, Client4.getOptions(options)); 63 | 64 | if (response.ok) { 65 | return response.json(); 66 | } 67 | 68 | const text = await response.text(); 69 | 70 | throw new ClientError(Client4.url, { 71 | message: text || '', 72 | status_code: response.status, 73 | url 74 | }); 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /webapp/src/constants/svgs.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | VIDEO_CAMERA: " ", 3 | VIDEO_CAMERA_3: "icon video smallCreated with Sketch." 4 | }; 5 | -------------------------------------------------------------------------------- /assets/i18n/active.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.ask.channel_meeting": "Channel meeting", 3 | "jitsi.ask.meeting_name_random_words": "Meeting name with random words", 4 | "jitsi.ask.personal_meeting": "Personal meeting", 5 | "jitsi.ask.select_meeting_type": "Select type of meeting you want to start", 6 | "jitsi.ask.title": "Jitsi Meeting Start", 7 | "jitsi.ask.uuid_meeting": "Meeting name with UUID", 8 | "jitsi.command.help.text": "* |/jitsi| - Create a new meeting\n* |/jitsi [topic]| - Create a new meeting with specified topic\n* |/jitsi help| - Show this help text\n* |/jitsi settings| - View your current user settings for the Jitsi plugin\n* |/jitsi settings [setting] [value]| - Update your user settings (see below for options)\n\n###### Jitsi Settings:\n* |/jitsi settings embedded [true/false]|: (Experimental) When true, Jitsi meeting is embedded as a floating window inside Mattermost. When false, Jitsi meeting opens in a new window.\n* |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Select how meeting names are generated with one of these options:\n * |words|: Random English words in title case (e.g. PlayfulDragonsObserveCuriously)\n * |uuid|: UUID (universally unique identifier)\n * |mattermost|: Mattermost specific names. Combination of team name, channel name and random text in public and private channels; personal meeting name in direct and group messages channels.\n * |ask|: The plugin asks you to select the name every time you start a meeting", 9 | "jitsi.command.help.title": "###### Mattermost Jitsi Plugin - Slash Command help\n", 10 | "jitsi.command.settings.current_values": "###### Jitsi Settings:\n* Embedded: |{{.Embedded}}|\n* Naming Scheme: |{{.NamingScheme}}|", 11 | "jitsi.command.settings.invalid_parameters": "Invalid settings parameters", 12 | "jitsi.command.settings.unable_to_get": "Unable to get user settings", 13 | "jitsi.command.settings.unable_to_set": "Unable to set user settings", 14 | "jitsi.command.settings.updated": "Jitsi settings updated", 15 | "jitsi.command.settings.wrong_embedded_value": "Invalid `embedded` value, use `true` or `false`.", 16 | "jitsi.command.settings.wrong_field": "Invalid config field, use `embedded` or `naming_scheme`.", 17 | "jitsi.command.settings.wrong_naming_scheme_value": "Invalid `naming_scheme` value, use `ask`, `words`, `uuid` or `mattermost`.", 18 | "jitsi.start_meeting.channel_meeting_topic": "{{.ChannelName}} Channel Meeting", 19 | "jitsi.start_meeting.default_meeting_topic": "Jitsi Meeting", 20 | "jitsi.start_meeting.fallback_text": "Video Meeting started at [{{.MeetingID}}]({{.MeetingURL}}).\n\n[Join Meeting]({{.MeetingURL}})", 21 | "jitsi.start_meeting.meeting_id": "Meeting ID", 22 | "jitsi.start_meeting.meeting_link_valid_until": "Meeting link valid until: {{.Datetime}}", 23 | "jitsi.start_meeting.personal_meeting_id": "Personal Meeting ID (PMI)", 24 | "jitsi.start_meeting.personal_meeting_topic": "{{.Name}}'s Personal Meeting", 25 | "jitsi.start_meeting.slack_attachment_text": "{{.MeetingType}}: [{{.MeetingID}}]({{.MeetingURL}})\n\n[Join Meeting]({{.MeetingURL}})" 26 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattermost/mattermost-plugin-jitsi 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/cristalhq/jwt/v2 v2.0.0 7 | github.com/google/uuid v1.6.0 8 | github.com/mattermost/mattermost/server/public v0.1.14 9 | github.com/nicksnyder/go-i18n/v2 v2.6.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/stretchr/testify v1.10.0 12 | ) 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.1.0 // indirect 16 | github.com/beevik/etree v1.5.1 // indirect 17 | github.com/blang/semver/v4 v4.0.0 // indirect 18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 19 | github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect 20 | github.com/fatih/color v1.18.0 // indirect 21 | github.com/francoispqt/gojay v1.2.13 // indirect 22 | github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect 23 | github.com/go-sql-driver/mysql v1.9.2 // indirect 24 | github.com/golang/protobuf v1.5.4 // indirect 25 | github.com/gorilla/websocket v1.5.3 // indirect 26 | github.com/hashicorp/errwrap v1.1.0 // indirect 27 | github.com/hashicorp/go-hclog v1.6.3 // indirect 28 | github.com/hashicorp/go-multierror v1.1.1 // indirect 29 | github.com/hashicorp/go-plugin v1.6.3 // indirect 30 | github.com/hashicorp/yamux v0.1.2 // indirect 31 | github.com/jonboulle/clockwork v0.5.0 // indirect 32 | github.com/lib/pq v1.10.9 // indirect 33 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect 34 | github.com/mattermost/gosaml2 v0.9.0 // indirect 35 | github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect 36 | github.com/mattermost/logr/v2 v2.0.22 // indirect 37 | github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect 38 | github.com/mattn/go-colorable v0.1.14 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/oklog/run v1.1.0 // indirect 41 | github.com/pborman/uuid v1.2.1 // indirect 42 | github.com/pelletier/go-toml v1.9.5 // indirect 43 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect 44 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 45 | github.com/rudderlabs/analytics-go v3.3.3+incompatible // indirect 46 | github.com/russellhaering/goxmldsig v1.5.0 // indirect 47 | github.com/segmentio/backo-go v1.1.0 // indirect 48 | github.com/sirupsen/logrus v1.9.3 // indirect 49 | github.com/stretchr/objx v0.5.2 // indirect 50 | github.com/tidwall/gjson v1.18.0 // indirect 51 | github.com/tidwall/match v1.1.1 // indirect 52 | github.com/tidwall/pretty v1.2.1 // indirect 53 | github.com/tinylib/msgp v1.2.5 // indirect 54 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 55 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 56 | github.com/wiggin77/merror v1.0.5 // indirect 57 | github.com/wiggin77/srslog v1.0.1 // indirect 58 | github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect 59 | golang.org/x/crypto v0.38.0 // indirect 60 | golang.org/x/mod v0.24.0 // indirect 61 | golang.org/x/net v0.40.0 // indirect 62 | golang.org/x/sys v0.33.0 // indirect 63 | golang.org/x/text v0.25.0 // indirect 64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect 65 | google.golang.org/grpc v1.72.0 // indirect 66 | google.golang.org/protobuf v1.36.6 // indirect 67 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 68 | gopkg.in/yaml.v2 v2.4.0 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /webapp/src/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. 2 | // See License.txt for license information. 3 | 4 | import * as React from 'react'; 5 | 6 | import {Channel} from 'mattermost-redux/types/channels'; 7 | import {Post} from 'mattermost-redux/types/posts'; 8 | import {getConfig} from 'mattermost-redux/selectors/entities/general'; 9 | import {GlobalState} from 'mattermost-redux/types/store'; 10 | 11 | import Icon from './components/icon'; 12 | import PostTypeJitsi from './components/post_type_jitsi'; 13 | import I18nProvider from './components/i18n_provider'; 14 | import RootPortal from './components/root_portal'; 15 | import reducer from './reducers'; 16 | import {startMeeting, loadConfig} from './actions'; 17 | import manifest from './manifest'; 18 | import Client from './client'; 19 | 20 | class PluginClass { 21 | rootPortal?: RootPortal; 22 | 23 | initialize(registry: any, store: any) { 24 | const {id: pluginId} = manifest; 25 | if ((window as any).JitsiMeetExternalAPI) { 26 | this.rootPortal = new RootPortal(registry, store); 27 | if (this.rootPortal) { 28 | this.rootPortal.render(); 29 | } 30 | } else { 31 | const script = document.createElement('script'); 32 | script.type = 'text/javascript'; 33 | script.onload = () => { 34 | this.rootPortal = new RootPortal(registry, store); 35 | if (this.rootPortal) { 36 | this.rootPortal.render(); 37 | } 38 | }; 39 | script.src = `${(window as any).basename}/plugins/${pluginId}/jitsi_meet_external_api.js`; 40 | document.head.appendChild(script); 41 | } 42 | registry.registerReducer(reducer); 43 | 44 | const action = (channel: Channel) => { 45 | store.dispatch(startMeeting(channel.id)); 46 | }; 47 | const helpText = 'Start Jitsi Meeting'; 48 | 49 | // Channel header icon 50 | registry.registerChannelHeaderButtonAction(, action, helpText); 51 | 52 | // App Bar icon 53 | if (registry.registerAppBarComponent) { 54 | const config = getConfig(store.getState()); 55 | const siteUrl = (config && config.SiteURL) || ''; 56 | const iconURL = `${siteUrl}/plugins/${pluginId}/public/app-bar-icon.png`; 57 | registry.registerAppBarComponent(iconURL, action, helpText); 58 | } 59 | 60 | Client.setServerRoute(getServerRoute(store.getState())); 61 | registry.registerPostTypeComponent('custom_jitsi', (props: { post: Post }) => ( 62 | )); 63 | registry.registerWebSocketEventHandler('custom_jitsi_config_update', () => store.dispatch(loadConfig())); 64 | store.dispatch(loadConfig()); 65 | } 66 | 67 | uninitialize() { 68 | if (this.rootPortal) { 69 | this.rootPortal.cleanup(); 70 | } 71 | } 72 | } 73 | 74 | (global as any).window.registerPlugin('jitsi', new PluginClass()); 75 | 76 | function getServerRoute(state: GlobalState) { 77 | const config = getConfig(state); 78 | let basePath = ''; 79 | if (config && config.SiteURL) { 80 | basePath = config.SiteURL; 81 | if (basePath && basePath[basePath.length - 1] === '/') { 82 | basePath = basePath.substr(0, basePath.length - 1); 83 | } 84 | } 85 | return basePath; 86 | } 87 | -------------------------------------------------------------------------------- /webapp/src/utils/user_utils.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from '@jest/globals'; 2 | 3 | import {UserProfile} from 'mattermost-redux/types/users'; 4 | 5 | import {displayUsernameForUser} from './user_utils'; 6 | 7 | describe('displayUsernameForUser', () => { 8 | const defaultConfig = { 9 | TeammateNameDisplay: 'nickname_full_name' 10 | }; 11 | 12 | const user: UserProfile = { 13 | id: 'test', 14 | create_at: 0, 15 | update_at: 0, 16 | delete_at: 0, 17 | auth_data: '', 18 | auth_service: '', 19 | email: '', 20 | position: '', 21 | roles: '', 22 | locale: '', 23 | notify_props: { 24 | desktop: 'all', 25 | desktop_sound: 'true', 26 | email: 'true', 27 | mark_unread: 'all', 28 | push: 'all', 29 | push_status: 'online', 30 | comments: 'any', 31 | first_name: 'true', 32 | channel: 'true', 33 | mention_keys: '' 34 | }, 35 | email_verified: true, 36 | terms_of_service_id: '', 37 | terms_of_service_create_at: 0, 38 | is_bot: false, 39 | last_picture_update: 0, 40 | username: 'test-username', 41 | first_name: 'test-first-name', 42 | last_name: 'test-last-name', 43 | nickname: 'test-nickname' 44 | }; 45 | 46 | it('should return and empty string is there is no user', () => { 47 | expect(displayUsernameForUser(null, defaultConfig)).toBe(''); 48 | }); 49 | 50 | it('should return the username if no config is passed', () => { 51 | expect(displayUsernameForUser(user, {})).toBe(user.username); 52 | }); 53 | 54 | it('should return the nickname if nickname_full_name config is passed and there is a nickname', () => { 55 | expect(displayUsernameForUser(user, {TeammateNameDisplay: 'nickname_full_name'})).toBe(user.nickname); 56 | }); 57 | 58 | it('should return the full name if nickname_full_name config is passed and there is not a nickname', () => { 59 | expect(displayUsernameForUser({...user, nickname: ''}, {TeammateNameDisplay: 'nickname_full_name'})).toBe(`${user.first_name} ${user.last_name}`); 60 | }); 61 | 62 | it('should return the username if nickname_full_name config is passed and there is not a nickname, first_name or last_name', () => { 63 | expect(displayUsernameForUser({...user, nickname: '', first_name: '', last_name: ''}, {TeammateNameDisplay: 'nickname_full_name'})).toBe(user.username); 64 | }); 65 | 66 | it('should return the full name if nickname_full_name config is passed and there is not a nickname', () => { 67 | expect(displayUsernameForUser({...user, nickname: ''}, {TeammateNameDisplay: 'nickname_full_name'})).toBe(`${user.first_name} ${user.last_name}`); 68 | }); 69 | 70 | it('should return the username if nickname_full_name config is passed and there is not a nickname, first_name or last_name', () => { 71 | expect(displayUsernameForUser({...user, nickname: '', first_name: '', last_name: ''}, {TeammateNameDisplay: 'nickname_full_name'})).toBe(user.username); 72 | }); 73 | 74 | it('should return the fullname if full_name config is passed and there is a full_name and a nickname', () => { 75 | expect(displayUsernameForUser(user, {TeammateNameDisplay: 'full_name'})).toBe(`${user.first_name} ${user.last_name}`); 76 | }); 77 | 78 | it('should return the username if full_name config is passed and there is not a first_name or last_name', () => { 79 | expect(displayUsernameForUser({...user, first_name: '', last_name: ''}, {TeammateNameDisplay: 'full_name'})).toBe(user.username); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /webapp/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import {PostTypes} from 'mattermost-redux/action_types'; 2 | import {DispatchFunc, GetStateFunc, ActionFunc, ActionResult} from 'mattermost-redux/types/actions'; 3 | import {Post} from 'mattermost-redux/types/posts'; 4 | import ActionTypes from 'action_types'; 5 | 6 | import Client from 'client'; 7 | 8 | export function startMeeting(channelId: string, personal: boolean = false, topic: string = '', meetingId: string = ''): ActionFunc { 9 | return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { 10 | try { 11 | await Client.startMeeting(channelId, personal, topic, meetingId); 12 | } catch (error) { 13 | const post: Post = { 14 | id: 'jitsiPlugin' + Date.now(), 15 | create_at: Date.now(), 16 | update_at: 0, 17 | edit_at: 0, 18 | delete_at: 0, 19 | is_pinned: false, 20 | user_id: getState().entities.users.currentUserId, 21 | channel_id: channelId, 22 | root_id: '', 23 | parent_id: '', 24 | original_id: '', 25 | reply_count: 0, 26 | message: 'We could not start a meeting at this time.', 27 | type: 'system_ephemeral', 28 | props: {}, 29 | metadata: { 30 | embeds: [], 31 | emojis: [], 32 | files: [], 33 | images: {}, 34 | reactions: [] 35 | }, 36 | hashtags: '', 37 | pending_post_id: '' 38 | }; 39 | 40 | dispatch({ 41 | type: PostTypes.RECEIVED_POSTS, 42 | data: { 43 | order: [], 44 | posts: { 45 | [post.id]: post 46 | } 47 | }, 48 | channelId 49 | }); 50 | 51 | return {error}; 52 | } 53 | 54 | return {data: true}; 55 | }; 56 | } 57 | 58 | export function enrichMeetingJwt(meetingJwt: string): ActionFunc { 59 | return async (): Promise => { 60 | try { 61 | const data = await Client.enrichMeetingJwt(meetingJwt); 62 | return {data}; 63 | } catch (error) { 64 | return {error}; 65 | } 66 | }; 67 | } 68 | 69 | export function loadConfig(): ActionFunc { 70 | return async (dispatch: DispatchFunc): Promise => { 71 | try { 72 | const data = await Client.loadConfig(); 73 | dispatch({ 74 | type: ActionTypes.CONFIG_RECEIVED, 75 | data 76 | }); 77 | return {data}; 78 | } catch (error) { 79 | return {error}; 80 | } 81 | }; 82 | } 83 | 84 | export function openJitsiMeeting(post: Post | null, jwt: string | null): ActionFunc { 85 | return (dispatch: DispatchFunc): ActionResult => { 86 | dispatch({ 87 | type: ActionTypes.OPEN_MEETING, 88 | data: { 89 | post, 90 | jwt 91 | } 92 | }); 93 | return {data: null}; 94 | }; 95 | } 96 | 97 | export function setUserStatus(userId: string, status: string): ActionFunc { 98 | return async (dispatch: DispatchFunc): Promise => { 99 | try { 100 | const data = await Client.setUserStatus(userId, status); 101 | dispatch({ 102 | type: ActionTypes.USER_STATUS_CHANGED, 103 | data 104 | }); 105 | return {data}; 106 | } catch (error) { 107 | return {error}; 108 | } 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jitsi", 3 | "version": "1.1.0", 4 | "description": "Jitsi audio and video conferencing plugin for Mattermost", 5 | "main": "src/index.tsx", 6 | "scripts": { 7 | "build": "webpack --mode=production", 8 | "build:watch": "webpack --mode=production --watch", 9 | "debug": "webpack --mode=none", 10 | "debug:watch": "webpack --mode=development --watch", 11 | "lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --cache", 12 | "fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --fix --cache", 13 | "test": "LANG=C TZ=UTC jest --forceExit --detectOpenHandles --verbose", 14 | "test:watch": "LANG=C TZ=UTC jest --watch", 15 | "test-ci": "LANG=C TZ=UTC jest --forceExit --detectOpenHandles --maxWorkers=2", 16 | "check-types": "tsc" 17 | }, 18 | "author": "", 19 | "license": "", 20 | "devDependencies": { 21 | "@babel/cli": "7.16.8", 22 | "@babel/core": "7.16.12", 23 | "@babel/plugin-proposal-class-properties": "7.16.7", 24 | "@babel/plugin-proposal-object-rest-spread": "7.16.7", 25 | "@babel/plugin-proposal-optional-chaining": "7.16.7", 26 | "@babel/plugin-syntax-dynamic-import": "7.8.3", 27 | "@babel/preset-env": "7.16.11", 28 | "@babel/preset-react": "7.16.7", 29 | "@babel/preset-typescript": "7.16.7", 30 | "@babel/runtime": "7.16.7", 31 | "@emotion/babel-preset-css-prop": "11.2.0", 32 | "@types/babel__core": "7.1.18", 33 | "@types/babel__template": "7.4.1", 34 | "@types/enzyme": "3.10.5", 35 | "@types/jest": "25.2.3", 36 | "@types/node": "14.0.5", 37 | "@types/react": "16.9.35", 38 | "@types/react-dom": "16.9.8", 39 | "@types/react-redux": "7.1.9", 40 | "@types/react-router-dom": "5.1.5", 41 | "@types/react-transition-group": "4.4.0", 42 | "@typescript-eslint/eslint-plugin": "5.10.1", 43 | "@typescript-eslint/parser": "5.10.1", 44 | "babel-loader": "8.0.6", 45 | "babel-plugin-typescript-to-proptypes": "0.17.1", 46 | "enzyme": "3.11.0", 47 | "enzyme-adapter-react-16": "1.15.2", 48 | "enzyme-to-json": "3.5.0", 49 | "eslint": "8.8.0", 50 | "eslint-import-resolver-webpack": "0.12.1", 51 | "eslint-plugin-import": "2.25.4", 52 | "eslint-plugin-react": "7.28.0", 53 | "file-loader": "6.0.0", 54 | "identity-obj-proxy": "3.0.0", 55 | "jest": "26.0.1", 56 | "jest-canvas-mock": "2.2.0", 57 | "jest-junit": "10.0.0", 58 | "ts-jest": "26.0.0", 59 | "ts-loader": "7.0.5", 60 | "typescript": "3.9.3", 61 | "webpack": "4.43.0", 62 | "webpack-bundle-analyzer": "3.8.0", 63 | "webpack-cli": "3.3.11" 64 | }, 65 | "dependencies": { 66 | "core-js": "3.6.5", 67 | "mattermost-redux": "5.23.0", 68 | "react": "16.14.0", 69 | "react-intl": "4.6.3", 70 | "react-redux": "7.2.0", 71 | "redux": "4.0.5" 72 | }, 73 | "jest": { 74 | "snapshotSerializers": [ 75 | "/node_modules/enzyme-to-json/serializer" 76 | ], 77 | "testPathIgnorePatterns": [ 78 | "/node_modules/", 79 | "/non_npm_dependencies/" 80 | ], 81 | "clearMocks": true, 82 | "collectCoverageFrom": [ 83 | "src/**/*.{ts,tsx}" 84 | ], 85 | "coverageReporters": [ 86 | "lcov", 87 | "text-summary" 88 | ], 89 | "moduleNameMapper": { 90 | "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy", 91 | "^.+\\.(css|less|scss)$": "identity-obj-proxy", 92 | "^.*i18n.*\\.(json)$": "/tests/i18n_mock.json", 93 | "^bundle-loader\\?lazy\\!(.*)$": "$1" 94 | }, 95 | "moduleDirectories": [ 96 | "", 97 | "node_modules", 98 | "non_npm_dependencies" 99 | ], 100 | "reporters": [ 101 | "default", 102 | "jest-junit" 103 | ], 104 | "transformIgnorePatterns": [ 105 | "node_modules/(?!react-native|react-router|mattermost-webapp)" 106 | ], 107 | "setupFiles": [ 108 | "jest-canvas-mock" 109 | ], 110 | "setupFilesAfterEnv": [ 111 | "/tests/setup.js" 112 | ], 113 | "testURL": "http://localhost:8065" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Mattermost Jitsi Plugin 2 | 3 | Thank you for your interest in contributing! Join the [Plugin: Jitsi](https://community-daily.mattermost.com/core/channels/plugin-jitsi) channel on the Mattermost community server for discussion about this plugin. 4 | 5 | ## Reporting issues 6 | 7 | If you think you've found a bug, [please use the GitHub issue tracker](https://github.com/mattermost/mattermost-plugin-jitsi/issues/new?template=issue.md) to open an issue. To help us troubleshoot the issue, please provide the required information in the issue template. 8 | 9 | ## Translating strings 10 | 11 | The Mattermost Jitsi plugin supports localization to various languages. We as maintainers rely on contributors to help with the translations. 12 | 13 | The plugin uses [go-i18n](https://github.com/nicksnyder/go-i18n) as library and tool to manage translation. The CLI tool `goi18n` is required to manage translation. You can install it by running `env GO111MODULE=off go get -u github.com/nicksnyder/go-i18n/v2/goi18n`. 14 | 15 | The localization process is defined below: 16 | 17 | 1. During development, new translation strings may be added or existing ones updated. 18 | 2. When a new version is planned to release soon, a repository maintainer opens an issue informing about the new release and mentions all translation maintainers in the issue. 19 | 3. Translation maintainers submit PRs with new translations, which may get reviewed by other translators. 20 | 4. After all translation PRs are merged, the new version is released. If a translation PR is not submitted within a week, the release is cut without it. 21 | 22 | ### Translation maintainers 23 | 24 | - French: [@Extazx2](https://github.com/Extazx2) 25 | - German: [@hanzei](https://github.com/hanzei) 26 | - Spanish: [@jespino](https://github.com/jespino) 27 | 28 | ### Translate to a new language 29 | 30 | Note: We use the German locale (`de`) in this example. When translating to a new language, replace `de` in the following commands with the locale you want to translate. [See available locales](https://github.com/mattermost/mattermost-server/tree/master/i18n). 31 | 32 | 1. Open your teminal window and create a translation file: 33 | 34 | `touch asserts/i18n/translate.de.json` 35 | 36 | 2. Merge all current messages into your translation file: 37 | 38 | `make i18n-extract-server` 39 | 40 | 3. Translate all messages in `asserts/i18n/translate.de.json`. 41 | 42 | 4. Merge the translated messages into the active message files: 43 | 44 | `make i18n-merge-server` 45 | 46 | 5. Add your language to the list of [supported languages](https://github.com/mattermost/mattermost-plugin-jitsi#localization) in `README.md` and add yourself to the list of [translation maintainers](#translation-maintainers) in `CONTRIBUTING.md`. 47 | 48 | 6. [Submit a PR](https://github.com/mattermost/mattermost-plugin-jitsi/compare) with these files. 49 | 50 | Once you've submitted a PR, your changes will be reviewed. Big thank you for your contribution! 51 | 52 | ### Translate existing languages 53 | 54 | 1. Ensure all translation messages are correctly extracted: 55 | 56 | `make i18n-extract-server` 57 | 58 | 2. Translate all messages in `asserts/i18n/translate.*.json` for the languages you are comfortable with. 59 | 60 | 3. Merge the translated messages into the active message files: 61 | 62 | `make i18n-merge-server` 63 | 64 | 4. Commit **only the language files you edited** and [submit a PR](https://github.com/mattermost/mattermost-plugin-jitsi/compare). 65 | 66 | ## Submitting bug fixes or features 67 | 68 | If you're contributing a feature, [please open a feature request](https://github.com/mattermost/mattermost-plugin-jitsi/issues/new?template=issue.md) first. This enables the feature to be discussed and fully specified before you start working on it. Small code changes can be submitted without opening an issue. 69 | 70 | Note that this project uses [Go modules](https://github.com/golang/go/wiki/Modules). Be sure to locate the project outside of `$GOPATH`, or allow the use of Go modules within your `$GOPATH` with an `export GO111MODULE=on`. 71 | 72 | ### Help wanted 73 | 74 | You can view [open help wanted issues](https://github.com/mattermost/mattermost-plugin-jitsi/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22Help+Wanted%22) to get started with contributing to the plugin. 75 | 76 | ## Development 77 | 78 | This plugin contains both a server and web app portion. Read our documentation about the [Developer Workflow](https://developers.mattermost.com/extend/plugins/developer-workflow/) and [Developer Setup](https://developers.mattermost.com/extend/plugins/developer-setup/) for more information about developing and extending plugins. 79 | -------------------------------------------------------------------------------- /server/plugin_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/cristalhq/jwt/v2" 11 | "github.com/mattermost/mattermost/server/public/model" 12 | "github.com/mattermost/mattermost/server/public/plugin/plugintest" 13 | "github.com/mattermost/mattermost/server/public/pluginapi/i18n" 14 | "github.com/stretchr/testify/mock" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestVerifyJwt(t *testing.T) { 19 | testToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.mpHl842O7xEZjgQ8CyX8xYLDoEORGVMnAxULkW-u8Ek" 20 | t.Run("bad secret should fail", func(t *testing.T) { 21 | claims, err := verifyJwt("bad-secret", testToken) 22 | require.NotNil(t, err) 23 | require.Nil(t, claims) 24 | }) 25 | t.Run("right secret should work", func(t *testing.T) { 26 | claims, err := verifyJwt("test-secret", testToken) 27 | require.Nil(t, err) 28 | require.Equal(t, "1234567890", claims.Subject) 29 | issuedAt, err := claims.IssuedAt.MarshalJSON() 30 | require.Nil(t, err) 31 | require.Equal(t, "1516239022", string(issuedAt)) 32 | }) 33 | } 34 | 35 | func TestSignClaims(t *testing.T) { 36 | claims := Claims{} 37 | claims.Issuer = "test" 38 | claims.Audience = []string{"test-app-id"} 39 | claims.ExpiresAt = jwt.NewNumericDate(time.Time{}) 40 | claims.Subject = "test" 41 | claims.Room = "test" 42 | 43 | token, err := signClaims("test-secret", &claims) 44 | require.Nil(t, err) 45 | 46 | newClaims, err := verifyJwt("test-secret", token) 47 | require.Nil(t, err) 48 | require.Equal(t, &claims, newClaims) 49 | } 50 | 51 | func TestStartMeeting(t *testing.T) { 52 | p := Plugin{ 53 | configuration: &configuration{ 54 | JitsiURL: "http://test", 55 | }, 56 | } 57 | apiMock := plugintest.API{} 58 | defer apiMock.AssertExpectations(t) 59 | apiMock.On("GetBundlePath").Return("..", nil) 60 | config := model.Config{} 61 | config.SetDefaults() 62 | apiMock.On("GetConfig").Return(&config, nil) 63 | 64 | p.SetAPI(&apiMock) 65 | 66 | i18nBundle, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n")) 67 | require.Nil(t, err) 68 | p.b = i18nBundle 69 | 70 | testUser := model.User{Id: "test-id", Username: "test-username", FirstName: "test-first-name", LastName: "test-last-name", Nickname: "test-nickname"} 71 | testChannel := model.Channel{Id: "test-id", Type: model.ChannelTypeDirect, Name: "test-name", DisplayName: "test-display-name"} 72 | 73 | t.Run("start meeting without topic or id", func(t *testing.T) { 74 | apiMock.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { 75 | return strings.HasPrefix(post.Props["meeting_link"].(string), "http://test/") 76 | })).Return(&model.Post{}, nil) 77 | b, _ := json.Marshal(UserConfig{Embedded: false, NamingScheme: "mattermost"}) 78 | apiMock.On("KVGet", "config_test-id", mock.Anything).Return(b, nil) 79 | 80 | meetingID, err := p.startMeeting(&testUser, &testChannel, "", "", false, "") 81 | require.Nil(t, err) 82 | require.Regexp(t, "^test-username-", meetingID) 83 | }) 84 | 85 | t.Run("start meeting with topic and without id", func(t *testing.T) { 86 | apiMock.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { 87 | return strings.HasPrefix(post.Props["meeting_link"].(string), "http://test/") 88 | })).Return(&model.Post{}, nil) 89 | b, _ := json.Marshal(UserConfig{Embedded: false, NamingScheme: "mattermost"}) 90 | apiMock.On("KVGet", "config_test-id", mock.Anything).Return(b, nil) 91 | 92 | meetingID, err := p.startMeeting(&testUser, &testChannel, "", "Test topic", false, "") 93 | require.Nil(t, err) 94 | require.Regexp(t, "^Test-topic-", meetingID) 95 | }) 96 | 97 | t.Run("start meeting without topic and with id", func(t *testing.T) { 98 | apiMock.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { 99 | return strings.HasPrefix(post.Props["meeting_link"].(string), "http://test/") 100 | })).Return(&model.Post{}, nil) 101 | b, _ := json.Marshal(UserConfig{Embedded: false, NamingScheme: "mattermost"}) 102 | apiMock.On("KVGet", "config_test-id", mock.Anything).Return(b, nil) 103 | 104 | meetingID, err := p.startMeeting(&testUser, &testChannel, "test-id", "", false, "") 105 | require.Nil(t, err) 106 | require.Regexp(t, "^test-username-", meetingID) 107 | }) 108 | 109 | t.Run("start meeting with topic and id", func(t *testing.T) { 110 | testUser := model.User{Id: "test-id", Username: "test-username", FirstName: "test-first-name", LastName: "test-last-name", Nickname: "test-nickname"} 111 | testChannel := model.Channel{Id: "test-id", Type: model.ChannelTypeOpen, TeamId: "test-team-id", Name: "test-name", DisplayName: "test-display-name"} 112 | 113 | apiMock.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { 114 | return strings.HasPrefix(post.Props["meeting_link"].(string), "http://test/") 115 | })).Return(&model.Post{}, nil) 116 | b, _ := json.Marshal(UserConfig{Embedded: false, NamingScheme: "mattermost"}) 117 | apiMock.On("KVGet", "config_test-id", mock.Anything).Return(b, nil) 118 | 119 | meetingID, err := p.startMeeting(&testUser, &testChannel, "test-id", "Test topic", false, "") 120 | require.Nil(t, err) 121 | require.Equal(t, "test-id", meetingID) 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /assets/i18n/active.ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.ask.channel_meeting": { 3 | "hash": "sha1-c38fb5c89ece9869aa4b9b6d1d041ab64df1eb8f", 4 | "other":"Совещание канала" 5 | }, 6 | "jitsi.ask.meeting_name_random_words": { 7 | "hash": "sha1-ee8f64a8a20eb04fefde418d9dc55d409664a2fb", 8 | "other":"Название совещания из случайных слов" 9 | }, 10 | "jitsi.ask.personal_meeting": { 11 | "hash": "sha1-9aa3e0500b4b0af16961174dcf792e9e11c5b220", 12 | "other":"Личная встреча" 13 | }, 14 | "jitsi.ask.select_meeting_type": { 15 | "hash": "sha1-e3bb1fbe427d3433e652b343086fa94926b8760b", 16 | "other":"Выберите тип встречи которую вы хотите начать" 17 | }, 18 | "jitsi.ask.title": { 19 | "hash": "sha1-06bd54e7555f74e5c45f19f5d357cabf399226d8", 20 | "other":"Начало совещания Jitsi" 21 | }, 22 | "jitsi.ask.uuid_meeting": { 23 | "hash": "sha1-573ba198f97f8c38ee986a2cad4068d0e54c9dbd", 24 | "other":"Имя совещания с UUID" 25 | }, 26 | "jitsi.command.help.text": { 27 | "hash": "sha1-08e1afe25556ca110a40b00eadf99b6716db697a", 28 | "other":"* |/jitsi| - Создать новое совещание\n* |/jitsi [тема]| - Создать новое совещание с заданной темой\n* |/jitsi help| - Показать справочную информацию\n* |/jitsi settings| - Посмотреть свои персональные настройки для совещаний Jitsi\n* |/jitsi settings [настройка] [значение]| - Обновить свои персональные настройки (см. ниже для подробностей)\n\n###### Настройки Jitsi:\n* |/jitsi settings embedded [true/false]|: (Экспериментальное) При значении true совещания Jitsi встраиваются как плавающее окно внутри клиента Mattermost. При значении false совещания Jitsi открываются в новом окне.\n* |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Выберите схему создания имени совещания:\n * |words|: Случайные слова на английском языке (e.g. PlayfulDragonsObserveCuriously)\n * |uuid|: UUID (универсальный уникальный идентификатор)\n * |mattermost|: Специфические имена Mattermost. Комбинация имени команды, имени канала и случайного текста для публичных и приватных каналов; личное имя встречи в каналах личных сообщений.\n * |ask|: Плагин предлагает выбрать имя при каждом начале совещания" 29 | }, 30 | "jitsi.command.help.title": { 31 | "hash": "sha1-4323c51f8bbbb49db2a370dd496d29531a034b3c", 32 | "other":"###### Mattermost Jitsi плагин - Справка по слэш-команде\n" 33 | }, 34 | "jitsi.command.settings.current_values": { 35 | "hash": "sha1-fe2a45638786b11d783dedacabad04227391973b", 36 | "other":"###### Jitsi настройки:\n* Встраивать: |{{.Embedded}}|\n* Схема названий: |{{.NamingScheme}}|" 37 | }, 38 | "jitsi.command.settings.invalid_parameters": { 39 | "hash": "sha1-f72c018607477396d845c7a3e11be73490b79a56", 40 | "other":"Неверные параметры настроек" 41 | }, 42 | "jitsi.command.settings.unable_to_get": { 43 | "hash": "sha1-7e8adf669715dc73bf3f13568593cc30539af7ec", 44 | "other":"Не удалось получить пользовательские настройки" 45 | }, 46 | "jitsi.command.settings.unable_to_set": { 47 | "hash": "sha1-3bab2d5cd97e52eb365ab5e25aa5c89bf2a78f31", 48 | "other":"Не удалось установить пользовательские настройки" 49 | }, 50 | "jitsi.command.settings.updated": { 51 | "hash": "sha1-ea98a80baec114b32359809c03c7b11038b18eb6", 52 | "other":"Настройки Jitsi обновлены" 53 | }, 54 | "jitsi.command.settings.wrong_embedded_value": { 55 | "hash": "sha1-63273ba7ad2d4defa65c3c31765ed57668f157c3", 56 | "other":"Неверное значение `embedded`, должно быть `true` или `false`." 57 | }, 58 | "jitsi.command.settings.wrong_field": { 59 | "hash": "sha1-75edec3f38b0abd2004b0d5cb5318462993f6a18", 60 | "other":"Неверное поле конфигурации, используйте `embedded` или `naming_scheme`." 61 | }, 62 | "jitsi.command.settings.wrong_naming_scheme_value": { 63 | "hash": "sha1-fb39ca34d10fa7b9ccea30f0ebf7e1f7936b6b32", 64 | "other":"Неверное значение `naming_scheme`, используйте `ask`, `words`, `uuid` или `mattermost`." 65 | }, 66 | "jitsi.start_meeting.channel_meeting_topic": { 67 | "hash": "sha1-87eea3707ed3cad060b73cb43c0d4efe4ec8cc84", 68 | "other":"Совещание канала {{.ChannelName}}" 69 | }, 70 | "jitsi.start_meeting.default_meeting_topic": { 71 | "hash": "sha1-b057021a4c94f69bc1f90d9fa69a6165caeb4cce", 72 | "other":"Совещание Jitsi" 73 | }, 74 | "jitsi.start_meeting.fallback_text": { 75 | "hash": "sha1-e956f3c3fd1a6c3995976154e0f45099234a1f0a", 76 | "other":"Видеоконференция начата по адресу [{{.MeetingID}}]({{.MeetingURL}}).\n\n[Присоединиться]({{.MeetingURL}})" 77 | }, 78 | "jitsi.start_meeting.meeting_id": { 79 | "hash": "sha1-8a1315eaeb59e44132aa5c27e4f53e7ba92359f7", 80 | "other":"Идентификатор совещания" 81 | }, 82 | "jitsi.start_meeting.meeting_link_valid_until": { 83 | "hash": "sha1-2707cc0764b955fca772a7dd5337e4aaa6018fcc", 84 | "other":"Ссылка на совещание действительна до: {{.Datetime}}" 85 | }, 86 | "jitsi.start_meeting.personal_meeting_id": { 87 | "hash": "sha1-ba2b43d1c43e28926cc42c26651693567363dd77", 88 | "other":"Идентификатор личной встречи (PMI)" 89 | }, 90 | "jitsi.start_meeting.personal_meeting_topic": { 91 | "hash": "sha1-22fab211c66c011f0e1f2ca6303443da6e20793e", 92 | "other":"Личная встреча {{.Name}}" 93 | }, 94 | "jitsi.start_meeting.slack_attachment_text": { 95 | "hash": "sha1-7726587ccfee9bb7539aad458c154f8be3d95dcd", 96 | "other":"{{.MeetingType}}: [{{.MeetingID}}]({{.MeetingURL}})\n\n[Присоединиться]({{.MeetingURL}})" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "jitsi", 3 | "name": "Jitsi", 4 | "description": "Jitsi audio and video conferencing plugin for Mattermost.", 5 | "homepage_url": "https://github.com/mattermost/mattermost-plugin-jitsi", 6 | "support_url": "https://github.com/mattermost/mattermost-plugin-jitsi/issues", 7 | "icon_path": "assets/icon.svg", 8 | "min_server_version": "5.2.0", 9 | "server": { 10 | "executables": { 11 | "linux-amd64": "server/dist/plugin-linux-amd64", 12 | "darwin-amd64": "server/dist/plugin-darwin-amd64", 13 | "windows-amd64": "server/dist/plugin-windows-amd64.exe" 14 | } 15 | }, 16 | "webapp": { 17 | "bundle_path": "webapp/dist/main.js" 18 | }, 19 | "settings_schema": { 20 | "settings": [ 21 | { 22 | "key": "JitsiURL", 23 | "display_name": "Jitsi Server URL:", 24 | "type": "text", 25 | "help_text": "The URL for your Jitsi server, for example https://jitsi.example.com. Defaults to https://meet.jit.si, which is the public server provided by Jitsi.", 26 | "placeholder": "https://meet.jit.si", 27 | "default": "https://meet.jit.si" 28 | }, 29 | { 30 | "key": "JitsiEmbedded", 31 | "display_name": "Embed Jitsi video inside Mattermost:", 32 | "type": "bool", 33 | "help_text": "(Experimental) When true, Jitsi video is embedded as a floating window inside Mattermost by default. Users can override this setting with '/jitsi settings'." 34 | }, 35 | { 36 | "key": "JitsiPrejoinPage", 37 | "display_name": "Show pre-join page:", 38 | "type": "bool", 39 | "help_text": "When false, pre-join page will not be displayed when Jitsi is embedded inside Mattermost." 40 | }, 41 | { 42 | "key": "JitsiNamingScheme", 43 | "display_name": "Jitsi Meeting Names:", 44 | "type": "radio", 45 | "help_text": "Select how meeting names are generated by default. Users can override this setting with '/jitsi settings'.", 46 | "default": "words", 47 | "options": [ 48 | { 49 | "display_name": "Random English words in title case (e.g. PlayfulDragonsObserveCuriously)", 50 | "value": "words" 51 | }, 52 | { 53 | "display_name": "UUID (universally unique identifier)", 54 | "value": "uuid" 55 | }, 56 | { 57 | "display_name": "Mattermost context specific names. Combination of team name, channel name, and random text in Public and Private channels; personal meeting name in Direct and Group Message channels.", 58 | "value": "mattermost" 59 | }, 60 | { 61 | "display_name": "Allow user to select meeting name", 62 | "value": "ask" 63 | } 64 | ] 65 | }, 66 | { 67 | "key": "JitsiJWT", 68 | "display_name": "Use JWT Authentication for Jitsi:", 69 | "type": "bool", 70 | "help_text": "(Optional) If your Jitsi server uses JSON Web Tokens (JWT) for authentication, set this value to true." 71 | }, 72 | { 73 | "key": "JitsiAppID", 74 | "display_name": "App ID for JWT Authentication:", 75 | "type": "text", 76 | "help_text": "(Optional) The app ID used for authentication by the Jitsi server and JWT token generator." 77 | }, 78 | { 79 | "key": "JitsiAppSecret", 80 | "display_name": "App Secret for JWT Authentication:", 81 | "type": "text", 82 | "help_text": "(Optional) The app secret used for authentication by the Jitsi server and JWT token generator.", 83 | "secret": true 84 | }, 85 | { 86 | "key": "JitsiLinkValidTime", 87 | "display_name": "Meeting Link Expiry Time (minutes):", 88 | "type": "number", 89 | "help_text": "(Optional) The number of minutes from when the meeting link is created to when it becomes invalid. Minimum is 1 minute. Only applies if using JWT authentication for your Jitsi server.", 90 | "default": 30 91 | }, 92 | { 93 | "key": "JitsiCompatibilityMode", 94 | "display_name": "Enable Compatibility Mode:", 95 | "type": "bool", 96 | "help_text": "(Insecure) If your Jitsi server is not compatible with this plugin, include the JavaScript API hosted on your Jitsi server directly in Mattermost instead of the default API version provided by the plugin. **WARNING:** Enabling this setting can compromise the security of your Mattermost system, if your Jitsi server is not fully trusted and allows direct modification of program files. Use with caution.", 97 | "default": false 98 | } 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/i18n/active.de.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.ask.channel_meeting": { 3 | "hash": "sha1-c38fb5c89ece9869aa4b9b6d1d041ab64df1eb8f", 4 | "other": "Kanal Meeting" 5 | }, 6 | "jitsi.ask.meeting_name_random_words": { 7 | "hash": "sha1-ee8f64a8a20eb04fefde418d9dc55d409664a2fb", 8 | "other": "Zufällige Wörter als Meeting Name" 9 | }, 10 | "jitsi.ask.personal_meeting": { 11 | "hash": "sha1-9aa3e0500b4b0af16961174dcf792e9e11c5b220", 12 | "other": "Persönliches Meeting" 13 | }, 14 | "jitsi.ask.select_meeting_type": { 15 | "hash": "sha1-e3bb1fbe427d3433e652b343086fa94926b8760b", 16 | "other": "Welche Art von Meeting soll gestartet werden?" 17 | }, 18 | "jitsi.ask.title": { 19 | "hash": "sha1-06bd54e7555f74e5c45f19f5d357cabf399226d8", 20 | "other": "Ein Jitsi Meeting starten" 21 | }, 22 | "jitsi.ask.uuid_meeting": { 23 | "hash": "sha1-573ba198f97f8c38ee986a2cad4068d0e54c9dbd", 24 | "other": "Einzigartige Nummer als Meeting Name" 25 | }, 26 | "jitsi.command.help.text": { 27 | "hash": "sha1-08e1afe25556ca110a40b00eadf99b6716db697a", 28 | "other": "* |/jitsi| - Starte ein Meeting\n* |/jitsi [Thema]| - Starte ein Meeting mit einem speziellen Thema\n* |/jitsi help| - Zeige diesen Hilfe-Text an\n* |/jitsi settings| - Zeige die die aktuellen Benutzereinstellungen an für das Jitsi Plugin\n* |/jitsi settings [Einstellung] [Wert]| - Aktualisiere die Benutzereinstellungen für das Jitsi Plugin (Siehe unten)\n\n###### Jitsi Einstellungen:\n* |/jitsi settings embedded [true/false]|: (Experimentell) Wenn auf `true` gesetzt, werden Jitsi Mettings als Fenster-Im-Festeris in Mattermost angezeigt. When auf `false` gesetzt, werden Jitsi Meetings in einem neuen Fenster geöffnet.\n* |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Select how meeting names are generated with one of these options:\n * |words|: Zufällige englische Wörter (z.B. PlayfulDragonsObserveCuriously)\n * |uuid|: Einzigartige Nummer\n * |mattermost|: Mattermost spezifischer Name. Eine Kombinations aus Teamname, Kanalname and zufälligem Text in öffentlichen und privaten Kanälen. Persönlicher Meeting Name in Direkt- und Gruppennachrichten.\n * |ask|: Frage beim Erstellen eines Meetings nach welcher Name verwendet werden soll." 29 | }, 30 | "jitsi.command.help.title": { 31 | "hash": "sha1-4323c51f8bbbb49db2a370dd496d29531a034b3c", 32 | "other": "###### Mattermost Jitsi Plugin - Hilfe zu den Slash-Befehlen\n" 33 | }, 34 | "jitsi.command.settings.current_values": { 35 | "hash": "sha1-fe2a45638786b11d783dedacabad04227391973b", 36 | "other": "###### Jitsi Einstellungen:\n* Fenster-in-Fenster: |{{.Embedded}}|\n* Naming Scheme: |{{.NamingScheme}}|" 37 | }, 38 | "jitsi.command.settings.invalid_parameters": { 39 | "hash": "sha1-f72c018607477396d845c7a3e11be73490b79a56", 40 | "other": "Ungültiger Einstellung" 41 | }, 42 | "jitsi.command.settings.unable_to_get": { 43 | "hash": "sha1-7e8adf669715dc73bf3f13568593cc30539af7ec", 44 | "other": "Abrufen der Benutzereinstellungen fehlgeschlagen" 45 | }, 46 | "jitsi.command.settings.unable_to_set": { 47 | "hash": "sha1-3bab2d5cd97e52eb365ab5e25aa5c89bf2a78f31", 48 | "other": "Speichern der Benutzereinstellungen fehlgeschlagen" 49 | }, 50 | "jitsi.command.settings.updated": { 51 | "hash": "sha1-ea98a80baec114b32359809c03c7b11038b18eb6", 52 | "other": "Benutzereinstellungen aktualisiert" 53 | }, 54 | "jitsi.command.settings.wrong_embedded_value": { 55 | "hash": "sha1-63273ba7ad2d4defa65c3c31765ed57668f157c3", 56 | "other": "Ungültiger `embedded` Wert, muss `true` or `false` sein." 57 | }, 58 | "jitsi.command.settings.wrong_field": { 59 | "hash": "sha1-75edec3f38b0abd2004b0d5cb5318462993f6a18", 60 | "other": "Ungültiger config field, use `embedded` or `naming_scheme`." 61 | }, 62 | "jitsi.command.settings.wrong_naming_scheme_value": { 63 | "hash": "sha1-fb39ca34d10fa7b9ccea30f0ebf7e1f7936b6b32", 64 | "other": "Ungültiger `naming_scheme` Wer, muss `ask`, `words`, `uuid` or `mattermost` sein." 65 | }, 66 | "jitsi.start_meeting.channel_meeting_topic": { 67 | "hash": "sha1-87eea3707ed3cad060b73cb43c0d4efe4ec8cc84", 68 | "other": "{{.ChannelName}} Kanal Meeting" 69 | }, 70 | "jitsi.start_meeting.default_meeting_topic": { 71 | "hash": "sha1-b057021a4c94f69bc1f90d9fa69a6165caeb4cce", 72 | "other": "Jitsi Meeting" 73 | }, 74 | "jitsi.start_meeting.fallback_text": { 75 | "hash": "sha1-e956f3c3fd1a6c3995976154e0f45099234a1f0a", 76 | "other": "Meeting gestartet: [{{.MeetingID}}]({{.MeetingURL}}).\n\n[Tritt Meeting bei]({{.MeetingURL}})" 77 | }, 78 | "jitsi.start_meeting.meeting_id": { 79 | "hash": "sha1-8a1315eaeb59e44132aa5c27e4f53e7ba92359f7", 80 | "other": "Meeting ID" 81 | }, 82 | "jitsi.start_meeting.meeting_link_valid_until": { 83 | "hash": "sha1-2707cc0764b955fca772a7dd5337e4aaa6018fcc", 84 | "other": "Meeting-Link gültig bis: {{.Datetime}}" 85 | }, 86 | "jitsi.start_meeting.personal_meeting_id": { 87 | "hash": "sha1-ba2b43d1c43e28926cc42c26651693567363dd77", 88 | "other": "Persönliche Meeting ID" 89 | }, 90 | "jitsi.start_meeting.personal_meeting_topic": { 91 | "hash": "sha1-22fab211c66c011f0e1f2ca6303443da6e20793e", 92 | "other": "{{.Name}}s persönliches Meeting" 93 | }, 94 | "jitsi.start_meeting.slack_attachment_text": { 95 | "hash": "sha1-7726587ccfee9bb7539aad458c154f8be3d95dcd", 96 | "other": "{{.MeetingType}}: [{{.MeetingID}}]({{.MeetingURL}})\n\n[Tritt Meeting bei]({{.MeetingURL}})" 97 | } 98 | } -------------------------------------------------------------------------------- /assets/i18n/active.es.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.ask.channel_meeting": { 3 | "hash": "sha1-c38fb5c89ece9869aa4b9b6d1d041ab64df1eb8f", 4 | "other": "Reunion del canal" 5 | }, 6 | "jitsi.ask.meeting_name_random_words": { 7 | "hash": "sha1-ee8f64a8a20eb04fefde418d9dc55d409664a2fb", 8 | "other": "Nombre de reunión con palabras aleatorias" 9 | }, 10 | "jitsi.ask.personal_meeting": { 11 | "hash": "sha1-9aa3e0500b4b0af16961174dcf792e9e11c5b220", 12 | "other": "Reunión personal" 13 | }, 14 | "jitsi.ask.select_meeting_type": { 15 | "hash": "sha1-e3bb1fbe427d3433e652b343086fa94926b8760b", 16 | "other": "Selecciona el tipo de reunión que quieres iniciar" 17 | }, 18 | "jitsi.ask.title": { 19 | "hash": "sha1-06bd54e7555f74e5c45f19f5d357cabf399226d8", 20 | "other": "Iniciar reunión Jitsi" 21 | }, 22 | "jitsi.ask.uuid_meeting": { 23 | "hash": "sha1-573ba198f97f8c38ee986a2cad4068d0e54c9dbd", 24 | "other": "Nombre de reunión con UUID" 25 | }, 26 | "jitsi.command.help.text": { 27 | "hash": "sha1-08e1afe25556ca110a40b00eadf99b6716db697a", 28 | "other": "* |/jitsi| - Crear nueva reunión\n* |/jitsi [team]| - Crea una nueva reunión con un tema específico\n* |/jitsi help| - Muestra este texto de ayuda\n* |/jitsi settings| - Ver tu configuracion actual para el plugin de Jitsi\n* |/jitsi settings [setting] [value]| - Actualiza tu configuracion (ver opciones abajo)\n\n###### Configuracion de Jitsi:\n* |/jitsi settings embedded [true/false]|: (Experimental) Cuando es true, la reunión Jitsi está dentro de Mattermost como una ventana flotante. Cuando es false, la reunión de Jitsi se abre en una ventana nueva.\n* |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Selecciona como se generan los nombres de las reuniones con una de estas opciones:\n * |words|: Palabras aletorias en ingles en formato title case (e.g. PlayfulDragonsObserveCuriously)\n * |uuid|: UUID (identificador único universal)\n * |mattermost|: Nombres espeicifcos de Mattermost. Una combinación de nombre de equipo, nombre de canal y un texto aleagorio para canales públicos y privados; salas de reuniones personales en mensajes directos y de grupo.\n * |ask|: La extensión te permitirá selecionar el tipo de nombre a usar cada vez que inicies una reunión." 29 | }, 30 | "jitsi.command.help.title": { 31 | "hash": "sha1-4323c51f8bbbb49db2a370dd496d29531a034b3c", 32 | "other": "###### Extension de Mattermost Jitsi - Ayuda del comando de barra\n" 33 | }, 34 | "jitsi.command.settings.current_values": { 35 | "hash": "sha1-fe2a45638786b11d783dedacabad04227391973b", 36 | "other": "###### Configuración de Jitsi:\n* Incrustado: `{{.Embedded}}`\n* Esquema de nombres: `{{.NamingScheme}}`" 37 | }, 38 | "jitsi.command.settings.invalid_parameters": { 39 | "hash": "sha1-f72c018607477396d845c7a3e11be73490b79a56", 40 | "other": "Parametros de configuración invalidos" 41 | }, 42 | "jitsi.command.settings.unable_to_get": { 43 | "hash": "sha1-7e8adf669715dc73bf3f13568593cc30539af7ec", 44 | "other": "No se ha podido obtener la configuración del usuario" 45 | }, 46 | "jitsi.command.settings.unable_to_set": { 47 | "hash": "sha1-3bab2d5cd97e52eb365ab5e25aa5c89bf2a78f31", 48 | "other": "No se ha podido guardar la configuración del usuario" 49 | }, 50 | "jitsi.command.settings.updated": { 51 | "hash": "sha1-ea98a80baec114b32359809c03c7b11038b18eb6", 52 | "other": "Configuración de Jitsi actualizada" 53 | }, 54 | "jitsi.command.settings.wrong_embedded_value": { 55 | "hash": "sha1-63273ba7ad2d4defa65c3c31765ed57668f157c3", 56 | "other": "Valor invalido para la configuración `embedded`, use `true` o `false`." 57 | }, 58 | "jitsi.command.settings.wrong_field": { 59 | "hash": "sha1-75edec3f38b0abd2004b0d5cb5318462993f6a18", 60 | "other": "Configuración no valida, use `embedded` o `naming_scheme`." 61 | }, 62 | "jitsi.command.settings.wrong_naming_scheme_value": { 63 | "hash": "sha1-fb39ca34d10fa7b9ccea30f0ebf7e1f7936b6b32", 64 | "other": "Valor de `naming_scheme` invalido, use `ask`, `words`, `uuid` o `mattermost`." 65 | }, 66 | "jitsi.start_meeting.channel_meeting_topic": { 67 | "hash": "sha1-87eea3707ed3cad060b73cb43c0d4efe4ec8cc84", 68 | "other": "Reunión del canal {{.ChannelName}}" 69 | }, 70 | "jitsi.start_meeting.default_meeting_topic": { 71 | "hash": "sha1-b057021a4c94f69bc1f90d9fa69a6165caeb4cce", 72 | "other": "Reunión Jitsi" 73 | }, 74 | "jitsi.start_meeting.fallback_text": { 75 | "hash": "sha1-e956f3c3fd1a6c3995976154e0f45099234a1f0a", 76 | "other": "Reunión de vidio iniciada en [{{.MeetingID}}]({{.MeetingURL}}).\n\n[Unirse a la reunión]({{.MeetingURL}})" 77 | }, 78 | "jitsi.start_meeting.meeting_id": { 79 | "hash": "sha1-8a1315eaeb59e44132aa5c27e4f53e7ba92359f7", 80 | "other": "ID de Reunion" 81 | }, 82 | "jitsi.start_meeting.meeting_link_valid_until": { 83 | "hash": "sha1-2707cc0764b955fca772a7dd5337e4aaa6018fcc", 84 | "other": "El enlace a la reunión es válido hasta: {{.Datetime}}" 85 | }, 86 | "jitsi.start_meeting.personal_meeting_id": { 87 | "hash": "sha1-ba2b43d1c43e28926cc42c26651693567363dd77", 88 | "other": "ID de sala personal de rueniones (PMI)" 89 | }, 90 | "jitsi.start_meeting.personal_meeting_topic": { 91 | "hash": "sha1-22fab211c66c011f0e1f2ca6303443da6e20793e", 92 | "other": "Sala de reuniones personal de {{.Name}}" 93 | }, 94 | "jitsi.start_meeting.slack_attachment_text": { 95 | "hash": "sha1-7726587ccfee9bb7539aad458c154f8be3d95dcd", 96 | "other": "{{.MeetingType}}: [{{.MeetingID}}]({{.MeetingURL}})\n\n[Unirse a la reunión]({{.MeetingURL}})" 97 | } 98 | } -------------------------------------------------------------------------------- /assets/i18n/active.fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "jitsi.ask.channel_meeting": { 3 | "hash": "sha1-c38fb5c89ece9869aa4b9b6d1d041ab64df1eb8f", 4 | "other": "Réunion de canal" 5 | }, 6 | "jitsi.ask.meeting_name_random_words": { 7 | "hash": "sha1-ee8f64a8a20eb04fefde418d9dc55d409664a2fb", 8 | "other": "Nom de réunion avec mots aléatoires" 9 | }, 10 | "jitsi.ask.personal_meeting": { 11 | "hash": "sha1-9aa3e0500b4b0af16961174dcf792e9e11c5b220", 12 | "other": "Réunion personnelle" 13 | }, 14 | "jitsi.ask.select_meeting_type": { 15 | "hash": "sha1-e3bb1fbe427d3433e652b343086fa94926b8760b", 16 | "other": "Choisissez le type de réunion que vous souhaitez lancer" 17 | }, 18 | "jitsi.ask.title": { 19 | "hash": "sha1-06bd54e7555f74e5c45f19f5d357cabf399226d8", 20 | "other": "Début de réunion Jitsi" 21 | }, 22 | "jitsi.ask.uuid_meeting": { 23 | "hash": "sha1-573ba198f97f8c38ee986a2cad4068d0e54c9dbd", 24 | "other": "Nom de réunion avec UUID" 25 | }, 26 | "jitsi.command.help.text": { 27 | "hash": "sha1-08e1afe25556ca110a40b00eadf99b6716db697a", 28 | "other": "* |/jitsi| - Création d'une nouvelle réunion\n* |/jitsi [topic]| - Création d'une réunion avec un sujet particulier\n* |/jitsi help| - Affiche ce message d'aide\n* |/jitsi settings| - Affiche vos paramètres actuels pour le plugin Jitsi\n* |/jitsi settings [setting] [value]| - Mets à jour vos paramètres (voir ci-dessous pour les options)\n\n###### Paramètres Jitsi:\n* |/jitsi settings embedded [true/false]|: (Expérimentale) Si \"true\", la réunion Jitsi est incluse en tant que fenêtre flottante dans Mattermost. Si \"false\", la réunion Jitsi s'ouvre dans une nouvelle fenêtre.\n* |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Sélectionne la manière dont sont générés les noms de réunion avec une de ces options:\n * |words|: Mots Anglais aléatoires en \"title case\" (ex. PlayfulDragonsObserveCuriously)\n * |uuid|: UUID (Identifiant unique universel)\n * |mattermost|: Noms spécifiques Mattermost. Combinaison de nom de Team, nom de canal, et mots aléatoires pour les canaux publics et privés; Salles de réunions personnelles dans les conversations privées ou de groupe.\n * |ask|: Le plugin vous demande de choisir le nom de la réunion à chaque fois que vous en démarrez une." 29 | }, 30 | "jitsi.command.help.title": { 31 | "hash": "sha1-4323c51f8bbbb49db2a370dd496d29531a034b3c", 32 | "other": "###### Mattermost Jitsi Plugin - Aide pour la commande oblique (/)\n" 33 | }, 34 | "jitsi.command.settings.current_values": { 35 | "hash": "sha1-fe2a45638786b11d783dedacabad04227391973b", 36 | "other": "###### Paramètres Jitsi:\n* Incrusté: |{{.Embedded}}|\n* Schéma de dénomination: |{{.NamingScheme}}|" 37 | }, 38 | "jitsi.command.settings.invalid_parameters": { 39 | "hash": "sha1-f72c018607477396d845c7a3e11be73490b79a56", 40 | "other": "Paramètres de configuration non valides" 41 | }, 42 | "jitsi.command.settings.unable_to_get": { 43 | "hash": "sha1-7e8adf669715dc73bf3f13568593cc30539af7ec", 44 | "other": "Impossible de récupérer les paramètres de l'utilisateur" 45 | }, 46 | "jitsi.command.settings.unable_to_set": { 47 | "hash": "sha1-3bab2d5cd97e52eb365ab5e25aa5c89bf2a78f31", 48 | "other": "Impossible de mettre à jour les paramètres de l'utilisateur" 49 | }, 50 | "jitsi.command.settings.updated": { 51 | "hash": "sha1-ea98a80baec114b32359809c03c7b11038b18eb6", 52 | "other": "Configuration du plugin mis à jour." 53 | }, 54 | "jitsi.command.settings.wrong_embedded_value": { 55 | "hash": "sha1-63273ba7ad2d4defa65c3c31765ed57668f157c3", 56 | "other": "Valeur `embedded` incorrecte, utilisez `true` ou `false`." 57 | }, 58 | "jitsi.command.settings.wrong_field": { 59 | "hash": "sha1-75edec3f38b0abd2004b0d5cb5318462993f6a18", 60 | "other": "Champ de configuration invalide, utilisez `embedded` ou `naming_scheme`." 61 | }, 62 | "jitsi.command.settings.wrong_naming_scheme_value": { 63 | "hash": "sha1-fb39ca34d10fa7b9ccea30f0ebf7e1f7936b6b32", 64 | "other": "Valeur `naming_scheme` invalide, utilisez `ask`, `words`, `uuid` ou `mattermost`." 65 | }, 66 | "jitsi.start_meeting.channel_meeting_topic": { 67 | "hash": "sha1-87eea3707ed3cad060b73cb43c0d4efe4ec8cc84", 68 | "other": "Réunion de canal {{.ChannelName}}" 69 | }, 70 | "jitsi.start_meeting.default_meeting_topic": { 71 | "hash": "sha1-b057021a4c94f69bc1f90d9fa69a6165caeb4cce", 72 | "other": "Réunion Jitsi" 73 | }, 74 | "jitsi.start_meeting.fallback_text": { 75 | "hash": "sha1-e956f3c3fd1a6c3995976154e0f45099234a1f0a", 76 | "other": "Réunion vidéo démarrée à [{{.MeetingID}}]({{.MeetingURL}}).\n\n[Join Meeting]({{.MeetingURL}})" 77 | }, 78 | "jitsi.start_meeting.meeting_id": { 79 | "hash": "sha1-8a1315eaeb59e44132aa5c27e4f53e7ba92359f7", 80 | "other": "Identifiant de réunion" 81 | }, 82 | "jitsi.start_meeting.meeting_link_valid_until": { 83 | "hash": "sha1-2707cc0764b955fca772a7dd5337e4aaa6018fcc", 84 | "other": "Lien de réunion valide jusqu'au : {{.Datetime}}" 85 | }, 86 | "jitsi.start_meeting.personal_meeting_id": { 87 | "hash": "sha1-ba2b43d1c43e28926cc42c26651693567363dd77", 88 | "other": "Identifiant de réunion personnel (PMI)" 89 | }, 90 | "jitsi.start_meeting.personal_meeting_topic": { 91 | "hash": "sha1-22fab211c66c011f0e1f2ca6303443da6e20793e", 92 | "other": "Réunion personnelle de {{.Name}}" 93 | }, 94 | "jitsi.start_meeting.slack_attachment_text": { 95 | "hash": "sha1-7726587ccfee9bb7539aad458c154f8be3d95dcd", 96 | "other": "{{.MeetingType}}: [{{.MeetingID}}]({{.MeetingURL}})\n\n[Rejoindre la réunion]({{.MeetingURL}})" 97 | } 98 | } -------------------------------------------------------------------------------- /server/configuration.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. 2 | // See License for license information. 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "net/url" 9 | "reflect" 10 | 11 | "github.com/mattermost/mattermost/server/public/model" 12 | "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger" 13 | "github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | // configuration captures the plugin's external configuration as exposed in the Mattermost server 18 | // configuration, as well as values computed from the configuration. Any public fields will be 19 | // deserialized from the Mattermost server configuration in OnConfigurationChange. 20 | // 21 | // As plugins are inherently concurrent (hooks being called asynchronously), and the plugin 22 | // configuration can change at any time, access to the configuration must be synchronized. The 23 | // strategy used in this plugin is to guard a pointer to the configuration, and clone the entire 24 | // struct whenever it changes. You may replace this with whatever strategy you choose. 25 | // 26 | // If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep 27 | // copy appropriate for your types. 28 | type configuration struct { 29 | JitsiURL string 30 | JitsiAppID string 31 | JitsiAppSecret string 32 | JitsiNamingScheme string 33 | JitsiLinkValidTime int 34 | JitsiJWT bool 35 | JitsiEmbedded bool 36 | JitsiCompatibilityMode bool 37 | JitsiPrejoinPage bool 38 | } 39 | 40 | const publicJitsiServerURL = "https://meet.jit.si" 41 | 42 | // GetJitsiURL return the currently configured JitsiURL or the URL from the 43 | // public servers provided by Jitsi. 44 | func (c *configuration) GetJitsiURL() string { 45 | if len(c.JitsiURL) > 0 { 46 | return c.JitsiURL 47 | } 48 | return publicJitsiServerURL 49 | } 50 | 51 | func (c *configuration) GetDefaultJitsiURL() string { 52 | return publicJitsiServerURL 53 | } 54 | 55 | // Clone shallow copies the configuration. Your implementation may require a deep copy if 56 | // your configuration has reference types. 57 | func (c *configuration) Clone() *configuration { 58 | var clone = *c 59 | return &clone 60 | } 61 | 62 | // IsValid checks if all needed fields are set. 63 | func (c *configuration) IsValid() error { 64 | if len(c.JitsiURL) > 0 { 65 | _, err := url.Parse(c.JitsiURL) 66 | if err != nil { 67 | return fmt.Errorf("error invalid jitsiURL") 68 | } 69 | } 70 | 71 | if c.JitsiJWT { 72 | if len(c.JitsiAppID) == 0 { 73 | return fmt.Errorf("error no Jitsi app ID was provided to use with JWT") 74 | } 75 | if len(c.JitsiAppSecret) == 0 { 76 | return fmt.Errorf("error no Jitsi app secret provided to use with JWT") 77 | } 78 | if c.JitsiLinkValidTime < 1 { 79 | c.JitsiLinkValidTime = 30 80 | } 81 | } 82 | 83 | return nil 84 | } 85 | 86 | // getConfiguration retrieves the active configuration under lock, making it safe to use 87 | // concurrently. The active configuration may change underneath the client of this method, but 88 | // the struct returned by this API call is considered immutable. 89 | func (p *Plugin) getConfiguration() *configuration { 90 | p.configurationLock.RLock() 91 | defer p.configurationLock.RUnlock() 92 | 93 | if p.configuration == nil { 94 | return &configuration{} 95 | } 96 | 97 | return p.configuration 98 | } 99 | 100 | // setConfiguration replaces the active configuration under lock. 101 | // 102 | // Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not 103 | // reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a 104 | // hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur. 105 | // 106 | // This method panics if setConfiguration is called with the existing configuration. This almost 107 | // certainly means that the configuration was modified without being cloned and may result in 108 | // an unsafe access. 109 | func (p *Plugin) setConfiguration(configuration *configuration) { 110 | p.configurationLock.Lock() 111 | defer p.configurationLock.Unlock() 112 | 113 | if configuration != nil && p.configuration == configuration { 114 | // Ignore assignment if the configuration struct is empty. Go will optimize the 115 | // allocation for same to point at the same memory address, breaking the check 116 | // above. 117 | if reflect.ValueOf(*configuration).NumField() == 0 { 118 | return 119 | } 120 | 121 | panic("setConfiguration called with the existing configuration") 122 | } 123 | 124 | p.API.PublishWebSocketEvent(configChangeEvent, nil, &model.WebsocketBroadcast{}) 125 | p.configuration = configuration 126 | } 127 | 128 | // OnConfigurationChange is invoked when configuration changes may have been made. 129 | func (p *Plugin) OnConfigurationChange() error { 130 | var configuration = new(configuration) 131 | 132 | // Load the public configuration fields from the Mattermost server configuration. 133 | if err := p.API.LoadPluginConfiguration(configuration); err != nil { 134 | return errors.Wrap(err, "failed to load plugin configuration") 135 | } 136 | 137 | p.tracker = telemetry.NewTracker(p.telemetryClient, p.API.GetDiagnosticId(), p.API.GetServerVersion(), manifest.Id, manifest.Version, "jitsi", telemetry.NewTrackerConfig(p.API.GetConfig()), logger.New(p.API)) 138 | 139 | p.setConfiguration(configuration) 140 | 141 | return nil 142 | } 143 | 144 | // OnDeactivate is invoked once the user disables the plugin 145 | func (p *Plugin) OnDeactivate() error { 146 | if p.telemetryClient != nil { 147 | err := p.telemetryClient.Close() 148 | if err != nil { 149 | p.API.LogWarn("OnDeactivate: failed to close telemetryClient", "error", err.Error()) 150 | } 151 | } 152 | 153 | return nil 154 | } 155 | -------------------------------------------------------------------------------- /webapp/src/components/post_type_jitsi/post_type_jitsi.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {jest, describe, expect, it} from '@jest/globals'; 3 | import {shallow} from 'enzyme'; 4 | 5 | import {Post} from 'mattermost-redux/types/posts'; 6 | 7 | import {PostTypeJitsi} from './post_type_jitsi'; 8 | import Constants from 'mattermost-redux/constants/general'; 9 | 10 | describe('PostTypeJitsi', () => { 11 | const basePost: Post = { 12 | id: 'test', 13 | create_at: 100, 14 | update_at: 100, 15 | edit_at: 100, 16 | delete_at: 100, 17 | message: 'test-message', 18 | is_pinned: false, 19 | user_id: 'test-user-id', 20 | channel_id: 'test-channel-id', 21 | root_id: '', 22 | parent_id: '', 23 | original_id: '', 24 | type: 'custom_jitsi', 25 | hashtags: '', 26 | pending_post_id: '', 27 | reply_count: 0, 28 | metadata: { 29 | embeds: [], 30 | emojis: [], 31 | files: [], 32 | images: {}, 33 | reactions: [] 34 | }, 35 | props: { 36 | jwt_meeting_valid_until: 123, 37 | meeting_link: 'http://test-meeting-link/test', 38 | jwt_meeting: true, 39 | meeting_jwt: 'xxxxxxxxxxxx', 40 | meeting_topic: 'Test topic', 41 | meeting_id: 'test', 42 | meeting_personal: false 43 | } 44 | }; 45 | 46 | const actions = { 47 | enrichMeetingJwt: jest.fn().mockImplementation(() => Promise.resolve({data: {jwt: 'test-enriched-jwt'}})), 48 | openJitsiMeeting: jest.fn(), 49 | setUserStatus: jest.fn().mockImplementation(() => Promise.resolve({data: {user_id: 'test-user-id', status: Constants.DND}})) 50 | }; 51 | 52 | const theme = { 53 | buttonColor: '#fabada' 54 | }; 55 | 56 | const defaultProps = { 57 | post: basePost, 58 | theme, 59 | creatorName: 'test', 60 | currentUser: { 61 | first_name: 'First', 62 | last_name: 'Last', 63 | username: 'firstLast' 64 | }, 65 | useMilitaryTime: false, 66 | meetingEmbedded: false, 67 | actions 68 | }; 69 | 70 | it('should render null if the post type is null', () => { 71 | defaultProps.actions.enrichMeetingJwt.mockClear(); 72 | const props = {...defaultProps}; 73 | delete props.post; 74 | const wrapper = shallow( 75 | 76 | ); 77 | expect(wrapper).toMatchSnapshot(); 78 | expect(defaultProps.actions.enrichMeetingJwt).not.toBeCalled(); 79 | }); 80 | 81 | it('should render a post if the post type is not null, and should try to enrich the token', () => { 82 | defaultProps.actions.enrichMeetingJwt.mockClear(); 83 | const wrapper = shallow( 84 | 85 | ); 86 | expect(defaultProps.actions.enrichMeetingJwt).toBeCalled(); 87 | expect(wrapper).toMatchSnapshot(); 88 | }); 89 | 90 | it('should render a post without token if there is no jwt token, and shouldn\'t try to enrich the token', () => { 91 | defaultProps.actions.enrichMeetingJwt.mockClear(); 92 | const props = { 93 | ...defaultProps, 94 | post: { 95 | ...defaultProps.post, 96 | props: { 97 | ...defaultProps.post.props, 98 | jwt_meeting: false 99 | } 100 | } 101 | }; 102 | 103 | const wrapper = shallow( 104 | 105 | ); 106 | expect(wrapper).toMatchSnapshot(); 107 | expect(defaultProps.actions.enrichMeetingJwt).not.toBeCalled(); 108 | }); 109 | 110 | it('should render the default topic if the topic is empty', () => { 111 | const props = { 112 | ...defaultProps, 113 | post: { 114 | ...defaultProps.post, 115 | props: { 116 | ...defaultProps.post.props, 117 | meeting_topic: null 118 | } 119 | } 120 | }; 121 | 122 | const wrapper = shallow( 123 | 124 | ); 125 | expect(wrapper.find('h1')).toMatchSnapshot(); 126 | }); 127 | 128 | it('should render the a different subtitle if the meeting is personal', () => { 129 | const props = { 130 | ...defaultProps, 131 | post: { 132 | ...defaultProps.post, 133 | props: { 134 | ...defaultProps.post.props, 135 | meeting_personal: true 136 | } 137 | } 138 | }; 139 | 140 | const wrapper = shallow( 141 | 142 | ); 143 | expect(wrapper).toMatchSnapshot(); 144 | }); 145 | 146 | it('should prevent the default link behavior and call the action to open jitsi if embedded is true', () => { 147 | defaultProps.actions.openJitsiMeeting.mockClear(); 148 | const props = { 149 | ...defaultProps, 150 | meetingEmbedded: true 151 | }; 152 | 153 | const wrapper = shallow(); 154 | const event = {preventDefault: jest.fn()}; 155 | wrapper.find('a.btn-primary').simulate('click', event); 156 | expect(defaultProps.actions.openJitsiMeeting).toBeCalled(); 157 | expect(event.preventDefault).toBeCalled(); 158 | }); 159 | 160 | it('should not prevent the default link behavior and should not call the action to open jitsi if embedded is false', () => { 161 | defaultProps.actions.openJitsiMeeting.mockClear(); 162 | const props = { 163 | ...defaultProps, 164 | meetingEmbedded: false 165 | }; 166 | 167 | const wrapper = shallow(); 168 | const event = {preventDefault: jest.fn()}; 169 | wrapper.find('a.btn-primary').simulate('click', event); 170 | expect(defaultProps.actions.openJitsiMeeting).not.toBeCalled(); 171 | expect(event.preventDefault).not.toBeCalled(); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Disclaimer 2 | 3 | **This repository is community supported and not maintained by Mattermost. Mattermost disclaims liability for integrations, including Third Party Integrations and Mattermost Integrations. Integrations may be modified or discontinued at any time.** 4 | 5 | # Mattermost Jitsi Plugin (Beta) 6 | 7 | [](https://circleci.com/gh/mattermost/mattermost-plugin-jitsi) 8 | [](https://codecov.io/gh/mattermost/mattermost-plugin-jitsi) 9 | [](https://github.com/mattermost/mattermost-plugin-jitsi/releases/latest) 10 | [](https://github.com/mattermost/mattermost-plugin-jitsi/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22Up+For+Grabs%22+label%3A%22Help+Wanted%22) 11 | 12 | **Maintainer:** [@mickmister](https://github.com/mickmister) 13 | **Originally developed by:** [Sean Sackowitz](https://github.com/seansackowitz). 14 | 15 | Start and join voice calls, video calls and use screen sharing with your team members with a Jitsi plugin for Mattermost. 16 | 17 | Clicking a video icon in a Mattermost channel posts a message that invites team members to join a Jitsi meetings call. 18 | 19 |  20 | 21 | ## Features 22 | 23 | - Use a `/jitsi` command to start a new meeting. Optionally append a desired meeting topic after the command. 24 | - Embed Jitsi meetings as a floating window inside Mattermost for a seamless experience. 25 | - Click a video icon in channel header to start a new Jitsi meeting in the channel. Not yet supported on mobile. 26 | - Use a `/jitsi settings` command to configure user preferences, including 27 | - whether Jitsi meetings appear as a floating window inside Mattermost or in a separate window 28 | - how meeting names are generated 29 | 30 | The plugin has been tested on Chrome, Firefox and the Mattermost Desktop Apps. 31 | 32 | ## Installation 33 | 34 | 1. In GitHub, go to the [Releases](https://github.com/mattermost/mattermost-plugin-jitsi/releases) directory to download the plugin file. 35 | 2. In Mattermost, go to **System Console > Plugins > Plugin Management**. 36 | 3. Select **Choose File** to upload the plugin file. 37 | 38 | ## Configuration 39 | 40 | Go to **System Console > Plugins > Jitsi** and set the following values: 41 | 42 | 1. **Enable Plugin**: **true**. 43 | 2. **Jitsi Server URL**: The URL for your Jitsi server. If you set the Jitsi Server URL to ``https://meet.jit.si``, it uses the public server provided by Jitsi. 44 | 3. **Embed Jitsi video inside Mattermost**: When **true**, Jitsi video is embedded as a floating window inside Mattermost. This feature is experimental. 45 | 4. (Optional) If your Jitsi server uses JSON Web Tokens (JWT) for authentication, set: 46 | 47 | - **Use JWT Authentication for Jitsi**: **true**. 48 | - **App ID** and **App Secret** used for JWT authentication. 49 | - **Meeting Link Expiry Time** in minutes. Defaults to 30 minutes. 50 | 51 | 5. **Jitsi Meeting Names**: Select how Jitsi meeting names are generated by default. The user can optionally override this setting for themselves via `/jitsi settings`. 52 | 53 | - Defaults to using random English words in title case, but you can also use a UUID as the meeting link, or the team and channel name where the Jitsi meeting is created. You can also allow the user to choose the meeting name each time by default. 54 | 55 | You're all set! To test it, go to any Mattermost channel and click the video icon in the channel header to start a new Jitsi meeting. 56 | 57 | ## Localization 58 | 59 | Mattermost Jitsi Plugin supports localization in multiple languages: 60 | - English 61 | - French 62 | - German 63 | - Spanish 64 | - Hungarian 65 | 66 | The plugin automatically displays languages based on the following: 67 | - For system messages, the locale set in **System Console > General > Localization > Default Server Language** is used. 68 | - For user messages, such as help text and error messages, the locale set set in **Account Settings > Display > Language** is used. 69 | 70 | ## Manual builds 71 | 72 | You can use Docker to compile the binaries yourself. Run `./docker-make` shell script which builds a Docker image with necessary build dependencies and runs `make all` afterwards. 73 | 74 | You can also use make targets like `dist` (`./docker-make dist`) from the [Makefile](./Makefile). 75 | 76 | ## Development 77 | 78 | This plugin contains both a server and web app portion. Read our documentation about the [Developer Workflow](https://developers.mattermost.com/integrate/plugins/developer-workflow/) and [Developer Setup](https://developers.mattermost.com/integrate/plugins/developer-setup/) for more information about developing and extending plugins. 79 | 80 | ### Releasing new versions 81 | 82 | The version of a plugin is determined at compile time, automatically populating a `version` field in the [plugin manifest](plugin.json): 83 | * If the current commit matches a tag, the version will match after stripping any leading `v`, e.g. `1.3.1`. 84 | * Otherwise, the version will combine the nearest tag with `git rev-parse --short HEAD`, e.g. `1.3.1+d06e53e1`. 85 | * If there is no version tag, an empty version will be combined with the short hash, e.g. `0.0.0+76081421`. 86 | 87 | To disable this behaviour, manually populate and maintain the `version` field. 88 | 89 | ### Server 90 | 91 | Inside the `/server` directory, you will find the Go files that make up the server-side of the plugin. Within there, build the plugin like you would any other Go application. 92 | 93 | ### Web App 94 | 95 | Inside the `/webapp` directory, you will find the JS and React files that make up the client-side of the plugin. Within there, modify files and components as necessary. Test your syntax by running `npm run build`. 96 | 97 | ## Contributing 98 | 99 | We welcome contributions for bug reports, issues, feature requests, feature implementations, and pull requests. Feel free to [file a new issue](https://github.com/mattermost/mattermost-plugin-jitsi/issues/new/choose) or join the [Plugin: Jitsi channel](https://community.mattermost.com/core/channels/plugin-jitsi) on the Mattermost community server. 100 | 101 | For a complete guide on contributing to the plugin, see the [Contribution Guidelines](CONTRIBUTING.md). 102 | -------------------------------------------------------------------------------- /server/randomNameGenerator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | "strings" 7 | 8 | "github.com/google/uuid" 9 | "github.com/mattermost/mattermost/server/public/shared/mlog" 10 | ) 11 | 12 | var PLURALNOUN = []string{"Aliens", "Animals", "Antelopes", "Ants", "Apes", "Apples", "Baboons", 13 | "Bacteria", "Badgers", "Bananas", "Bats", "Bears", "Birds", "Bonobos", 14 | "Brides", "Bugs", "Bulls", "Butterflies", "Cheetahs", "Cherries", "Chicken", 15 | "Children", "Chimps", "Clowns", "Cows", "Creatures", "Dinosaurs", "Dogs", 16 | "Dolphins", "Donkeys", "Dragons", "Ducks", "Dwarfs", "Eagles", "Elephants", 17 | "Elves", "Fathers", "Fish", "Flowers", "Frogs", "Fruit", "Fungi", 18 | "Galaxies", "Geese", "Goats", "Gorillas", "Hedgehogs", "Hippos", "Horses", 19 | "Hunters", "Insects", "Kids", "Knights", "Lemons", "Lemurs", "Leopards", 20 | "LifeForms", "Lions", "Lizards", "Mice", "Monkeys", "Monsters", "Mushrooms", 21 | "Octopodes", "Oranges", "Orangutans", "Organisms", "Pants", "Parrots", 22 | "Penguins", "People", "Pigeons", "Pigs", "Pineapples", "Plants", "Potatoes", 23 | "Priests", "Rats", "Reptiles", "Reptilians", "Rhinos", "Seagulls", "Sheep", 24 | "Siblings", "Snakes", "Spaghetti", "Spiders", "Squid", "Squirrels", 25 | "Stars", "Students", "Teachers", "Tigers", "Tomatoes", "Trees", "Vampires", 26 | "Vegetables", "Viruses", "Vulcans", "Weasels", "Werewolves", "Whales", 27 | "Witches", "Wizards", "Wolves", "Workers", "Worms", "Zebras"} 28 | 29 | var VERB = []string{"Abandon", "Adapt", "Advertise", "Answer", "Anticipate", "Appreciate", 30 | "Approach", "Argue", "Ask", "Bite", "Blossom", "Blush", "Breathe", "Breed", 31 | "Bribe", "Burn", "Calculate", "Clean", "Code", "Communicate", "Compute", 32 | "Confess", "Confiscate", "Conjugate", "Conjure", "Consume", "Contemplate", 33 | "Crawl", "Dance", "Delegate", "Devour", "Develop", "Differ", "Discuss", 34 | "Dissolve", "Drink", "Eat", "Elaborate", "Emancipate", "Estimate", "Expire", 35 | "Extinguish", "Extract", "Facilitate", "Fall", "Feed", "Finish", "Floss", 36 | "Fly", "Follow", "Fragment", "Freeze", "Gather", "Glow", "Grow", "Hex", 37 | "Hide", "Hug", "Hurry", "Improve", "Intersect", "Investigate", "Jinx", 38 | "Joke", "Jubilate", "Kiss", "Laugh", "Manage", "Meet", "Merge", "Move", 39 | "Object", "Observe", "Offer", "Paint", "Participate", "Party", "Perform", 40 | "Plan", "Pursue", "Pierce", "Play", "Postpone", "Pray", "Proclaim", 41 | "Question", "Read", "Reckon", "Rejoice", "Represent", "Resize", "Rhyme", 42 | "Scream", "Search", "Select", "Share", "Shoot", "Shout", "Signal", "Sing", 43 | "Skate", "Sleep", "Smile", "Smoke", "Solve", "Spell", "Steer", "Stink", 44 | "Substitute", "Swim", "Taste", "Teach", "Terminate", "Think", "Type", 45 | "Unite", "Vanish", "Worship"} 46 | 47 | var ADVERB = []string{"Absently", "Accurately", "Accusingly", "Adorably", "AllTheTime", "Alone", 48 | "Always", "Amazingly", "Angrily", "Anxiously", "Anywhere", "Appallingly", 49 | "Apparently", "Articulately", "Astonishingly", "Badly", "Barely", 50 | "Beautifully", "Blindly", "Bravely", "Brightly", "Briskly", "Brutally", 51 | "Calmly", "Carefully", "Casually", "Cautiously", "Cleverly", "Constantly", 52 | "Correctly", "Crazily", "Curiously", "Cynically", "Daily", "Dangerously", 53 | "Deliberately", "Delicately", "Desperately", "Discreetly", "Eagerly", 54 | "Easily", "Euphoricly", "Evenly", "Everywhere", "Exactly", "Expectantly", 55 | "Extensively", "Ferociously", "Fiercely", "Finely", "Flatly", "Frequently", 56 | "Frighteningly", "Gently", "Gloriously", "Grimly", "Guiltily", "Happily", 57 | "Hard", "Hastily", "Heroically", "High", "Highly", "Hourly", "Humbly", 58 | "Hysterically", "Immensely", "Impartially", "Impolitely", "Indifferently", 59 | "Intensely", "Jealously", "Jovially", "Kindly", "Lazily", "Lightly", 60 | "Loudly", "Lovingly", "Loyally", "Magnificently", "Malevolently", "Merrily", 61 | "Mightily", "Miserably", "Mysteriously", "NOT", "Nervously", "Nicely", 62 | "Nowhere", "Objectively", "Obnoxiously", "Obsessively", "Obviously", 63 | "Often", "Painfully", "Patiently", "Playfully", "Politely", "Poorly", 64 | "Precisely", "Promptly", "Quickly", "Quietly", "Randomly", "Rapidly", 65 | "Rarely", "Recklessly", "Regularly", "Remorsefully", "Responsibly", 66 | "Rudely", "Ruthlessly", "Sadly", "Scornfully", "Seamlessly", "Seldom", 67 | "Selfishly", "Seriously", "Shakily", "Sharply", "Sideways", "Silently", 68 | "Sleepily", "Slightly", "Slowly", "Slyly", "Smoothly", "Softly", "Solemnly", 69 | "Steadily", "Sternly", "Strangely", "Strongly", "Stunningly", "Surely", 70 | "Tenderly", "Thoughtfully", "Tightly", "Uneasily", "Vanishingly", 71 | "Violently", "Warmly", "Weakly", "Wearily", "Weekly", "Weirdly", "Well", 72 | "Well", "Wickedly", "Wildly", "Wisely", "Wonderfully", "Yearly"} 73 | 74 | var ADJECTIVE = []string{"Abominable", "Accurate", "Adorable", "All", "Alleged", "Ancient", "Angry", 75 | "Anxious", "Appalling", "Apparent", "Astonishing", "Attractive", "Awesome", 76 | "Baby", "Bad", "Beautiful", "Benign", "Big", "Bitter", "Blind", "Blue", 77 | "Bold", "Brave", "Bright", "Brisk", "Calm", "Camouflaged", "Casual", 78 | "Cautious", "Choppy", "Chosen", "Clever", "Cold", "Cool", "Crawly", 79 | "Crazy", "Creepy", "Cruel", "Curious", "Cynical", "Dangerous", "Dark", 80 | "Delicate", "Desperate", "Difficult", "Discreet", "Disguised", "Dizzy", 81 | "Dumb", "Eager", "Easy", "Edgy", "Electric", "Elegant", "Emancipated", 82 | "Enormous", "Euphoric", "Evil", "Fast", "Ferocious", "Fierce", "Fine", 83 | "Flawed", "Flying", "Foolish", "Foxy", "Freezing", "Funny", "Furious", 84 | "Gentle", "Glorious", "Golden", "Good", "Green", "Green", "Guilty", 85 | "Hairy", "Happy", "Hard", "Hasty", "Hazy", "Heroic", "Hostile", "Hot", 86 | "Humble", "Humongous", "Humorous", "Hysterical", "Idealistic", "Ignorant", 87 | "Immense", "Impartial", "Impolite", "Indifferent", "Infuriated", 88 | "Insightful", "Intense", "Interesting", "Intimidated", "Intriguing", 89 | "Jealous", "Jolly", "Jovial", "Jumpy", "Kind", "Laughing", "Lazy", "Liquid", 90 | "Lonely", "Longing", "Loud", "Loving", "Loyal", "Macabre", "Mad", "Magical", 91 | "Magnificent", "Malevolent", "Medieval", "Memorable", "Mere", "Merry", 92 | "Mighty", "Mischievous", "Miserable", "Modified", "Moody", "Most", 93 | "Mysterious", "Mystical", "Needy", "Nervous", "Nice", "Objective", 94 | "Obnoxious", "Obsessive", "Obvious", "Opinionated", "Orange", "Painful", 95 | "Passionate", "Perfect", "Pink", "Playful", "Poisonous", "Polite", "Poor", 96 | "Popular", "Powerful", "Precise", "Preserved", "Pretty", "Purple", "Quick", 97 | "Quiet", "Random", "Rapid", "Rare", "Real", "Reassuring", "Reckless", "Red", 98 | "Regular", "Remorseful", "Responsible", "Rich", "Rude", "Ruthless", "Sad", 99 | "Scared", "Scary", "Scornful", "Screaming", "Selfish", "Serious", "Shady", 100 | "Shaky", "Sharp", "Shiny", "Shy", "Simple", "Sleepy", "Slow", "Sly", 101 | "Small", "Smart", "Smelly", "Smiling", "Smooth", "Smug", "Sober", "Soft", 102 | "Solemn", "Square", "Square", "Steady", "Strange", "Strong", "Stunning", 103 | "Subjective", "Successful", "Surly", "Sweet", "Tactful", "Tense", 104 | "Thoughtful", "Tight", "Tiny", "Tolerant", "Uneasy", "Unique", "Unseen", 105 | "Warm", "Weak", "Weird", "WellCooked", "Wild", "Wise", "Witty", "Wonderful", 106 | "Worried", "Yellow", "Young", "Zealous"} 107 | 108 | var LETTERS = []rune("abcdefghijklmnopqrstuvwxyz") 109 | 110 | func randomInt(max int) int { 111 | value, err := rand.Int(rand.Reader, big.NewInt(int64(max))) 112 | if err != nil { 113 | mlog.Error("Error generating random number", mlog.Err(err)) 114 | panic(err.Error()) 115 | } 116 | return int(value.Int64()) 117 | } 118 | 119 | func randomElement(s []string) string { 120 | return s[randomInt(len(s))] 121 | } 122 | 123 | func randomString(runes []rune, n int) string { 124 | b := make([]rune, n) 125 | for i := range b { 126 | b[i] = runes[randomInt(len(runes))] 127 | } 128 | return string(b) 129 | } 130 | 131 | func generateEnglishName(delimiter string) string { 132 | var components = []string{ 133 | randomElement(ADJECTIVE), 134 | randomElement(PLURALNOUN), 135 | randomElement(VERB), 136 | randomElement(ADVERB), 137 | } 138 | return strings.Join(components, delimiter) 139 | } 140 | 141 | func generateEnglishTitleName() string { 142 | return generateEnglishName("") 143 | } 144 | 145 | func generateUUIDName() string { 146 | id := uuid.New() 147 | return (id.String()) 148 | } 149 | 150 | func generateTeamChannelName(teamName string, channelName string) string { 151 | name := teamName 152 | if name != "" { 153 | name += "-" 154 | } 155 | name += channelName 156 | name += "-" + randomString(LETTERS, 10) 157 | 158 | return name 159 | } 160 | 161 | func generatePersonalMeetingName(username string) string { 162 | return username + "-" + randomString(LETTERS, 20) 163 | } 164 | -------------------------------------------------------------------------------- /webapp/src/components/post_type_jitsi/post_type_jitsi.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {FormattedMessage} from 'react-intl'; 3 | import {Post} from 'mattermost-redux/types/posts'; 4 | import {Theme} from 'mattermost-redux/types/preferences'; 5 | import {ActionResult} from 'mattermost-redux/types/actions'; 6 | import Constants from 'mattermost-redux/constants/general'; 7 | import {UserProfile} from 'mattermost-redux/types/users'; 8 | 9 | import Svgs from 'constants/svgs'; 10 | 11 | import {makeStyleFromTheme} from 'mattermost-redux/utils/theme_utils'; 12 | 13 | export type Props = { 14 | post?: Post, 15 | theme: Theme, 16 | currentUser: UserProfile, 17 | creatorName: string, 18 | useMilitaryTime: boolean, 19 | meetingEmbedded: boolean, 20 | actions: { 21 | enrichMeetingJwt: (jwt: string) => Promise, 22 | openJitsiMeeting: (post: Post | null, jwt: string | null) => ActionResult, 23 | setUserStatus: (userId: string, status: string) => Promise, 24 | } 25 | } 26 | 27 | type State = { 28 | meetingJwt?: string, 29 | } 30 | 31 | export class PostTypeJitsi extends React.PureComponent { 32 | constructor(props: Props) { 33 | super(props); 34 | 35 | this.state = {}; 36 | } 37 | 38 | componentDidMount() { 39 | const {post} = this.props; 40 | if (post && post.props.jwt_meeting) { 41 | this.props.actions.enrichMeetingJwt(post.props.meeting_jwt).then((response: any) => { 42 | if (response.data) { 43 | this.setState({meetingJwt: response.data.jwt}); 44 | } 45 | }); 46 | } 47 | } 48 | 49 | openJitsiMeeting = (e: React.MouseEvent) => { 50 | if (this.props.meetingEmbedded) { 51 | e.preventDefault(); 52 | if (this.props.post) { 53 | // could be improved by using an enum in the future for the status 54 | this.props.actions.setUserStatus(this.props.currentUser.id, Constants.DND); 55 | this.props.actions.openJitsiMeeting(this.props.post, this.state.meetingJwt || this.props.post.props.meeting_jwt || null); 56 | } 57 | } else if (this.state.meetingJwt) { 58 | e.preventDefault(); 59 | if (this.props.post) { 60 | const props = this.props.post.props; 61 | let meetingLink = props.meeting_link + '?jwt=' + (this.state.meetingJwt); 62 | meetingLink += `#config.callDisplayName=${encodeURIComponent(`"${props.meeting_topic || props.default_meeting_topic}"`)}`; 63 | window.open(meetingLink, '_blank'); 64 | } 65 | } 66 | }; 67 | 68 | renderUntilDate = (post: Post, style: any): React.ReactNode => { 69 | const props = post.props; 70 | 71 | if (props.jwt_meeting) { 72 | const date = new Date(props.jwt_meeting_valid_until * 1000); 73 | let dateStr = props.jwt_meeting_valid_until; 74 | if (!isNaN(date.getTime())) { 75 | dateStr = date.toString(); 76 | } 77 | return ( 78 | 79 | 83 | {dateStr} 84 | 85 | ); 86 | } 87 | return null; 88 | }; 89 | 90 | render() { 91 | const style = getStyle(this.props.theme); 92 | const post = this.props.post; 93 | if (!post) { 94 | return null; 95 | } 96 | 97 | const props = post.props; 98 | 99 | let meetingLink = props.meeting_link; 100 | if (props.jwt_meeting) { 101 | meetingLink += '?jwt=' + encodeURIComponent(props.meeting_jwt); 102 | } 103 | 104 | meetingLink += `#config.callDisplayName=${encodeURIComponent(`"${props.meeting_topic || props.default_meeting_topic}"`)}`; 105 | meetingLink += `&userInfo.displayName=${encodeURIComponent(`"${this.props.currentUser.username}"`)}`; 106 | 107 | const preText = ( 108 | 113 | ); 114 | 115 | let subtitle = ( 116 | 120 | ); 121 | if (props.meeting_personal) { 122 | subtitle = ( 123 | 127 | ); 128 | } 129 | 130 | let title = ( 131 | 135 | ); 136 | if (props.meeting_topic) { 137 | title = props.meeting_topic; 138 | } 139 | 140 | return ( 141 | 142 | {preText} 143 | 144 | 145 | 146 | 147 | {title} 148 | 149 | 150 | {subtitle} 151 | 157 | {props.meeting_id} 158 | 159 | 160 | 161 | 162 | 163 | 171 | 175 | 179 | 180 | 181 | {this.renderUntilDate(post, style)} 182 | 183 | 184 | 185 | 186 | 187 | 188 | ); 189 | } 190 | } 191 | 192 | const getStyle = makeStyleFromTheme((theme) => { 193 | return { 194 | attachment: { 195 | marginLeft: '-20px', 196 | position: 'relative' 197 | }, 198 | content: { 199 | borderRadius: '4px', 200 | borderStyle: 'solid', 201 | borderWidth: '1px', 202 | borderColor: '#BDBDBF', 203 | margin: '5px 0 5px 20px', 204 | padding: '2px 5px' 205 | }, 206 | container: { 207 | borderLeftStyle: 'solid', 208 | borderLeftWidth: '4px', 209 | padding: '10px', 210 | borderLeftColor: '#89AECB' 211 | }, 212 | body: { 213 | overflowX: 'auto', 214 | overflowY: 'hidden', 215 | paddingRight: '5px', 216 | width: '100%' 217 | }, 218 | title: { 219 | fontSize: '16px', 220 | fontWeight: '600', 221 | height: '22px', 222 | lineHeight: '18px', 223 | margin: '5px 0 1px 0', 224 | padding: '0' 225 | }, 226 | button: { 227 | fontFamily: 'Open Sans', 228 | fontSize: '12px', 229 | fontWeight: 'bold', 230 | letterSpacing: '1px', 231 | lineHeight: '19px', 232 | marginTop: '12px', 233 | borderRadius: '4px', 234 | color: theme.buttonColor 235 | }, 236 | buttonIcon: { 237 | paddingRight: '8px', 238 | fill: theme.buttonColor 239 | }, 240 | validUntil: { 241 | marginTop: '10px' 242 | } 243 | }; 244 | }); 245 | -------------------------------------------------------------------------------- /webapp/src/components/conference/conference.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {describe, expect, it, jest} from '@jest/globals'; 3 | import {shallow} from 'enzyme'; 4 | 5 | import {Post, PostMetadata, PostType} from 'mattermost-redux/types/posts'; 6 | 7 | import Conference from './conference'; 8 | 9 | describe('Conference', () => { 10 | const basePost: Post = { 11 | id: 'test', 12 | create_at: 100, 13 | update_at: 100, 14 | edit_at: 100, 15 | delete_at: 100, 16 | message: 'test-message', 17 | is_pinned: false, 18 | user_id: 'test-user-id', 19 | channel_id: 'test-channel-id', 20 | root_id: '', 21 | parent_id: '', 22 | original_id: '', 23 | type: 'custom_jitsi' as PostType, 24 | hashtags: '', 25 | props: { 26 | jwt_meeting_valid_until: 123, 27 | meeting_link: 'http://test-meeting-link/test', 28 | jwt_meeting: true, 29 | meeting_jwt: 'xxxxxxxxxxxx', 30 | meeting_topic: 'Test topic', 31 | meeting_id: 'test', 32 | meeting_personal: false 33 | }, 34 | metadata: {} as PostMetadata, 35 | pending_post_id: 'test', 36 | reply_count: 100 37 | }; 38 | 39 | const actions = { 40 | openJitsiMeeting: jest.fn(), 41 | setUserStatus: jest.fn() 42 | }; 43 | 44 | const defaultProps = { 45 | post: basePost, 46 | jwt: null, 47 | actions, 48 | currentUser: { 49 | id: 'mockId', 50 | username: 'firstLast' 51 | } 52 | }; 53 | 54 | Conference.prototype.getViewportWidth = () => 10; 55 | Conference.prototype.getViewportHeight = () => 10; 56 | Conference.prototype.componentDidUpdate = jest.fn(); 57 | Conference.prototype.componentDidMount = jest.fn(); 58 | Conference.prototype.componentWillUnmount = jest.fn(); 59 | 60 | it('should render null if the post type is null', () => { 61 | const props = {...defaultProps, post: null}; 62 | const wrapper = shallow( 63 | 64 | ); 65 | expect(wrapper).toMatchSnapshot(); 66 | }); 67 | 68 | it('should render and initialize the conference interface', () => { 69 | const wrapper = shallow( 70 | 71 | ); 72 | expect(wrapper).toMatchSnapshot(); 73 | }); 74 | 75 | describe('should show the correct buttons depending on the state', () => { 76 | const wrapper = shallow( 77 | 78 | ); 79 | const instance = wrapper.instance(); 80 | 81 | it('should have down, open outside, maximize and close buttons', () => { 82 | instance.setState({minimized: true, position: 'top'}); 83 | expect(wrapper).toMatchSnapshot(); 84 | }); 85 | 86 | it('should have up, open outside, maximize and close buttons', () => { 87 | instance.setState({minimized: true, position: 'bottom'}); 88 | expect(wrapper).toMatchSnapshot(); 89 | }); 90 | 91 | it('should have open outside, minimize and close buttons', () => { 92 | instance.setState({minimized: false}); 93 | expect(wrapper).toMatchSnapshot(); 94 | }); 95 | }); 96 | 97 | describe('should show the the loading spinner depending on the state', () => { 98 | const wrapper = shallow( 99 | 100 | ); 101 | const instance = wrapper.instance(); 102 | 103 | it('should show loading', () => { 104 | instance.setState({loading: true}); 105 | expect(wrapper).toMatchSnapshot(); 106 | }); 107 | 108 | it('should not show loading', () => { 109 | instance.setState({loading: false}); 110 | expect(wrapper).toMatchSnapshot(); 111 | }); 112 | }); 113 | 114 | describe('should maximize based on the state', () => { 115 | const wrapper = shallow( 116 | 117 | ); 118 | const instance = wrapper.instance() as Conference; 119 | 120 | it('should toggle tile only if was open before minimize and now is closed', () => { 121 | instance.api = { 122 | executeCommand: jest.fn() 123 | }; 124 | instance.setState({wasTileView: true, isTileView: true}); 125 | instance.maximize(); 126 | expect(instance.api.executeCommand).not.toBeCalledWith('toggleTileView'); 127 | instance.api.executeCommand.mockClear(); 128 | 129 | instance.setState({wasTileView: true, isTileView: false}); 130 | instance.maximize(); 131 | expect(instance.api.executeCommand).toBeCalledWith('toggleTileView'); 132 | instance.api.executeCommand.mockClear(); 133 | 134 | instance.setState({wasTileView: false, isTileView: true}); 135 | instance.maximize(); 136 | expect(instance.api.executeCommand).toBeCalledWith('toggleTileView'); 137 | instance.api.executeCommand.mockClear(); 138 | 139 | instance.setState({wasTileView: false, isTileView: false}); 140 | instance.maximize(); 141 | expect(instance.api.executeCommand).not.toBeCalledWith('toggleTileView'); 142 | instance.api.executeCommand.mockClear(); 143 | }); 144 | 145 | it('should toggle filmstrip only if was open before minimize and now is closed', () => { 146 | instance.api = { 147 | executeCommand: jest.fn() 148 | }; 149 | instance.setState({wasFilmStrip: true, isFilmStrip: true}); 150 | instance.maximize(); 151 | expect(instance.api.executeCommand).not.toBeCalledWith('toggleFilmStrip'); 152 | instance.api.executeCommand.mockClear(); 153 | 154 | instance.setState({wasFilmStrip: true, isFilmStrip: false}); 155 | instance.maximize(); 156 | expect(instance.api.executeCommand).toBeCalledWith('toggleFilmStrip'); 157 | instance.api.executeCommand.mockClear(); 158 | 159 | instance.setState({wasFilmStrip: false, isFilmStrip: true}); 160 | instance.maximize(); 161 | expect(instance.api.executeCommand).toBeCalledWith('toggleFilmStrip'); 162 | instance.api.executeCommand.mockClear(); 163 | 164 | instance.setState({wasFilmStrip: false, isFilmStrip: false}); 165 | instance.maximize(); 166 | expect(instance.api.executeCommand).not.toBeCalledWith('toggleFilmStrip'); 167 | instance.api.executeCommand.mockClear(); 168 | }); 169 | }); 170 | 171 | describe('should minimize based on the state', () => { 172 | const wrapper = shallow( 173 | 174 | ); 175 | const instance = wrapper.instance() as Conference; 176 | 177 | it('should toggle tile only if is open before minimizing', () => { 178 | instance.api = { 179 | executeCommand: jest.fn() 180 | }; 181 | instance.setState({isTileView: true}); 182 | instance.minimize(); 183 | expect(instance.api.executeCommand).toBeCalledWith('toggleTileView'); 184 | instance.api.executeCommand.mockClear(); 185 | 186 | instance.setState({isTileView: false}); 187 | instance.minimize(); 188 | expect(instance.api.executeCommand).not.toBeCalledWith('toggleTileView'); 189 | instance.api.executeCommand.mockClear(); 190 | }); 191 | 192 | it('should toggle filmstrip only if is open before minimize', () => { 193 | instance.api = { 194 | executeCommand: jest.fn() 195 | }; 196 | instance.setState({isFilmStrip: true}); 197 | instance.minimize(); 198 | expect(instance.api.executeCommand).toBeCalledWith('toggleFilmStrip'); 199 | instance.api.executeCommand.mockClear(); 200 | 201 | instance.setState({isFilmStrip: false}); 202 | instance.minimize(); 203 | expect(instance.api.executeCommand).not.toBeCalledWith('toggleFilmStrip'); 204 | instance.api.executeCommand.mockClear(); 205 | }); 206 | }); 207 | 208 | it('should execute the hangup command, wait and call the action to close the meeting, and reset the state on closed', (done) => { 209 | defaultProps.actions.openJitsiMeeting.mockClear(); 210 | const wrapper = shallow( 211 | 212 | ); 213 | const instance = wrapper.instance() as Conference; 214 | instance.setState({ 215 | minimized: false, 216 | loading: false, 217 | position: 'top', 218 | wasTileView: false, 219 | isTileView: false, 220 | wasFilmStrip: false, 221 | isFilmStrip: false 222 | }); 223 | instance.api = { 224 | executeCommand: jest.fn(), 225 | dispose: jest.fn() 226 | }; 227 | instance.close(); 228 | expect(defaultProps.actions.openJitsiMeeting).not.toBeCalled(); 229 | expect(instance.api.executeCommand).toBeCalledWith('hangup'); 230 | setTimeout(() => { 231 | expect(defaultProps.actions.openJitsiMeeting).toBeCalledTimes(1); 232 | expect(defaultProps.actions.openJitsiMeeting).toBeCalledWith(null, null); 233 | expect(instance.api.dispose).toBeCalled(); 234 | expect(instance.state).toEqual({ 235 | minimized: true, 236 | loading: true, 237 | position: 'bottom', 238 | wasTileView: true, 239 | isTileView: true, 240 | wasFilmStrip: true, 241 | isFilmStrip: true 242 | }); 243 | 244 | if (done) { 245 | done(); 246 | } 247 | }, 210); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /server/command_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/mattermost/mattermost/server/public/model" 10 | "github.com/mattermost/mattermost/server/public/plugin" 11 | "github.com/mattermost/mattermost/server/public/plugin/plugintest" 12 | "github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry" 13 | "github.com/mattermost/mattermost/server/public/pluginapi/i18n" 14 | "github.com/stretchr/testify/mock" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestCommandHelp(t *testing.T) { 19 | p := Plugin{ 20 | configuration: &configuration{ 21 | JitsiURL: "http://test", 22 | }, 23 | botID: "test-bot-id", 24 | } 25 | apiMock := plugintest.API{} 26 | defer apiMock.AssertExpectations(t) 27 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user", Locale: "en"}, nil) 28 | apiMock.On("GetBundlePath").Return("..", nil) 29 | 30 | p.SetAPI(&apiMock) 31 | 32 | i18nBundle, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n")) 33 | require.Nil(t, err) 34 | p.b = i18nBundle 35 | 36 | helpText := strings.ReplaceAll(`###### Mattermost Jitsi Plugin - Slash Command help 37 | * |/jitsi| - Create a new meeting 38 | * |/jitsi start [topic]| - Create a new meeting with specified topic 39 | * |/jitsi help| - Show this help text 40 | * |/jitsi settings see| - View your current user settings for the Jitsi plugin 41 | * |/jitsi settings [setting] [value]| - Update your user settings (see below for options) 42 | 43 | ###### Jitsi Settings: 44 | * |/jitsi settings embedded [true/false]|: (Experimental) When true, Jitsi meeting is embedded as a floating window inside Mattermost. When false, Jitsi meeting opens in a new window. 45 | * |/jitsi settings show_prejoin_page [true/false]|: When false, pre-join page will not be displayed when Jitsi meet is embedded inside Mattermost. 46 | * |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Select how meeting names are generated with one of these options: 47 | * |words|: Random English words in title case (e.g. PlayfulDragonsObserveCuriously) 48 | * |uuid|: UUID (universally unique identifier) 49 | * |mattermost|: Mattermost specific names. Combination of team name, channel name and random text in public and private channels; personal meeting name in direct and group messages channels. 50 | * |ask|: The plugin asks you to select the name every time you start a meeting`, "|", "`") 51 | 52 | apiMock.On("SendEphemeralPost", "test-user", &model.Post{ 53 | UserId: "test-bot-id", 54 | ChannelId: "test-channel", 55 | Message: helpText, 56 | }).Return(nil) 57 | response, err := p.ExecuteCommand(&plugin.Context{}, &model.CommandArgs{UserId: "test-user", ChannelId: "test-channel", Command: "/jitsi help"}) 58 | require.Equal(t, &model.CommandResponse{}, response) 59 | require.Nil(t, err) 60 | } 61 | 62 | func TestCommandSettings(t *testing.T) { 63 | p := Plugin{ 64 | configuration: &configuration{ 65 | JitsiURL: "http://test", 66 | JitsiEmbedded: false, 67 | JitsiNamingScheme: "mattermost", 68 | JitsiPrejoinPage: true, 69 | }, 70 | botID: "test-bot-id", 71 | } 72 | 73 | tests := []struct { 74 | newConfig *UserConfig 75 | name string 76 | command string 77 | output string 78 | }{ 79 | { 80 | name: "set valid setting with valid value", 81 | command: "/jitsi settings embedded true", 82 | output: "Jitsi settings updated:\n\n* embedded: `true`", 83 | newConfig: &UserConfig{Embedded: true, NamingScheme: "mattermost", ShowPrejoinPage: true}, 84 | }, 85 | { 86 | name: "set valid setting with invalid value (embedded)", 87 | command: "/jitsi settings embedded yes", 88 | output: "Invalid `embedded` value, use `true` or `false`.", 89 | newConfig: nil, 90 | }, 91 | { 92 | name: "set valid setting with invalid value (naming_scheme)", 93 | command: "/jitsi settings naming_scheme yes", 94 | output: "Invalid `naming_scheme` value, use `ask`, `words`, `uuid` or `mattermost`.", 95 | newConfig: nil, 96 | }, 97 | { 98 | name: "set invalid setting", 99 | command: "/jitsi settings other true", 100 | output: "Invalid config field, use `embedded`, `show_prejoin_page` or `naming_scheme`.", 101 | newConfig: nil, 102 | }, 103 | { 104 | name: "set invalid number of parameters", 105 | command: "/jitsi settings other", 106 | output: "Invalid settings parameters", 107 | newConfig: nil, 108 | }, 109 | { 110 | name: "get current user settings", 111 | command: "/jitsi settings see", 112 | output: "###### Jitsi Settings:\n* Embedded: `false`\n* Show Pre-join Page: `true`\n* Naming Scheme: `mattermost`", 113 | newConfig: nil, 114 | }, 115 | } 116 | 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | apiMock := plugintest.API{} 120 | defer apiMock.AssertExpectations(t) 121 | p.SetAPI(&apiMock) 122 | 123 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user", Locale: "en"}, nil) 124 | apiMock.On("GetBundlePath").Return("..", nil) 125 | 126 | i18nBundle, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n")) 127 | require.Nil(t, err) 128 | p.b = i18nBundle 129 | 130 | apiMock.On("KVGet", "config_test-user", mock.Anything).Return(nil, nil) 131 | apiMock.On("SendEphemeralPost", "test-user", &model.Post{ 132 | UserId: "test-bot-id", 133 | ChannelId: "test-channel", 134 | Message: tt.output, 135 | }).Return(nil) 136 | if tt.newConfig != nil { 137 | b, _ := json.Marshal(tt.newConfig) 138 | apiMock.On("KVSet", "config_test-user", b).Return(nil) 139 | var nilMap map[string]interface{} 140 | apiMock.On("PublishWebSocketEvent", configChangeEvent, nilMap, &model.WebsocketBroadcast{UserId: "test-user"}) 141 | } 142 | response, err := p.ExecuteCommand(&plugin.Context{}, &model.CommandArgs{UserId: "test-user", ChannelId: "test-channel", Command: tt.command}) 143 | require.Equal(t, &model.CommandResponse{}, response) 144 | require.Nil(t, err) 145 | }) 146 | } 147 | } 148 | 149 | func TestCommandStartMeeting(t *testing.T) { 150 | p := Plugin{ 151 | configuration: &configuration{ 152 | JitsiURL: "http://test", 153 | }, 154 | } 155 | 156 | t.Run("meeting without topic and ask configuration", func(t *testing.T) { 157 | apiMock := plugintest.API{} 158 | defer apiMock.AssertExpectations(t) 159 | p.SetAPI(&apiMock) 160 | p.tracker = telemetry.NewTracker(nil, "", "", "", "", "", telemetry.TrackerConfig{}, nil) 161 | apiMock.On("GetBundlePath").Return("..", nil) 162 | config := model.Config{} 163 | config.SetDefaults() 164 | apiMock.On("GetConfig").Return(&config, nil) 165 | 166 | i18nBundle, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n")) 167 | require.Nil(t, err) 168 | p.b = i18nBundle 169 | 170 | apiMock.On("SendEphemeralPost", "test-user", mock.MatchedBy(func(post *model.Post) bool { 171 | return post.Props["attachments"].([]*model.SlackAttachment)[0].Text == "Select type of meeting you want to start" 172 | })).Return(nil) 173 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user"}, nil) 174 | apiMock.On("GetChannel", "test-channel").Return(&model.Channel{Id: "test-channel"}, nil) 175 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user"}, nil) 176 | b, _ := json.Marshal(UserConfig{Embedded: false, NamingScheme: "ask"}) 177 | apiMock.On("KVGet", "config_test-user", mock.Anything).Return(b, nil) 178 | 179 | response, err := p.ExecuteCommand(&plugin.Context{}, &model.CommandArgs{UserId: "test-user", ChannelId: "test-channel", Command: "/jitsi"}) 180 | require.Equal(t, &model.CommandResponse{}, response) 181 | require.Nil(t, err) 182 | }) 183 | 184 | t.Run("meeting without topic and no ask configuration", func(t *testing.T) { 185 | apiMock := plugintest.API{} 186 | defer apiMock.AssertExpectations(t) 187 | p.SetAPI(&apiMock) 188 | 189 | apiMock.On("GetBundlePath").Return("..", nil) 190 | config := model.Config{} 191 | config.SetDefaults() 192 | apiMock.On("GetConfig").Return(&config, nil) 193 | 194 | i18nBundle, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n")) 195 | require.Nil(t, err) 196 | p.b = i18nBundle 197 | 198 | apiMock.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { 199 | return strings.HasPrefix(post.Props["meeting_link"].(string), "http://test/") 200 | })).Return(&model.Post{}, nil) 201 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user"}, nil) 202 | apiMock.On("GetChannel", "test-channel").Return(&model.Channel{Id: "test-channel"}, nil) 203 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user"}, nil) 204 | apiMock.On("KVGet", "config_test-user", mock.Anything).Return(nil, nil) 205 | 206 | response, err := p.ExecuteCommand(&plugin.Context{}, &model.CommandArgs{UserId: "test-user", ChannelId: "test-channel", Command: "/jitsi"}) 207 | require.Equal(t, &model.CommandResponse{}, response) 208 | require.Nil(t, err) 209 | }) 210 | 211 | t.Run("meeting with topic", func(t *testing.T) { 212 | apiMock := plugintest.API{} 213 | defer apiMock.AssertExpectations(t) 214 | p.SetAPI(&apiMock) 215 | 216 | apiMock.On("GetBundlePath").Return("..", nil) 217 | config := model.Config{} 218 | config.SetDefaults() 219 | apiMock.On("GetConfig").Return(&config, nil) 220 | 221 | i18nBundle, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n")) 222 | require.Nil(t, err) 223 | p.b = i18nBundle 224 | 225 | apiMock.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { 226 | return strings.HasPrefix(post.Props["meeting_link"].(string), "http://test/topic") 227 | })).Return(&model.Post{}, nil) 228 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user"}, nil) 229 | apiMock.On("GetChannel", "test-channel").Return(&model.Channel{Id: "test-channel"}, nil) 230 | apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user"}, nil) 231 | apiMock.On("KVGet", "config_test-user", mock.Anything).Return(nil, nil) 232 | 233 | response, err := p.ExecuteCommand(&plugin.Context{}, &model.CommandArgs{UserId: "test-user", ChannelId: "test-channel", Command: "/jitsi start topic"}) 234 | require.Equal(t, &model.CommandResponse{}, response) 235 | require.Nil(t, err) 236 | }) 237 | } 238 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= $(shell command -v go 2> /dev/null) 2 | NPM ?= $(shell command -v npm 2> /dev/null) 3 | CURL ?= $(shell command -v curl 2> /dev/null) 4 | MM_DEBUG ?= 5 | GOPATH ?= $(shell go env GOPATH) 6 | GO_TEST_FLAGS ?= -race 7 | GO_BUILD_FLAGS ?= 8 | MM_UTILITIES_DIR ?= ../mattermost-utilities 9 | DLV_DEBUG_PORT := 2346 10 | DEFAULT_GOOS := $(shell go env GOOS) 11 | DEFAULT_GOARCH := $(shell go env GOARCH) 12 | 13 | export GO111MODULE=on 14 | 15 | # We need to export GOBIN to allow it to be set 16 | # for processes spawned from the Makefile 17 | export GOBIN ?= $(PWD)/bin 18 | 19 | # You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. 20 | ASSETS_DIR ?= assets 21 | 22 | ## Define the default target (make all) 23 | .PHONY: default 24 | default: all 25 | 26 | # Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. 27 | include build/setup.mk 28 | 29 | BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz 30 | 31 | # Include custom makefile, if present 32 | ifneq ($(wildcard build/custom.mk),) 33 | include build/custom.mk 34 | endif 35 | 36 | ifneq ($(MM_DEBUG),) 37 | GO_BUILD_GCFLAGS = -gcflags "all=-N -l" 38 | else 39 | GO_BUILD_GCFLAGS = 40 | endif 41 | 42 | ## Checks the code style, tests, builds and bundles the plugin. 43 | .PHONY: all 44 | all: check-style test dist 45 | 46 | ## Propagates plugin manifest information into the server/ and webapp/ folders. 47 | .PHONY: apply 48 | apply: 49 | ./build/bin/manifest apply 50 | 51 | ## Install go tools 52 | install-go-tools: 53 | @echo Installing go tools 54 | $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 55 | $(GO) install gotest.tools/gotestsum@v1.7.0 56 | 57 | ## Runs eslint and golangci-lint 58 | .PHONY: check-style 59 | check-style: apply webapp/node_modules install-go-tools 60 | @echo Checking for style guide compliance 61 | 62 | ifneq ($(HAS_WEBAPP),) 63 | cd webapp && npm run lint 64 | cd webapp && npm run check-types 65 | endif 66 | 67 | # It's highly recommended to run go-vet first 68 | # to find potential compile errors that could introduce 69 | # weird reports at golangci-lint step 70 | ifneq ($(HAS_SERVER),) 71 | @echo Running golangci-lint 72 | $(GO) vet ./... 73 | $(GOBIN)/golangci-lint run ./... 74 | endif 75 | 76 | ## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set. 77 | .PHONY: server 78 | server: 79 | ifneq ($(HAS_SERVER),) 80 | ifneq ($(MM_DEBUG),) 81 | $(info DEBUG mode is on; to disable, unset MM_DEBUG) 82 | endif 83 | mkdir -p server/dist; 84 | ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),) 85 | @echo Building plugin only for $(DEFAULT_GOOS)-$(DEFAULT_GOARCH) because MM_SERVICESETTINGS_ENABLEDEVELOPER is enabled 86 | cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH); 87 | else 88 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-amd64; 89 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-arm64; 90 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-amd64; 91 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-arm64; 92 | cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-windows-amd64.exe; 93 | endif 94 | endif 95 | 96 | ## Ensures NPM dependencies are installed without having to run this all the time. 97 | webapp/node_modules: $(wildcard webapp/package.json) 98 | ifneq ($(HAS_WEBAPP),) 99 | cd webapp && $(NPM) install 100 | touch $@ 101 | endif 102 | 103 | ## Builds the webapp, if it exists. 104 | .PHONY: webapp 105 | webapp: webapp/node_modules 106 | ifneq ($(HAS_WEBAPP),) 107 | ifeq ($(MM_DEBUG),) 108 | cd webapp && $(NPM) run build; 109 | else 110 | cd webapp && $(NPM) run debug; 111 | endif 112 | endif 113 | 114 | ## Generates a tar bundle of the plugin for install. 115 | .PHONY: bundle 116 | bundle: 117 | rm -rf dist/ 118 | mkdir -p dist/$(PLUGIN_ID) 119 | ./build/bin/manifest dist 120 | ifneq ($(wildcard $(ASSETS_DIR)/.),) 121 | cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ 122 | endif 123 | ifneq ($(HAS_PUBLIC),) 124 | cp -r public dist/$(PLUGIN_ID)/ 125 | endif 126 | ifneq ($(HAS_SERVER),) 127 | mkdir -p dist/$(PLUGIN_ID)/server 128 | cp -r server/dist dist/$(PLUGIN_ID)/server/ 129 | endif 130 | ifneq ($(HAS_WEBAPP),) 131 | mkdir -p dist/$(PLUGIN_ID)/webapp 132 | cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/ 133 | endif 134 | cd dist && tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) 135 | 136 | @echo plugin built at: dist/$(BUNDLE_NAME) 137 | 138 | ## Builds and bundles the plugin. 139 | .PHONY: dist 140 | dist: apply server webapp bundle 141 | 142 | ## Builds and installs the plugin to a server. 143 | .PHONY: deploy 144 | deploy: dist 145 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 146 | 147 | ## Builds and installs the plugin to a server, updating the webapp automatically when changed. 148 | .PHONY: watch 149 | watch: apply server bundle 150 | ifeq ($(MM_DEBUG),) 151 | cd webapp && $(NPM) run build:watch 152 | else 153 | cd webapp && $(NPM) run debug:watch 154 | endif 155 | 156 | ## Installs a previous built plugin with updated webpack assets to a server. 157 | .PHONY: deploy-from-watch 158 | deploy-from-watch: bundle 159 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 160 | 161 | ## Setup dlv for attaching, identifying the plugin PID for other targets. 162 | .PHONY: setup-attach 163 | setup-attach: 164 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 165 | $(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w)) 166 | 167 | @if [ ${NUM_PID} -gt 2 ]; then \ 168 | echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \ 169 | exit 1; \ 170 | fi 171 | 172 | ## Check if setup-attach succeeded. 173 | .PHONY: check-attach 174 | check-attach: 175 | @if [ -z ${PLUGIN_PID} ]; then \ 176 | echo "Could not find plugin PID; the plugin is not running. Exiting."; \ 177 | exit 1; \ 178 | else \ 179 | echo "Located Plugin running with PID: ${PLUGIN_PID}"; \ 180 | fi 181 | 182 | ## Attach dlv to an existing plugin instance. 183 | .PHONY: attach 184 | attach: setup-attach check-attach 185 | dlv attach ${PLUGIN_PID} 186 | 187 | ## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT. 188 | .PHONY: attach-headless 189 | attach-headless: setup-attach check-attach 190 | dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient 191 | 192 | ## Detach dlv from an existing plugin instance, if previously attached. 193 | .PHONY: detach 194 | detach: setup-attach 195 | @DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \ 196 | if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \ 197 | echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \ 198 | kill -9 $$DELVE_PID ; \ 199 | fi 200 | 201 | ## Runs any lints and unit tests defined for the server and webapp, if they exist. 202 | .PHONY: test 203 | test: apply webapp/node_modules install-go-tools 204 | ifneq ($(HAS_SERVER),) 205 | $(GOBIN)/gotestsum -- -v ./... 206 | endif 207 | ifneq ($(HAS_WEBAPP),) 208 | cd webapp && $(NPM) run test; 209 | endif 210 | 211 | ## Runs any lints and unit tests defined for the server and webapp, if they exist, optimized 212 | ## for a CI environment. 213 | .PHONY: test-ci 214 | test-ci: apply webapp/node_modules install-go-tools 215 | ifneq ($(HAS_SERVER),) 216 | $(GOBIN)/gotestsum --format standard-verbose --junitfile report.xml -- ./... 217 | endif 218 | ifneq ($(HAS_WEBAPP),) 219 | cd webapp && $(NPM) run test; 220 | endif 221 | 222 | ## Creates a coverage report for the server code. 223 | .PHONY: coverage 224 | coverage: apply webapp/node_modules 225 | ifneq ($(HAS_SERVER),) 226 | $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./server/... 227 | $(GO) tool cover -html=server/coverage.txt 228 | endif 229 | 230 | ## Extract strings for translation from the source code. 231 | .PHONY: i18n-extract 232 | i18n-extract: 233 | ifneq ($(HAS_WEBAPP),) 234 | ifeq ($(HAS_MM_UTILITIES),) 235 | @echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" 236 | else 237 | cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp 238 | endif 239 | endif 240 | 241 | ## Disable the plugin. 242 | .PHONY: disable 243 | disable: detach 244 | ./build/bin/pluginctl disable $(PLUGIN_ID) 245 | 246 | ## Enable the plugin. 247 | .PHONY: enable 248 | enable: 249 | ./build/bin/pluginctl enable $(PLUGIN_ID) 250 | 251 | ## Reset the plugin, effectively disabling and re-enabling it on the server. 252 | .PHONY: reset 253 | reset: detach 254 | ./build/bin/pluginctl reset $(PLUGIN_ID) 255 | 256 | ## Kill all instances of the plugin, detaching any existing dlv instance. 257 | .PHONY: kill 258 | kill: detach 259 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 260 | 261 | @for PID in ${PLUGIN_PID}; do \ 262 | echo "Killing plugin pid $$PID"; \ 263 | kill -9 $$PID; \ 264 | done; \ 265 | 266 | ## Clean removes all build artifacts. 267 | .PHONY: clean 268 | clean: 269 | rm -fr dist/ 270 | ifneq ($(HAS_SERVER),) 271 | rm -fr server/coverage.txt 272 | rm -fr server/dist 273 | endif 274 | ifneq ($(HAS_WEBAPP),) 275 | rm -fr webapp/junit.xml 276 | rm -fr webapp/dist 277 | rm -fr webapp/node_modules 278 | endif 279 | rm -fr build/bin/ 280 | 281 | .PHONY: logs 282 | logs: 283 | ./build/bin/pluginctl logs $(PLUGIN_ID) 284 | 285 | .PHONY: logs-watch 286 | logs-watch: 287 | ./build/bin/pluginctl logs-watch $(PLUGIN_ID) 288 | 289 | # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 290 | help: 291 | @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort 292 | -------------------------------------------------------------------------------- /server/api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | 12 | "github.com/mattermost/mattermost/server/public/model" 13 | "github.com/mattermost/mattermost/server/public/plugin" 14 | "github.com/mattermost/mattermost/server/public/shared/mlog" 15 | ) 16 | 17 | const externalAPICacheTTL = 3600000 18 | 19 | var externalAPICache []byte 20 | var externalAPILastUpdate int64 21 | var externalAPICacheMutex sync.Mutex 22 | 23 | type StartMeetingRequest struct { 24 | ChannelID string `json:"channel_id"` 25 | Topic string `json:"topic"` 26 | Personal bool `json:"personal"` 27 | MeetingID int `json:"meeting_id"` 28 | } 29 | 30 | type StartMeetingFromAction struct { 31 | model.PostActionIntegrationRequest 32 | Context struct { 33 | MeetingID string `json:"meeting_id"` 34 | MeetingTopic string `json:"meeting_topic"` 35 | RootID string `json:"root_id"` 36 | Personal bool `json:"personal"` 37 | } `json:"context"` 38 | } 39 | 40 | func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) { 41 | switch path := r.URL.Path; path { 42 | case "/api/v1/meetings/enrich": 43 | p.handleEnrichMeetingJwt(w, r) 44 | case "/api/v1/meetings": 45 | p.handleStartMeeting(w, r) 46 | case "/api/v1/config": 47 | p.handleConfig(w, r) 48 | case "/jitsi_meet_external_api.js": 49 | p.handleExternalAPIjs(w, r) 50 | default: 51 | http.NotFound(w, r) 52 | } 53 | } 54 | 55 | func (p *Plugin) handleConfig(w http.ResponseWriter, r *http.Request) { 56 | userID := r.Header.Get("Mattermost-User-Id") 57 | 58 | if userID == "" { 59 | http.Error(w, "Not authorized", http.StatusUnauthorized) 60 | return 61 | } 62 | 63 | config, err := p.getUserConfig(userID) 64 | if err != nil { 65 | mlog.Error("Error getting user config", mlog.Err(err)) 66 | http.Error(w, "Internal error", http.StatusInternalServerError) 67 | return 68 | } 69 | 70 | b, err := json.Marshal(config) 71 | if err != nil { 72 | mlog.Error("Error marshaling the Config to json", mlog.Err(err)) 73 | http.Error(w, "Internal error", http.StatusInternalServerError) 74 | return 75 | } 76 | w.Header().Set("Content-Type", "application/json") 77 | _, err = w.Write(b) 78 | if err != nil { 79 | mlog.Warn("Unable to write response body", mlog.String("handler", "handleConfig"), mlog.Err(err)) 80 | } 81 | } 82 | 83 | func (p *Plugin) handleExternalAPIjs(w http.ResponseWriter, r *http.Request) { 84 | if p.getConfiguration().JitsiCompatibilityMode { 85 | p.proxyExternalAPIjs(w, r) 86 | return 87 | } 88 | 89 | bundlePath, err := p.API.GetBundlePath() 90 | if err != nil { 91 | mlog.Error("Filed to get the bundle path") 92 | http.Error(w, "Internal error", http.StatusInternalServerError) 93 | return 94 | } 95 | externalAPIPath := filepath.Join(bundlePath, "assets", "external_api.js") 96 | externalAPIFile, err := os.Open(externalAPIPath) 97 | if err != nil { 98 | mlog.Error("Error opening file", mlog.String("path", externalAPIPath), mlog.Err(err)) 99 | http.Error(w, "Internal error", http.StatusInternalServerError) 100 | return 101 | } 102 | code, err := io.ReadAll(externalAPIFile) 103 | if err != nil { 104 | mlog.Error("Error reading file content", mlog.String("path", externalAPIPath), mlog.Err(err)) 105 | http.Error(w, "Internal error", http.StatusInternalServerError) 106 | return 107 | } 108 | w.Header().Set("Content-Type", "application/javascript") 109 | _, err = w.Write(code) 110 | if err != nil { 111 | mlog.Warn("Unable to write response body", mlog.String("handler", "proxyExternalAPIjs"), mlog.Err(err)) 112 | } 113 | } 114 | 115 | func (p *Plugin) proxyExternalAPIjs(w http.ResponseWriter, _ *http.Request) { 116 | externalAPICacheMutex.Lock() 117 | defer externalAPICacheMutex.Unlock() 118 | 119 | if externalAPICache != nil && externalAPILastUpdate > (model.GetMillis()-externalAPICacheTTL) { 120 | w.Header().Set("Content-Type", "application/javascript") 121 | _, _ = w.Write(externalAPICache) 122 | return 123 | } 124 | resp, err := http.Get(p.getConfiguration().GetJitsiURL() + "/external_api.js") 125 | if err != nil { 126 | mlog.Error("Error getting the external_api.js file from your Jitsi instance, please verify your JitsiURL setting", mlog.Err(err)) 127 | http.Error(w, "Internal error", http.StatusInternalServerError) 128 | return 129 | } 130 | defer resp.Body.Close() 131 | body, err := io.ReadAll(resp.Body) 132 | if err != nil { 133 | mlog.Error("Error getting reading the content", mlog.String("url", p.getConfiguration().GetJitsiURL()+"/external_api.js"), mlog.Err(err)) 134 | http.Error(w, "Internal error", http.StatusInternalServerError) 135 | return 136 | } 137 | externalAPICache = body 138 | externalAPILastUpdate = model.GetMillis() 139 | w.Header().Set("Content-Type", "application/javascript") 140 | _, err = w.Write(body) 141 | if err != nil { 142 | mlog.Warn("Unable to write response body", mlog.String("handler", "proxyExternalAPIjs"), mlog.Err(err)) 143 | } 144 | } 145 | 146 | func (p *Plugin) handleStartMeeting(w http.ResponseWriter, r *http.Request) { 147 | if err := p.getConfiguration().IsValid(); err != nil { 148 | mlog.Error("Invalid plugin configuration", mlog.Err(err)) 149 | http.Error(w, err.Error(), http.StatusInternalServerError) 150 | return 151 | } 152 | 153 | userID := r.Header.Get("Mattermost-User-Id") 154 | 155 | if userID == "" { 156 | http.Error(w, "Not authorized", http.StatusUnauthorized) 157 | return 158 | } 159 | 160 | user, appErr := p.API.GetUser(userID) 161 | if appErr != nil { 162 | mlog.Debug("Unable to the user", mlog.Err(appErr)) 163 | http.Error(w, "Forbidden", http.StatusForbidden) 164 | return 165 | } 166 | 167 | var req StartMeetingRequest 168 | var action StartMeetingFromAction 169 | 170 | bodyData, err := io.ReadAll(r.Body) 171 | if err != nil { 172 | mlog.Debug("Unable to read request body", mlog.Err(err)) 173 | http.Error(w, err.Error(), http.StatusBadRequest) 174 | return 175 | } 176 | 177 | err1 := json.NewDecoder(bytes.NewReader(bodyData)).Decode(&req) 178 | err2 := json.NewDecoder(bytes.NewReader(bodyData)).Decode(&action) 179 | if err1 != nil && err2 != nil { 180 | mlog.Debug("Unable to decode the request content as start meeting request or start meeting action") 181 | http.Error(w, "Unable to decode your request", http.StatusBadRequest) 182 | return 183 | } 184 | 185 | channelID := req.ChannelID 186 | if channelID == "" { 187 | channelID = action.ChannelId 188 | } 189 | 190 | if _, err := p.API.GetChannelMember(channelID, userID); err != nil { 191 | http.Error(w, "Forbidden", http.StatusForbidden) 192 | return 193 | } 194 | 195 | channel, appErr := p.API.GetChannel(channelID) 196 | if appErr != nil { 197 | http.Error(w, "Forbidden", http.StatusForbidden) 198 | return 199 | } 200 | 201 | userConfig, err := p.getUserConfig(userID) 202 | if err != nil { 203 | mlog.Error("Error getting user config", mlog.Err(err)) 204 | http.Error(w, err.Error(), http.StatusInternalServerError) 205 | return 206 | } 207 | 208 | if userConfig.NamingScheme == jitsiNameSchemeAsk && action.PostId == "" { 209 | err = p.askMeetingType(user, channel, "") 210 | if err != nil { 211 | mlog.Error("Error asking the user for meeting name type", mlog.Err(err)) 212 | http.Error(w, err.Error(), http.StatusInternalServerError) 213 | return 214 | } 215 | 216 | _, err = w.Write([]byte("OK")) 217 | if err != nil { 218 | mlog.Warn("Unable to write response body", mlog.String("handler", "handleStartMeeting"), mlog.Err(err)) 219 | } 220 | return 221 | } 222 | 223 | var meetingID string 224 | if userConfig.NamingScheme == jitsiNameSchemeAsk && action.PostId != "" { 225 | meetingID, err = p.startMeeting(user, channel, action.Context.MeetingID, action.Context.MeetingTopic, action.Context.Personal, "") 226 | if err != nil { 227 | mlog.Error("Error starting a new meeting from ask response", mlog.Err(err)) 228 | http.Error(w, err.Error(), http.StatusInternalServerError) 229 | return 230 | } 231 | p.deleteEphemeralPost(action.UserId, action.PostId) 232 | } else { 233 | meetingID, err = p.startMeeting(user, channel, "", req.Topic, req.Personal, action.Context.RootID) 234 | if err != nil { 235 | mlog.Error("Error starting a new meeting", mlog.Err(err)) 236 | http.Error(w, err.Error(), http.StatusInternalServerError) 237 | return 238 | } 239 | } 240 | 241 | b, err := json.Marshal(map[string]string{"meeting_id": meetingID}) 242 | if err != nil { 243 | mlog.Error("Error marshaling the MeetingID to json", mlog.Err(err)) 244 | http.Error(w, "Internal error", http.StatusInternalServerError) 245 | return 246 | } 247 | w.Header().Set("Content-Type", "application/json") 248 | _, err = w.Write(b) 249 | if err != nil { 250 | mlog.Warn("Unable to write response body", mlog.String("handler", "handleStartMeeting"), mlog.Err(err)) 251 | } 252 | } 253 | 254 | func (p *Plugin) handleEnrichMeetingJwt(w http.ResponseWriter, r *http.Request) { 255 | if err := p.getConfiguration().IsValid(); err != nil { 256 | mlog.Error("Invalid plugin configuration", mlog.Err(err)) 257 | http.Error(w, err.Error(), http.StatusInternalServerError) 258 | return 259 | } 260 | 261 | userID := r.Header.Get("Mattermost-User-Id") 262 | if userID == "" { 263 | http.Error(w, "Not authorized", http.StatusUnauthorized) 264 | return 265 | } 266 | 267 | var req EnrichMeetingJwtRequest 268 | 269 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 270 | mlog.Debug("Unable to read request body", mlog.Err(err)) 271 | http.Error(w, err.Error(), http.StatusBadRequest) 272 | } 273 | 274 | var user *model.User 275 | var err *model.AppError 276 | user, err = p.API.GetUser(userID) 277 | if err != nil { 278 | http.Error(w, err.Error(), err.StatusCode) 279 | } 280 | 281 | JWTMeeting := p.getConfiguration().JitsiJWT 282 | 283 | if !JWTMeeting { 284 | http.Error(w, "Not authorized", http.StatusUnauthorized) 285 | return 286 | } 287 | 288 | meetingJWT, err2 := p.updateJwtUserInfo(req.Jwt, user) 289 | if err2 != nil { 290 | mlog.Error("Error updating JWT context", mlog.Err(err2)) 291 | http.Error(w, "Internal error", http.StatusInternalServerError) 292 | return 293 | } 294 | 295 | b, err2 := json.Marshal(map[string]interface{}{"jwt": meetingJWT}) 296 | if err2 != nil { 297 | mlog.Error("Error marshaling the JWT json", mlog.Err(err2)) 298 | http.Error(w, "Internal error", http.StatusInternalServerError) 299 | return 300 | } 301 | w.Header().Set("Content-Type", "application/json") 302 | _, err2 = w.Write(b) 303 | if err2 != nil { 304 | mlog.Warn("Unable to write response body", mlog.String("handler", "handleEnrichMeetingJwt"), mlog.Err(err)) 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 40 | 42 | 43 | 45 | image/svg+xml 46 | 48 | 49 | 50 | 51 | 52 | 54 | 62 | 66 | 67 | -------------------------------------------------------------------------------- /webapp/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended" 4 | ], 5 | "parserOptions": { 6 | "ecmaVersion": 8, 7 | "sourceType": "module", 8 | "ecmaFeatures": { 9 | "jsx": true, 10 | "impliedStrict": true, 11 | "modules": true, 12 | "experimentalObjectRestSpread": true 13 | } 14 | }, 15 | "parser": "@typescript-eslint/parser", 16 | "plugins": [ 17 | "react", 18 | "@typescript-eslint" 19 | ], 20 | "env": { 21 | "browser": true, 22 | "node": true, 23 | "jquery": true, 24 | "es6": true, 25 | "jest": true 26 | }, 27 | "globals": { 28 | "jest": true, 29 | "describe": true, 30 | "it": true, 31 | "expect": true, 32 | "before": true, 33 | "after": true 34 | }, 35 | "rules": { 36 | "array-bracket-spacing": [2, "never"], 37 | "array-callback-return": 2, 38 | "arrow-body-style": 0, 39 | "arrow-parens": [2, "always"], 40 | "arrow-spacing": [2, { "before": true, "after": true }], 41 | "block-scoped-var": 2, 42 | "brace-style": [2, "1tbs", { "allowSingleLine": false }], 43 | "camelcase": [2, {"properties": "never"}], 44 | "capitalized-comments": 0, 45 | "class-methods-use-this": 0, 46 | "comma-dangle": [2, "never"], 47 | "comma-spacing": [2, {"before": false, "after": true}], 48 | "comma-style": [2, "last"], 49 | "complexity": [1, 10], 50 | "computed-property-spacing": [2, "never"], 51 | "consistent-return": 2, 52 | "consistent-this": [2, "self"], 53 | "constructor-super": 2, 54 | "curly": [2, "all"], 55 | "dot-location": [2, "object"], 56 | "eqeqeq": [2, "smart"], 57 | "func-call-spacing": [2, "never"], 58 | "func-name-matching": 0, 59 | "func-names": 2, 60 | "func-style": [2, "declaration", {"allowArrowFunctions": true}], 61 | "generator-star-spacing": [2, {"before": false, "after": true}], 62 | "global-require": 2, 63 | "guard-for-in": 2, 64 | "id-blacklist": 0, 65 | "indent": [2, 4, {"SwitchCase": 0}], 66 | "jsx-quotes": [2, "prefer-single"], 67 | "key-spacing": [2, {"beforeColon": false, "afterColon": true, "mode": "strict"}], 68 | "keyword-spacing": [2, {"before": true, "after": true, "overrides": {}}], 69 | "line-comment-position": 0, 70 | "linebreak-style": 2, 71 | "max-lines": [1, {"max": 450, "skipBlankLines": true, "skipComments": false}], 72 | "max-nested-callbacks": [2, {"max":2}], 73 | "max-statements-per-line": [2, {"max": 1}], 74 | "multiline-ternary": [1, "never"], 75 | "new-cap": 2, 76 | "new-parens": 2, 77 | "newline-before-return": 0, 78 | "newline-per-chained-call": 0, 79 | "no-alert": 2, 80 | "no-array-constructor": 2, 81 | "no-await-in-loop": 2, 82 | "no-caller": 2, 83 | "no-case-declarations": 2, 84 | "no-class-assign": 2, 85 | "no-compare-neg-zero": 2, 86 | "no-cond-assign": [2, "except-parens"], 87 | "no-confusing-arrow": 2, 88 | "no-console": 2, 89 | "no-const-assign": 2, 90 | "no-constant-condition": 2, 91 | "no-debugger": 2, 92 | "no-div-regex": 2, 93 | "no-dupe-args": 2, 94 | "no-dupe-class-members": 2, 95 | "no-dupe-keys": 2, 96 | "no-duplicate-case": 2, 97 | "no-duplicate-imports": [2, {"includeExports": true}], 98 | "no-else-return": 2, 99 | "no-empty": 2, 100 | "no-empty-pattern": 2, 101 | "no-eval": 2, 102 | "no-ex-assign": 2, 103 | "no-extend-native": 2, 104 | "no-extra-bind": 2, 105 | "no-extra-label": 2, 106 | "no-extra-parens": 0, 107 | "no-extra-semi": 2, 108 | "no-fallthrough": 2, 109 | "no-floating-decimal": 2, 110 | "no-func-assign": 2, 111 | "no-global-assign": 2, 112 | "no-implicit-coercion": 2, 113 | "no-implicit-globals": 0, 114 | "no-implied-eval": 2, 115 | "no-inner-declarations": 0, 116 | "no-invalid-regexp": 2, 117 | "no-irregular-whitespace": 2, 118 | "no-iterator": 2, 119 | "no-labels": 2, 120 | "no-lone-blocks": 2, 121 | "no-lonely-if": 2, 122 | "no-loop-func": 2, 123 | "no-magic-numbers": [1, { "ignore": [-1, 0, 1, 2], "enforceConst": true, "detectObjects": true } ], 124 | "no-mixed-operators": [2, {"allowSamePrecedence": false}], 125 | "no-mixed-spaces-and-tabs": 2, 126 | "no-multi-assign": 2, 127 | "no-multi-spaces": [2, { "exceptions": { "Property": false } }], 128 | "no-multi-str": 0, 129 | "no-multiple-empty-lines": [2, {"max": 1}], 130 | "no-native-reassign": 2, 131 | "no-negated-condition": 2, 132 | "no-nested-ternary": 2, 133 | "no-new": 2, 134 | "no-new-func": 2, 135 | "no-new-object": 2, 136 | "no-new-symbol": 2, 137 | "no-new-wrappers": 2, 138 | "no-octal-escape": 2, 139 | "no-param-reassign": 2, 140 | "no-process-env": 2, 141 | "no-process-exit": 2, 142 | "no-proto": 2, 143 | "no-redeclare": 2, 144 | "no-return-assign": [2, "always"], 145 | "no-return-await": 2, 146 | "no-script-url": 2, 147 | "no-self-assign": [2, {"props": true}], 148 | "no-self-compare": 2, 149 | "no-sequences": 2, 150 | "no-shadow": [2, {"hoist": "functions"}], 151 | "no-shadow-restricted-names": 2, 152 | "no-spaced-func": 2, 153 | "no-tabs": 0, 154 | "no-template-curly-in-string": 2, 155 | "no-ternary": 0, 156 | "no-this-before-super": 2, 157 | "no-throw-literal": 2, 158 | "no-trailing-spaces": [2, { "skipBlankLines": false }], 159 | "no-undef-init": 2, 160 | "no-undefined": 2, 161 | "no-underscore-dangle": 2, 162 | "no-unexpected-multiline": 2, 163 | "no-unmodified-loop-condition": 2, 164 | "no-unneeded-ternary": [2, {"defaultAssignment": false}], 165 | "no-unreachable": 2, 166 | "no-unsafe-finally": 2, 167 | "no-unsafe-negation": 2, 168 | "no-unused-expressions": 2, 169 | "no-unused-vars": "off", 170 | "@typescript-eslint/no-unused-vars": [ 171 | "error" 172 | ], 173 | "no-useless-computed-key": 2, 174 | "no-useless-concat": 2, 175 | "no-useless-constructor": 2, 176 | "no-useless-escape": 2, 177 | "no-useless-rename": 2, 178 | "no-useless-return": 2, 179 | "no-var": 0, 180 | "no-void": 2, 181 | "no-warning-comments": 1, 182 | "no-whitespace-before-property": 2, 183 | "no-with": 2, 184 | "object-curly-newline": 0, 185 | "object-curly-spacing": [2, "never"], 186 | "object-property-newline": [2, {"allowMultiplePropertiesPerLine": true}], 187 | "object-shorthand": [2, "always"], 188 | "one-var": [2, "never"], 189 | "one-var-declaration-per-line": 0, 190 | "operator-assignment": [2, "always"], 191 | "operator-linebreak": [2, "after"], 192 | "padded-blocks": [2, "never"], 193 | "prefer-arrow-callback": 2, 194 | "prefer-const": 2, 195 | "prefer-destructuring": 0, 196 | "prefer-numeric-literals": 2, 197 | "prefer-promise-reject-errors": 2, 198 | "prefer-rest-params": 2, 199 | "prefer-spread": 2, 200 | "prefer-template": 0, 201 | "quote-props": [2, "as-needed"], 202 | "quotes": [2, "single", "avoid-escape"], 203 | "radix": 2, 204 | "@typescript-eslint/explicit-member-accessibility": 0, 205 | "react/display-name": [2, { "ignoreTranspilerName": false }], 206 | "react/forbid-component-props": 0, 207 | "react/forbid-elements": [2, { "forbid": ["embed"] }], 208 | "react/jsx-boolean-value": [2, "always"], 209 | "react/jsx-closing-bracket-location": [2, { "location": "tag-aligned" }], 210 | "react/jsx-curly-spacing": [2, "never"], 211 | "react/jsx-equals-spacing": [2, "never"], 212 | "react/jsx-filename-extension": [2, { "extensions": [".tsx"] }], 213 | "react/jsx-first-prop-new-line": [2, "multiline"], 214 | "react/jsx-handler-names": 0, 215 | "react/jsx-indent": [2, 4], 216 | "react/jsx-indent-props": [2, 4], 217 | "react/jsx-key": 2, 218 | "react/jsx-max-props-per-line": [2, { "maximum": 1 }], 219 | "react/jsx-no-bind": 0, 220 | "react/jsx-no-comment-textnodes": 2, 221 | "react/jsx-no-duplicate-props": [2, { "ignoreCase": false }], 222 | "react/jsx-no-literals": 2, 223 | "react/jsx-no-target-blank": 2, 224 | "react/jsx-no-undef": 2, 225 | "react/jsx-pascal-case": 2, 226 | "react/jsx-tag-spacing": [2, { "closingSlash": "never", "beforeSelfClosing": "never", "afterOpening": "never" }], 227 | "react/jsx-uses-react": 2, 228 | "react/jsx-uses-vars": 2, 229 | "react/jsx-wrap-multilines": 2, 230 | "react/no-array-index-key": 1, 231 | "react/no-children-prop": 2, 232 | "react/no-danger": 0, 233 | "react/no-danger-with-children": 2, 234 | "react/no-deprecated": 2, 235 | "react/no-did-mount-set-state": 2, 236 | "react/no-did-update-set-state": 2, 237 | "react/no-direct-mutation-state": 2, 238 | "react/no-find-dom-node": 1, 239 | "react/no-is-mounted": 2, 240 | "react/no-multi-comp": [2, { "ignoreStateless": true }], 241 | "react/no-render-return-value": 2, 242 | "react/no-set-state": 0, 243 | "react/no-string-refs": 0, 244 | "react/no-unescaped-entities": 2, 245 | "react/no-unknown-property": 2, 246 | "react/no-unused-prop-types": [1, {"skipShapeProps": true}], 247 | "react/prefer-es6-class": 2, 248 | "react/prefer-stateless-function": 0, 249 | "react/prop-types": 2, 250 | "react/require-default-props": 0, 251 | "react/require-optimization": 1, 252 | "react/require-render-return": 2, 253 | "react/self-closing-comp": 2, 254 | "react/sort-comp": 0, 255 | "react/style-prop-object": 2, 256 | "require-yield": 2, 257 | "rest-spread-spacing": [2, "never"], 258 | "semi": [2, "always"], 259 | "semi-spacing": [2, {"before": false, "after": true}], 260 | "sort-imports": 0, 261 | "sort-keys": 0, 262 | "space-before-blocks": [2, "always"], 263 | "space-before-function-paren": [2, {"anonymous": "never", "named": "never", "asyncArrow": "always"}], 264 | "space-in-parens": [2, "never"], 265 | "space-infix-ops": 2, 266 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 267 | "symbol-description": 2, 268 | "template-curly-spacing": [2, "never"], 269 | "valid-typeof": [2, {"requireStringLiterals": false}], 270 | "vars-on-top": 0, 271 | "wrap-iife": [2, "outside"], 272 | "wrap-regex": 2, 273 | "yoda": [2, "never", {"exceptRange": false, "onlyEquality": false}] 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /server/command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/mattermost/mattermost/server/public/model" 8 | "github.com/mattermost/mattermost/server/public/plugin" 9 | "github.com/mattermost/mattermost/server/public/pluginapi/experimental/command" 10 | "github.com/mattermost/mattermost/server/public/shared/mlog" 11 | "github.com/nicksnyder/go-i18n/v2/i18n" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | const jitsiCommand = "jitsi" 16 | 17 | const jitsiSettingsSeeCommand = "see" 18 | const jitsiStartCommand = "start" 19 | 20 | const valueTrue = "true" 21 | const valueFalse = "false" 22 | 23 | const commandArgShowPrejoinPage = "show_prejoin_page" 24 | const commandArgEmbedded = "embedded" 25 | const commandArgNamingScheme = "naming_scheme" 26 | 27 | func startMeetingError(channelID string, detailedError string) (*model.CommandResponse, *model.AppError) { 28 | return &model.CommandResponse{ 29 | ResponseType: model.CommandResponseTypeEphemeral, 30 | ChannelId: channelID, 31 | Text: "We could not start a meeting at this time.", 32 | }, &model.AppError{ 33 | Message: "We could not start a meeting at this time.", 34 | DetailedError: detailedError, 35 | } 36 | } 37 | 38 | func (p *Plugin) createJitsiCommand() (*model.Command, error) { 39 | iconData, err := command.GetIconData(p.API, "assets/icon.svg") 40 | if err != nil { 41 | return nil, errors.Wrap(err, "failed to get icon data") 42 | } 43 | return &model.Command{ 44 | Trigger: jitsiCommand, 45 | AutoComplete: true, 46 | AutoCompleteDesc: "Start a Jitsi meeting in current channel. Other available commands: start, help, settings", 47 | AutoCompleteHint: "[command]", 48 | AutocompleteData: getAutocompleteData(), 49 | AutocompleteIconData: iconData, 50 | }, nil 51 | } 52 | 53 | func getAutocompleteData() *model.AutocompleteData { 54 | jitsi := model.NewAutocompleteData("jitsi", "[command]", "Start a Jitsi meeting in current channel. Other available commands: start, help, settings") 55 | 56 | start := model.NewAutocompleteData(jitsiStartCommand, "[topic]", "Start a new meeting in the current channel") 57 | start.AddTextArgument("(optional) The topic of the new meeting", "[topic]", "") 58 | jitsi.AddCommand(start) 59 | 60 | help := model.NewAutocompleteData("help", "", "Get slash command help") 61 | jitsi.AddCommand(help) 62 | 63 | settings := model.NewAutocompleteData("settings", "[setting] [value]", "Update your user settings (see /jitsi help for available options)") 64 | 65 | see := model.NewAutocompleteData(jitsiSettingsSeeCommand, "", "See your current settings") 66 | settings.AddCommand(see) 67 | 68 | embedded := model.NewAutocompleteData(commandArgEmbedded, "[value]", "Choose where the Jitsi meeting should open") 69 | items := []model.AutocompleteListItem{{ 70 | HelpText: "Jitsi meeting is embedded as a floating window inside Mattermost", 71 | Item: valueTrue, 72 | }, { 73 | HelpText: "Jitsi meeting opens in a new window", 74 | Item: valueFalse, 75 | }} 76 | embedded.AddStaticListArgument("Choose where the Jitsi meeting should open", true, items) 77 | settings.AddCommand(embedded) 78 | 79 | showPrejoinPage := model.NewAutocompleteData(commandArgShowPrejoinPage, "[value]", "Choose whether the pre-join page should be visible on Jitsi meeting in embedded mode") 80 | items = []model.AutocompleteListItem{{ 81 | HelpText: "Pre-join page on Jitsi meeting will be displayed", 82 | Item: valueTrue, 83 | }, { 84 | HelpText: "Pre-join page on Jitsi meeting will not be displayed", 85 | Item: valueFalse, 86 | }} 87 | showPrejoinPage.AddStaticListArgument("Choose whether the pre-join page should be visible on Jitsi meeting in embedded mode", true, items) 88 | settings.AddCommand(showPrejoinPage) 89 | 90 | namingScheme := model.NewAutocompleteData(commandArgNamingScheme, "[value]", "Select how meeting names are generated") 91 | items = []model.AutocompleteListItem{{ 92 | HelpText: "Random English words in title case (e.g. PlayfulDragonsObserveCuriously)", 93 | Item: "words", 94 | }, { 95 | HelpText: "UUID (universally unique identifier)", 96 | Item: "uuid", 97 | }, { 98 | HelpText: "Mattermost specific names. Combination of team name, channel name and random text in public and private channels; personal meeting name in direct and group messages channels", 99 | Item: "mattermost", 100 | }, { 101 | HelpText: "The plugin asks you to select the name every time you start a meeting", 102 | Item: "ask", 103 | }} 104 | namingScheme.AddStaticListArgument("Choose where the Jitsi meeting should open", true, items) 105 | settings.AddCommand(namingScheme) 106 | jitsi.AddCommand(settings) 107 | 108 | return jitsi 109 | } 110 | 111 | func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { 112 | split := strings.Fields(args.Command) 113 | command := split[0] 114 | var parameters []string 115 | action := "" 116 | if len(split) > 1 { 117 | action = split[1] 118 | } 119 | if len(split) > 2 { 120 | parameters = split[2:] 121 | } 122 | 123 | if command != "/"+jitsiCommand { 124 | return &model.CommandResponse{}, nil 125 | } 126 | 127 | switch action { 128 | case "help": 129 | return p.executeHelpCommand(c, args) 130 | 131 | case "settings": 132 | return p.executeSettingsCommand(c, args, parameters) 133 | 134 | case jitsiStartCommand: 135 | fallthrough 136 | default: 137 | return p.executeStartMeetingCommand(c, args) 138 | } 139 | } 140 | 141 | func (p *Plugin) executeStartMeetingCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { 142 | input := strings.TrimSpace(strings.TrimPrefix(args.Command, "/"+jitsiCommand)) 143 | input = strings.TrimSpace(strings.TrimPrefix(input, jitsiStartCommand)) 144 | 145 | user, appErr := p.API.GetUser(args.UserId) 146 | if appErr != nil { 147 | return startMeetingError(args.ChannelId, fmt.Sprintf("getUser() threw error: %s", appErr)) 148 | } 149 | 150 | channel, appErr := p.API.GetChannel(args.ChannelId) 151 | if appErr != nil { 152 | return startMeetingError(args.ChannelId, fmt.Sprintf("getChannel() threw error: %s", appErr)) 153 | } 154 | 155 | userConfig, err := p.getUserConfig(args.UserId) 156 | if err != nil { 157 | return startMeetingError(args.ChannelId, fmt.Sprintf("getChannel() threw error: %s", err)) 158 | } 159 | 160 | if userConfig.NamingScheme == jitsiNameSchemeAsk && input == "" { 161 | if err := p.askMeetingType(user, channel, args.RootId); err != nil { 162 | return startMeetingError(args.ChannelId, fmt.Sprintf("startMeeting() threw error: %s", appErr)) 163 | } 164 | } else { 165 | if _, err := p.startMeeting(user, channel, "", input, false, args.RootId); err != nil { 166 | return startMeetingError(args.ChannelId, fmt.Sprintf("startMeeting() threw error: %s", appErr)) 167 | } 168 | } 169 | 170 | p.trackMeeting(args) 171 | 172 | return &model.CommandResponse{}, nil 173 | } 174 | 175 | func (p *Plugin) executeHelpCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { 176 | l := p.b.GetUserLocalizer(args.UserId) 177 | helpTitle := p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 178 | DefaultMessage: &i18n.Message{ 179 | ID: "jitsi.command.help.title", 180 | Other: `###### Mattermost Jitsi Plugin - Slash Command help 181 | `, 182 | }, 183 | }) 184 | commandHelp := p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 185 | DefaultMessage: &i18n.Message{ 186 | ID: "jitsi.command.help.text", 187 | Other: `* |/jitsi| - Create a new meeting 188 | * |/jitsi start [topic]| - Create a new meeting with specified topic 189 | * |/jitsi help| - Show this help text 190 | * |/jitsi settings see| - View your current user settings for the Jitsi plugin 191 | * |/jitsi settings [setting] [value]| - Update your user settings (see below for options) 192 | 193 | ###### Jitsi Settings: 194 | * |/jitsi settings embedded [true/false]|: (Experimental) When true, Jitsi meeting is embedded as a floating window inside Mattermost. When false, Jitsi meeting opens in a new window. 195 | * |/jitsi settings show_prejoin_page [true/false]|: When false, pre-join page will not be displayed when Jitsi meet is embedded inside Mattermost. 196 | * |/jitsi settings naming_scheme [words/uuid/mattermost/ask]|: Select how meeting names are generated with one of these options: 197 | * |words|: Random English words in title case (e.g. PlayfulDragonsObserveCuriously) 198 | * |uuid|: UUID (universally unique identifier) 199 | * |mattermost|: Mattermost specific names. Combination of team name, channel name and random text in public and private channels; personal meeting name in direct and group messages channels. 200 | * |ask|: The plugin asks you to select the name every time you start a meeting`, 201 | }, 202 | }) 203 | 204 | text := helpTitle + strings.ReplaceAll(commandHelp, "|", "`") 205 | post := &model.Post{ 206 | UserId: p.botID, 207 | ChannelId: args.ChannelId, 208 | Message: text, 209 | RootId: args.RootId, 210 | } 211 | _ = p.API.SendEphemeralPost(args.UserId, post) 212 | 213 | return &model.CommandResponse{}, nil 214 | } 215 | 216 | func (p *Plugin) settingsError(userID string, channelID string, errorText string, rootID string) (*model.CommandResponse, *model.AppError) { 217 | post := &model.Post{ 218 | UserId: p.botID, 219 | ChannelId: channelID, 220 | Message: errorText, 221 | RootId: rootID, 222 | } 223 | _ = p.API.SendEphemeralPost(userID, post) 224 | 225 | return &model.CommandResponse{}, nil 226 | } 227 | 228 | func (p *Plugin) executeSettingsCommand(_ *plugin.Context, args *model.CommandArgs, parameters []string) (*model.CommandResponse, *model.AppError) { 229 | l := p.b.GetUserLocalizer(args.UserId) 230 | text := "" 231 | 232 | userConfig, err := p.getUserConfig(args.UserId) 233 | if err != nil { 234 | mlog.Debug("Unable to get user config", mlog.Err(err)) 235 | return p.settingsError(args.UserId, args.ChannelId, p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 236 | DefaultMessage: &i18n.Message{ 237 | ID: "jitsi.command.settings.unable_to_get", 238 | Other: "Unable to get user settings", 239 | }, 240 | }), args.RootId) 241 | } 242 | 243 | if len(parameters) == 0 || parameters[0] == jitsiSettingsSeeCommand { 244 | text = p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 245 | DefaultMessage: &i18n.Message{ 246 | ID: "jitsi.command.settings.current_values", 247 | Other: `###### Jitsi Settings: 248 | * Embedded: |{{.Embedded}}| 249 | * Show Pre-join Page: |{{.ShowPrejoinPage}}| 250 | * Naming Scheme: |{{.NamingScheme}}|`, 251 | }, 252 | TemplateData: map[string]string{ 253 | "Embedded": fmt.Sprintf("%v", userConfig.Embedded), 254 | "ShowPrejoinPage": fmt.Sprintf("%v", userConfig.ShowPrejoinPage), 255 | "NamingScheme": userConfig.NamingScheme, 256 | }, 257 | }) 258 | post := &model.Post{ 259 | UserId: p.botID, 260 | ChannelId: args.ChannelId, 261 | Message: strings.ReplaceAll(text, "|", "`"), 262 | RootId: args.RootId, 263 | } 264 | _ = p.API.SendEphemeralPost(args.UserId, post) 265 | 266 | return &model.CommandResponse{}, nil 267 | } 268 | 269 | if len(parameters) != 2 { 270 | return p.settingsError(args.UserId, args.ChannelId, p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 271 | DefaultMessage: &i18n.Message{ 272 | ID: "jitsi.command.settings.invalid_parameters", 273 | Other: "Invalid settings parameters", 274 | }, 275 | }), args.RootId) 276 | } 277 | 278 | switch parameters[0] { 279 | case commandArgEmbedded: 280 | switch parameters[1] { 281 | case valueTrue: 282 | userConfig.Embedded = true 283 | case valueFalse: 284 | userConfig.Embedded = false 285 | default: 286 | text = p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 287 | DefaultMessage: &i18n.Message{ 288 | ID: "jitsi.command.settings.wrong_embedded_value", 289 | Other: "Invalid `embedded` value, use `true` or `false`.", 290 | }, 291 | }) 292 | userConfig = nil 293 | } 294 | case commandArgNamingScheme: 295 | switch parameters[1] { 296 | case jitsiNameSchemeAsk: 297 | userConfig.NamingScheme = "ask" 298 | case jitsiNameSchemeWords: 299 | userConfig.NamingScheme = "words" 300 | case jitsiNameSchemeUUID: 301 | userConfig.NamingScheme = "uuid" 302 | case jitsiNameSchemeMattermost: 303 | userConfig.NamingScheme = "mattermost" 304 | default: 305 | text = p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 306 | DefaultMessage: &i18n.Message{ 307 | ID: "jitsi.command.settings.wrong_naming_scheme_value", 308 | Other: "Invalid `naming_scheme` value, use `ask`, `words`, `uuid` or `mattermost`.", 309 | }, 310 | }) 311 | userConfig = nil 312 | } 313 | case commandArgShowPrejoinPage: 314 | switch parameters[1] { 315 | case valueTrue: 316 | userConfig.ShowPrejoinPage = true 317 | case valueFalse: 318 | userConfig.ShowPrejoinPage = false 319 | default: 320 | text = p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 321 | DefaultMessage: &i18n.Message{ 322 | ID: "jitsi.command.settings.wrong_show_prejoin_page_value", 323 | Other: "Invalid `show_prejoin_page` value, use `true` or `false`.", 324 | }, 325 | }) 326 | userConfig = nil 327 | } 328 | default: 329 | text = p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 330 | DefaultMessage: &i18n.Message{ 331 | ID: "jitsi.command.settings.wrong_field", 332 | Other: "Invalid config field, use `embedded`, `show_prejoin_page` or `naming_scheme`.", 333 | }, 334 | }) 335 | userConfig = nil 336 | } 337 | 338 | if userConfig == nil { 339 | return p.settingsError(args.UserId, args.ChannelId, text, args.RootId) 340 | } 341 | 342 | err = p.setUserConfig(args.UserId, userConfig) 343 | if err != nil { 344 | mlog.Debug("Unable to set user settings", mlog.Err(err)) 345 | return p.settingsError(args.UserId, args.ChannelId, p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 346 | DefaultMessage: &i18n.Message{ 347 | ID: "jitsi.command.settings.unable_to_set", 348 | Other: "Unable to set user settings", 349 | }, 350 | }), args.RootId) 351 | } 352 | 353 | post := &model.Post{ 354 | UserId: p.botID, 355 | ChannelId: args.ChannelId, 356 | Message: p.b.LocalizeWithConfig(l, &i18n.LocalizeConfig{ 357 | DefaultMessage: &i18n.Message{ 358 | ID: "jitsi.command.settings.updated", 359 | Other: fmt.Sprintf("Jitsi settings updated:\n\n* %s: `%s`", parameters[0], parameters[1]), 360 | }, 361 | }), 362 | RootId: args.RootId, 363 | } 364 | _ = p.API.SendEphemeralPost(args.UserId, post) 365 | 366 | return &model.CommandResponse{}, nil 367 | } 368 | --------------------------------------------------------------------------------