diff --git a/.changeset/eight-mails-taste.md b/.changeset/eight-mails-taste.md new file mode 100644 index 0000000..0151887 --- /dev/null +++ b/.changeset/eight-mails-taste.md @@ -0,0 +1,5 @@ +--- +'matrix-barcamp-widget': minor +--- + +Add support for extra widgets in the session rooms. diff --git a/.changeset/honest-flies-heal.md b/.changeset/honest-flies-heal.md new file mode 100644 index 0000000..1ce460d --- /dev/null +++ b/.changeset/honest-flies-heal.md @@ -0,0 +1,5 @@ +--- +'matrix-barcamp-widget': patch +--- + +Reduce the height of the slots and make the tracks fill the full width. diff --git a/.changeset/tiny-rules-juggle.md b/.changeset/tiny-rules-juggle.md new file mode 100644 index 0000000..d5288ff --- /dev/null +++ b/.changeset/tiny-rules-juggle.md @@ -0,0 +1,5 @@ +--- +'matrix-barcamp-widget': minor +--- + +Add more track icons. diff --git a/.gitignore b/.gitignore index 71bd2aa..e73c824 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ typings/ # dotenv environment variables file .env .env.test +.env.local # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/docs/configuration.md b/docs/configuration.md index 6047a4c..3e805d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,7 +8,7 @@ Runtime configuration can be performed via environment variables. ## Environment Variables ```sh -# Configures the base URL of Element, used to register the Jitsi Widget. +# Configures the base URL of Element, used to register the Jitsi Widget. # Setting the variable is not important, as an Element intance chooses to use a # local version of the Jitsi Widget anyway. Defaults to `https://app.element.io`. REACT_APP_ELEMENT_BASE_URL=https://app.element.io @@ -16,4 +16,9 @@ REACT_APP_ELEMENT_BASE_URL=https://app.element.io # The hostname of the Jitsi Instance where video conferences should be hosted. # Defaults to `jitsi.riot.im` REACT_APP_JITSI_HOST_NAME=jitsi.riot.im + +# Additional widgets that should be added to the session rooms. +# Example: `[{"id":"widget","name":"Widget","type":"net.nordeck.widget-2:pad","url":"http://2.widget.example"}]` +# Important: The id and type fields must be unique. +REACT_APP_EXTRA_WIDGETS=[] ``` diff --git a/src/components/IconPicker/IconPicker.test.tsx b/src/components/IconPicker/IconPicker.test.tsx index 19e7de7..ca7972d 100644 --- a/src/components/IconPicker/IconPicker.test.tsx +++ b/src/components/IconPicker/IconPicker.test.tsx @@ -42,7 +42,7 @@ describe('', () => { const list = screen.getByRole('listbox', { name: /available icons/i }); - expect(within(list).getAllByRole('option')).toHaveLength(25); + expect(within(list).getAllByRole('option')).toHaveLength(30); expect( within(list).getByRole('option', { name: 'Icon "dog"', selected: true }) ).toBeInTheDocument(); @@ -109,12 +109,12 @@ describe('', () => { expect( within(list).getByRole('option', { - name: 'Icon "compass"', + name: 'Icon "server"', selected: true, }) ).toBeInTheDocument(); expect( - screen.getByRole('combobox', { name: 'Icon "compass"', expanded: true }) + screen.getByRole('combobox', { name: 'Icon "server"', expanded: true }) ).toBeInTheDocument(); // Go to end @@ -122,12 +122,15 @@ describe('', () => { expect( within(list).getByRole('option', { - name: 'Icon "fire"', + name: 'Icon "face surprise"', selected: true, }) ).toBeInTheDocument(); expect( - screen.getByRole('combobox', { name: 'Icon "fire"', expanded: true }) + screen.getByRole('combobox', { + name: 'Icon "face surprise"', + expanded: true, + }) ).toBeInTheDocument(); // Wrap over last diff --git a/src/components/IconPicker/icons.ts b/src/components/IconPicker/icons.ts index 214ac0d..b1db98a 100644 --- a/src/components/IconPicker/icons.ts +++ b/src/components/IconPicker/icons.ts @@ -42,6 +42,11 @@ export const iconSet = [ 'car', 'compass', 'fire', + 'pizza slice', + 'beer mug', + 'comment', + 'server', + 'face surprise', ]; export function randomIcon(): string { diff --git a/src/components/SessionGrid/SessionGrid.tsx b/src/components/SessionGrid/SessionGrid.tsx index be3a6a5..121b321 100644 --- a/src/components/SessionGrid/SessionGrid.tsx +++ b/src/components/SessionGrid/SessionGrid.tsx @@ -68,6 +68,7 @@ const TableContainer = styled.table<{ canDropSession, theme, }) => ({ + width: '100%', borderSpacing: 0, background: canDropSession ? `repeating-linear-gradient(45deg, ${theme.pageBackground}, ${theme.pageBackground} 10px, transparent 10px, transparent 20px)` @@ -88,8 +89,8 @@ const TableContainer = styled.table<{ 'td.session': { minWidth: 200, width: `${100 / trackCount}%`, - minHeight: 200, - height: 200, + minHeight: 115, + height: 115, }, // set the default borders, padding, and alignment for all cells diff --git a/src/store/api/roomWidgetsApi.test.ts b/src/store/api/roomWidgetsApi.test.ts index 9af9175..f19c045 100644 --- a/src/store/api/roomWidgetsApi.test.ts +++ b/src/store/api/roomWidgetsApi.test.ts @@ -15,8 +15,11 @@ */ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; +import { getEnvironment } from '../../lib/environment'; import { createStore } from '../store'; -import { roomWidgetsApi } from './roomWidgetsApi'; +import { getExtraWidgetsWidgets, roomWidgetsApi } from './roomWidgetsApi'; + +jest.mock('../../lib/environment'); let widgetApi: MockedWidgetApi; @@ -24,6 +27,12 @@ afterEach(() => widgetApi.stop()); beforeEach(() => (widgetApi = mockWidgetApi())); +beforeEach(() => { + jest + .mocked(getEnvironment) + .mockImplementation((_, defaultValue) => defaultValue); +}); + describe('setupLobbyRoomWidgets', () => { it('should setup jitsi widget and widget layout', async () => { const store = createStore({ widgetApi }); @@ -218,6 +227,118 @@ describe('setupSessionRoomWidgets', () => { ); }); + it('should setup extra widgets', async () => { + jest.mocked(getEnvironment).mockImplementation((name, defaultValue) => { + switch (name) { + case 'REACT_APP_EXTRA_WIDGETS': + return JSON.stringify([ + { + id: 'widget-1', + type: 'net.nordeck.widget-1:pad', + name: 'Widget 1', + url: 'http://1.widget.example', + }, + { + id: 'widget-2', + type: 'net.nordeck.widget-2:pad', + name: 'Widget 2', + url: 'http://2.widget.example', + }, + ]); + default: + return defaultValue; + } + }); + + const store = createStore({ widgetApi }); + + await store + .dispatch( + roomWidgetsApi.endpoints.setupSessionRoomWidgets.initiate({ + roomId: '!room-id', + roomName: 'My Room', + }) + ) + .unwrap(); + + expect(widgetApi.sendStateEvent).toBeCalledTimes(5); + expect(widgetApi.sendStateEvent).toBeCalledWith( + 'im.vector.modular.widgets', + { + creatorUserId: '@user-id', + id: 'barcamp', + name: 'BarCamp', + type: 'net.nordeck.barcamp:clock', + url: 'http://localhost/#/?theme=$org.matrix.msc2873.client_theme&matrix_user_id=$matrix_user_id&matrix_display_name=$matrix_display_name&matrix_avatar_url=$matrix_avatar_url&matrix_room_id=$matrix_room_id&matrix_client_id=$org.matrix.msc2873.client_id&matrix_client_language=$org.matrix.msc2873.client_language', + }, + { roomId: '!room-id', stateKey: 'barcamp' } + ); + expect(widgetApi.sendStateEvent).toBeCalledWith( + 'im.vector.modular.widgets', + { + creatorUserId: '@user-id', + data: { + conferenceId: 'EFZG633NFVUWI', + domain: 'jitsi.riot.im', + roomName: 'My Room', + }, + id: 'jitsi', + name: 'Video Conference', + type: 'jitsi', + url: 'https://app.element.io/jitsi.html?confId=EFZG633NFVUWI#conferenceId=$conferenceId&domain=$domain&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&roomId=$matrix_room_id&roomName=$roomName&theme=$theme', + }, + { roomId: '!room-id', stateKey: 'jitsi' } + ); + expect(widgetApi.sendStateEvent).toBeCalledWith( + 'im.vector.modular.widgets', + { + creatorUserId: '@user-id', + id: 'widget-1', + name: 'Widget 1', + type: 'net.nordeck.widget-1:pad', + url: 'http://1.widget.example', + }, + { roomId: '!room-id', stateKey: 'widget-1' } + ); + expect(widgetApi.sendStateEvent).toBeCalledWith( + 'im.vector.modular.widgets', + { + creatorUserId: '@user-id', + id: 'widget-2', + name: 'Widget 2', + type: 'net.nordeck.widget-2:pad', + url: 'http://2.widget.example', + }, + { roomId: '!room-id', stateKey: 'widget-2' } + ); + expect(widgetApi.sendStateEvent).toBeCalledWith( + 'io.element.widgets.layout', + { + widgets: { + jitsi: { + container: 'top', + height: 100, + index: 0, + width: 50, + }, + 'widget-1': { + container: 'top', + height: 100, + index: 1, + width: 30, + }, + barcamp: { + container: 'top', + height: 100, + index: 2, + width: 20, + }, + }, + }, + { roomId: '!room-id' } + ); + }); + it('should update existing jitsi widget, barcamp widget, and widget layout', async () => { widgetApi.mockSendStateEvent({ content: { @@ -325,3 +446,97 @@ describe('setupSessionRoomWidgets', () => { ); }); }); + +describe('getExtraWidgetsWidgets', () => { + it('should accept configuration', () => { + jest.mocked(getEnvironment).mockImplementation((name, defaultValue) => { + switch (name) { + case 'REACT_APP_EXTRA_WIDGETS': + return JSON.stringify([ + { + id: 'widget-1', + type: 'net.nordeck.widget-1:pad', + name: 'Widget 1', + url: 'http://1.widget.example', + }, + ]); + default: + return defaultValue; + } + }); + + expect(getExtraWidgetsWidgets('@user-id')).toEqual([ + { + creatorUserId: '@user-id', + id: 'widget-1', + type: 'net.nordeck.widget-1:pad', + name: 'Widget 1', + url: 'http://1.widget.example', + }, + ]); + }); + + it('should accept additional properties', () => { + jest.mocked(getEnvironment).mockImplementation((name, defaultValue) => { + switch (name) { + case 'REACT_APP_EXTRA_WIDGETS': + return JSON.stringify([ + { + id: 'widget-1', + type: 'net.nordeck.widget-1:pad', + name: 'Widget 1', + url: 'http://1.widget.example', + additional: 'tmp', + }, + ]); + default: + return defaultValue; + } + }); + + expect(getExtraWidgetsWidgets('@user-id')).toEqual([ + { + creatorUserId: '@user-id', + id: 'widget-1', + type: 'net.nordeck.widget-1:pad', + name: 'Widget 1', + url: 'http://1.widget.example', + }, + ]); + }); + + it.each([ + { id: undefined }, + { id: null }, + { id: 111 }, + { name: undefined }, + { name: null }, + { name: 111 }, + { type: undefined }, + { type: null }, + { type: 111 }, + { url: undefined }, + { url: null }, + { url: 111 }, + { url: 'no-uri' }, + ])('should reject event with patch %j', (patch: Object) => { + jest.mocked(getEnvironment).mockImplementation((name, defaultValue) => { + switch (name) { + case 'REACT_APP_EXTRA_WIDGETS': + return JSON.stringify([ + { + id: 'widget-1', + type: 'net.nordeck.widget-1:pad', + name: 'Widget 1', + url: 'http://1.widget.example', + ...patch, + }, + ]); + default: + return defaultValue; + } + }); + + expect(getExtraWidgetsWidgets('@user-id')).toEqual([]); + }); +}); diff --git a/src/store/api/roomWidgetsApi.ts b/src/store/api/roomWidgetsApi.ts index c3e1285..90a5dc0 100644 --- a/src/store/api/roomWidgetsApi.ts +++ b/src/store/api/roomWidgetsApi.ts @@ -20,7 +20,9 @@ import { WidgetApi, } from '@matrix-widget-toolkit/api'; import { t } from 'i18next'; +import Joi from 'joi'; import { isEqual, isError, last } from 'lodash'; +import log from 'loglevel'; import { base32 } from 'rfc4648'; import { getEnvironment } from '../../lib/environment'; import { @@ -127,27 +129,60 @@ export const roomWidgetsApi = baseApi.injectEndpoints({ createJitsiWidget(roomId, creatorUserId, roomName) ); - const widgetsLayout = await applyWidgetsLayout(widgetApi, roomId, { - widgets: { - [jitsiWidget.state_key]: { - container: 'top', - index: 0, - width: 80, - height: 100, + const extraWidgets = []; + for (const extraWidget of getExtraWidgetsWidgets(creatorUserId)) { + const widget = await applyWidget(widgetApi, roomId, extraWidget); + + extraWidgets.push(widget); + } + + let widgetsLayout: StateEvent; + if (extraWidgets.length > 0) { + widgetsLayout = await applyWidgetsLayout(widgetApi, roomId, { + widgets: { + [jitsiWidget.state_key]: { + container: 'top', + index: 0, + width: 50, + height: 100, + }, + [extraWidgets[0].state_key]: { + container: 'top', + index: 1, + width: 30, + height: 100, + }, + [barcampWidget.state_key]: { + container: 'top', + index: 2, + width: 20, + height: 100, + }, }, - [barcampWidget.state_key]: { - container: 'top', - index: 1, - width: 20, - height: 100, + }); + } else { + widgetsLayout = await applyWidgetsLayout(widgetApi, roomId, { + widgets: { + [jitsiWidget.state_key]: { + container: 'top', + index: 0, + width: 80, + height: 100, + }, + [barcampWidget.state_key]: { + container: 'top', + index: 1, + width: 20, + height: 100, + }, }, - }, - }); + }); + } return { data: { widgetsLayout, - widgets: [jitsiWidget], + widgets: [jitsiWidget, ...extraWidgets], }, }; } catch (e) { @@ -224,6 +259,42 @@ function createJitsiWidget( }; } +const extraWidgetsSchema = Joi.array< + Array<{ + id: string; + type: string; + name: string; + url: string; + }> +>().items( + Joi.object({ + id: Joi.string().required(), + type: Joi.string().required(), + name: Joi.string().required(), + url: Joi.string().uri().required(), + }).unknown(true) +); + +export function getExtraWidgetsWidgets(creatorUserId: string): WidgetsEvent[] { + const widgetsRaw = getEnvironment('REACT_APP_EXTRA_WIDGETS', '[]'); + const { error, value: widgets = [] } = extraWidgetsSchema.validate( + JSON.parse(widgetsRaw) + ); + + if (error) { + log.warn('Error while validating event', error); + return []; + } + + return widgets.map((w) => ({ + type: w.type, + url: w.url, + name: w.name, + id: w.id, + creatorUserId, + })); +} + async function applyWidget( widgetApi: WidgetApi, roomId: string, diff --git a/yarn.lock b/yarn.lock index f8ad3cf..b6bde46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3052,12 +3052,7 @@ acorn@^7.0.0, acorn@^7.1.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.2.4, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.8.0: - version "8.8.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" - integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== - -acorn@^8.7.1: +acorn@^8.2.4, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0: version "8.8.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== @@ -3849,9 +3844,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426: - version "1.0.30001435" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001435.tgz#502c93dbd2f493bee73a408fe98e98fb1dad10b2" - integrity sha512-kdCkUTjR+v4YAJelyiDTqiu82BDr4W4CP5sgTA0ZBmqn30XfS2ZghPLMowik9TPhS+psWJiUNxsqLyurDbmutA== + version "1.0.30001539" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001539.tgz" + integrity sha512-hfS5tE8bnNiNvEOEkm8HElUHroYwlqMMENEzELymy77+tJ6m+gA2krtHl5hxJaj71OlpC2cHZbdSMX1/YEqEkA== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0"