diff --git a/.changeset/fruity-bats-fly.md b/.changeset/fruity-bats-fly.md new file mode 100644 index 00000000..08638d77 --- /dev/null +++ b/.changeset/fruity-bats-fly.md @@ -0,0 +1,9 @@ +--- +'@matrix-widget-toolkit/api': major +'@matrix-widget-toolkit/testing': major +--- + +Rework powerlevel calculations to comply with spec in all room versions. + +Note this now requires the create room event to be passed to the power level functions. +Additionally, the mock widget api now has changed user id and room id defaults to comply with matrix spec. diff --git a/example-widget-mui/src/AllRoomsPage/AllRoomsPage.test.tsx b/example-widget-mui/src/AllRoomsPage/AllRoomsPage.test.tsx index 6198343c..383b5aed 100644 --- a/example-widget-mui/src/AllRoomsPage/AllRoomsPage.test.tsx +++ b/example-widget-mui/src/AllRoomsPage/AllRoomsPage.test.tsx @@ -28,7 +28,7 @@ import { RoomNameEvent } from '../events'; import { AllRoomsPage } from './AllRoomsPage'; function mockRoomNameEvent({ - room_id = '!room-id', + room_id = '!room-id:example.com', content = {}, }: { room_id?: string; @@ -118,19 +118,34 @@ describe('', () => { it('should render a list of rooms', async () => { widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-1', content: { name: 'Room 1' } }), + mockRoomNameEvent({ + room_id: '!room-id-1:example.com', + content: { name: 'Room 1' }, + }), ); widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-2', content: { name: 'Room 2' } }), + mockRoomNameEvent({ + room_id: '!room-id-2:example.com', + content: { name: 'Room 2' }, + }), ); widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-3', content: { name: 'Room 3' } }), + mockRoomNameEvent({ + room_id: '!room-id-3:example.com', + content: { name: 'Room 3' }, + }), ); widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-4', content: { name: 'Room 4' } }), + mockRoomNameEvent({ + room_id: '!room-id-4:example.com', + content: { name: 'Room 4' }, + }), ); widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-5', content: { name: 'Room 5' } }), + mockRoomNameEvent({ + room_id: '!room-id-5:example.com', + content: { name: 'Room 5' }, + }), ); render(, { wrapper }); @@ -152,7 +167,10 @@ describe('', () => { ).resolves.toBeInTheDocument(); widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-1', content: { name: 'Room 1' } }), + mockRoomNameEvent({ + room_id: '!room-id-1:example.com', + content: { name: 'Room 1' }, + }), ); await userEvent.click( @@ -167,7 +185,10 @@ describe('', () => { it('should navigate to the room', async () => { widgetApi.mockSendStateEvent( - mockRoomNameEvent({ room_id: '!room-id-1', content: { name: 'Room 1' } }), + mockRoomNameEvent({ + room_id: '!room-id-1:example.com', + content: { name: 'Room 1' }, + }), ); render(, { wrapper }); @@ -176,7 +197,7 @@ describe('', () => { await userEvent.click(button); expect(widgetApi.navigateTo).toHaveBeenCalledWith( - 'https://matrix.to/#/!room-id-1', + 'https://matrix.to/#/!room-id-1%3Aexample.com', ); }); }); diff --git a/example-widget-mui/src/DicePage/DicePage.test.tsx b/example-widget-mui/src/DicePage/DicePage.test.tsx index dee3c163..b9f60ee1 100644 --- a/example-widget-mui/src/DicePage/DicePage.test.tsx +++ b/example-widget-mui/src/DicePage/DicePage.test.tsx @@ -107,7 +107,7 @@ describe('', () => { type: 'net.nordeck.throw_dice', event_id: '$0', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', content: { pips: 5 }, }); @@ -115,7 +115,7 @@ describe('', () => { type: 'net.nordeck.throw_dice', event_id: '$1', origin_server_ts: 1, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', content: { pips: 3 }, }); diff --git a/example-widget-mui/src/ModalPage/ModalDialog.test.tsx b/example-widget-mui/src/ModalPage/ModalDialog.test.tsx index 190addd5..67636581 100644 --- a/example-widget-mui/src/ModalPage/ModalDialog.test.tsx +++ b/example-widget-mui/src/ModalPage/ModalDialog.test.tsx @@ -53,7 +53,9 @@ describe('', () => { await expect(screen.findByText(/a title/i)).resolves.toBeInTheDocument(); expect(screen.getByText(/some content…/i)).toBeInTheDocument(); - expect(screen.getByText(/Room ID: !room-id/i)).toBeInTheDocument(); + expect( + screen.getByText(/Room ID: !room-id:example.com/i), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: /i am confident!/i }), ).toBeInTheDocument(); diff --git a/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.test.tsx b/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.test.tsx index 9af4bade..35399231 100644 --- a/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.test.tsx +++ b/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.test.tsx @@ -33,35 +33,48 @@ afterEach(() => widgetApi.stop()); beforeEach(() => { widgetApi = mockWidgetApi(); + // @ts-expect-error - This is a test, we can set the userId directly + widgetApi.widgetParameters.userId = '@user-id:example.com'; + widgetApi.mockSendStateEvent({ + type: 'm.room.create', + sender: '@user-id:example.com', + state_key: '', + content: { + room_version: '11', + }, + origin_server_ts: 0, + event_id: '$create-event-id', + room_id: '!room-id:example.com', + }); widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { - users: { '@user-id': 100 }, + users: { '@user-id:example.com': 100 }, }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); widgetApi.mockSendStateEvent({ type: 'm.room.member', - sender: '@user-id', - state_key: '@another-user', + sender: '@user-id:example.com', + state_key: '@another-user:example.com', content: { membership: 'join' }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); widgetApi.mockSendStateEvent({ type: 'm.room.member', - sender: '@user-id', - state_key: '@user-id', + sender: '@user-id:example.com', + state_key: '@user-id:example.com', content: { membership: 'join' }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); wrapper = ({ children }: PropsWithChildren) => ( @@ -93,7 +106,7 @@ describe('', () => { await userEvent.click( await within(listbox).findByRole('option', { - name: '@another-user', + name: '@another-user:example.com', selected: true, checked: true, }), @@ -137,7 +150,7 @@ describe('', () => { await userEvent.click( within(listbox).getByRole('option', { - name: '@user-id You', + name: '@user-id:example.com You', selected: false, }), ); @@ -146,7 +159,7 @@ describe('', () => { screen.getByRole('combobox', { name: 'Username', }), - ).toHaveTextContent('@user-id'); + ).toHaveTextContent('@user-id:example.com'); }); it('should request the capabilities', async () => { @@ -161,6 +174,10 @@ describe('', () => { EventDirection.Receive, 'm.room.member', ), + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + 'm.room.create', + ), ]); const button = await screen.findByRole('button', { name: /promote/i }); @@ -191,7 +208,7 @@ describe('', () => { await userEvent.click( within(listbox).getByRole('option', { - name: '@user-id You', + name: '@user-id:example.com You', selected: false, }), ); @@ -208,16 +225,16 @@ describe('', () => { it('should disable actions if the user has no power to update the power of others', async () => { widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users: { - '@user-id': 0, + '@user-id:example.com': 0, }, }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); render(, { wrapper }); @@ -261,8 +278,8 @@ describe('', () => { 'm.room.power_levels', { users: { - '@another-user': 50, - '@user-id': 100, + '@another-user:example.com': 50, + '@user-id:example.com': 100, }, }, ); @@ -271,17 +288,17 @@ describe('', () => { it('should demote the user', async () => { widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users: { - '@another-user': 50, - '@user-id': 100, + '@another-user:example.com': 50, + '@user-id:example.com': 100, }, }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); render(, { wrapper }); @@ -306,8 +323,8 @@ describe('', () => { 'm.room.power_levels', { users: { - '@another-user': 0, - '@user-id': 100, + '@another-user:example.com': 0, + '@user-id:example.com': 100, }, }, ); diff --git a/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx b/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx index 64e10c41..a0854c9d 100644 --- a/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx +++ b/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx @@ -20,6 +20,7 @@ import { hasRoomEventPower, hasStateEventPower, PowerLevelsActions, + STATE_EVENT_CREATE, STATE_EVENT_POWER_LEVELS, STATE_EVENT_ROOM_MEMBER, } from '@matrix-widget-toolkit/api'; @@ -48,6 +49,7 @@ import { STATE_EVENT_ROOM_NAME } from '../events'; import { NavigationBar } from '../NavigationPage'; import { StoreProvider } from '../store'; import { + useGetCreateEventQuery, useGetPowerLevelsQuery, useUpdatePowerLevelsMutation, } from './powerLevelsApi'; @@ -90,6 +92,10 @@ export const PowerLevelsPage = (): ReactElement => { EventDirection.Receive, STATE_EVENT_ROOM_MEMBER, ), + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + STATE_EVENT_CREATE, + ), ]} > {/* @@ -142,6 +148,7 @@ export const PowerLevelsView = (): ReactElement => { const { data: powerLevelsEvent } = useGetPowerLevelsQuery(); const { data: roomMembersData } = useGetRoomMembersQuery(); + const { data: createEvent } = useGetCreateEventQuery(); const [selectedMember, setSelectedMember] = useState(); @@ -177,10 +184,15 @@ export const PowerLevelsView = (): ReactElement => { ? selectAllRoomMembers(roomMembersData) : []; + if (selectedMember === undefined) { + return Loading...; + } + // check if we (=the user of the widget) has the power to promote or // demote others const canPromoteOrDemote = hasStateEventPower( powerLevelsEvent?.content, + createEvent?.event, widgetApi.widgetParameters.userId, STATE_EVENT_POWER_LEVELS, ); @@ -188,6 +200,7 @@ export const PowerLevelsView = (): ReactElement => { // we assume that users that can change the name can be promoted or demoted const userIsModerator = hasStateEventPower( powerLevelsEvent?.content, + createEvent?.event, selectedMember, STATE_EVENT_ROOM_NAME, ); @@ -231,6 +244,7 @@ export const PowerLevelsView = (): ReactElement => { title={type} permitted={hasStateEventPower( powerLevelsEvent?.content, + createEvent?.event, selectedMember, type, )} @@ -252,6 +266,7 @@ export const PowerLevelsView = (): ReactElement => { title={type} permitted={hasRoomEventPower( powerLevelsEvent?.content, + createEvent?.event, selectedMember, type, )} @@ -273,6 +288,7 @@ export const PowerLevelsView = (): ReactElement => { title={action} permitted={hasActionPower( powerLevelsEvent?.content, + createEvent?.event, selectedMember, action, )} diff --git a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts index 21a258ef..65e778db 100644 --- a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts +++ b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts @@ -29,12 +29,12 @@ beforeEach(() => { widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users_default: 50 }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); }); @@ -76,12 +76,12 @@ describe('getPowerLevels', () => { widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users_default: 0 }, origin_server_ts: 1, event_id: '$event-id-1', - room_id: '!room-id', + room_id: '!room-id:example.com', }); // wait for the change @@ -115,12 +115,12 @@ describe('updatePowerLevels', () => { // override the original mock so the event is not forwarded to the reader widgetApi.sendStateEvent.mockResolvedValue({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { events_default: 100 }, origin_server_ts: 1, event_id: '$event-id-1', - room_id: '!room-id', + room_id: '!room-id:example.com', }); const store = createStore({ widgetApi }); diff --git a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts index 1648c71b..26d213ff 100644 --- a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts +++ b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts @@ -16,7 +16,11 @@ import { PowerLevelsStateEvent, + STATE_EVENT_CREATE, STATE_EVENT_POWER_LEVELS, + StateEvent, + StateEventCreateContent, + isValidCreateEventSchema, isValidPowerLevelStateEvent, } from '@matrix-widget-toolkit/api'; import { EventDirection, WidgetEventCapability } from 'matrix-widget-api'; @@ -89,6 +93,59 @@ export const powerLevelsApi = baseApi.injectEndpoints({ }, }), + /** Receive the room create event */ + getCreateEvent: builder.query< + { event: StateEvent | undefined }, + void + >({ + // do the initial loading + async queryFn(_, { extra }) { + const { widgetApi } = extra as ThunkExtraArgument; + try { + const events = await widgetApi.receiveStateEvents(STATE_EVENT_CREATE); + + const event = events.filter(isValidCreateEventSchema)[0]; + return { data: { event: event } }; + } catch (e) { + return { + error: { + name: 'LoadFailed', + message: `Could not load room create event: ${ + isError(e) ? e.message : JSON.stringify(e) + }`, + }, + }; + } + }, + // observe the room and apply updates to the redux store. + // see also https://redux-toolkit.js.org/rtk-query/usage/streaming-updates#using-the-oncacheentryadded-lifecycle + async onCacheEntryAdded( + _, + { cacheDataLoaded, cacheEntryRemoved, extra, updateCachedData }, + ) { + const { widgetApi } = extra as ThunkExtraArgument; + + // wait until first data is cached + await cacheDataLoaded; + + const subscription = widgetApi + .observeStateEvents(STATE_EVENT_CREATE) + .subscribe(async (event) => { + // update the cached data if the event changes in the room + if (isValidCreateEventSchema(event)) { + updateCachedData(() => ({ event: event })); + } else { + updateCachedData(() => ({ event: undefined })); + } + }); + + // wait until subscription is cancelled + await cacheEntryRemoved; + + subscription.unsubscribe(); + }, + }), + /** Update the name of the current room */ updatePowerLevels: builder.mutation({ // Optimistic update the local cache to instantly see the updated power levels. @@ -146,5 +203,8 @@ export const powerLevelsApi = baseApi.injectEndpoints({ }); // consume the store using the hooks generated by RTK Query -export const { useGetPowerLevelsQuery, useUpdatePowerLevelsMutation } = - powerLevelsApi; +export const { + useGetPowerLevelsQuery, + useGetCreateEventQuery, + useUpdatePowerLevelsMutation, +} = powerLevelsApi; diff --git a/example-widget-mui/src/PowerLevelsPage/roomMembersApi.test.ts b/example-widget-mui/src/PowerLevelsPage/roomMembersApi.test.ts index 9b726498..e84f7fa5 100644 --- a/example-widget-mui/src/PowerLevelsPage/roomMembersApi.test.ts +++ b/example-widget-mui/src/PowerLevelsPage/roomMembersApi.test.ts @@ -38,7 +38,7 @@ function mockRoomMemberEvent({ content: { membership: 'join', ...content }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }; } diff --git a/example-widget-mui/src/RelationsPage/RelationsPage.test.tsx b/example-widget-mui/src/RelationsPage/RelationsPage.test.tsx index 48721a1e..16827b49 100644 --- a/example-widget-mui/src/RelationsPage/RelationsPage.test.tsx +++ b/example-widget-mui/src/RelationsPage/RelationsPage.test.tsx @@ -16,7 +16,7 @@ import { WidgetApiMockProvider } from '@matrix-widget-toolkit/react'; import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; import { ComponentType, PropsWithChildren, act } from 'react'; @@ -37,15 +37,26 @@ afterEach(() => widgetApi.stop()); beforeEach(() => { widgetApi = mockWidgetApi(); + // @ts-expect-error - This is a test, we can set the userId directly + widgetApi.widgetParameters.userId = '@user-id:example.com'; widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users_default: 50 }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', + }); + widgetApi.mockSendStateEvent({ + type: 'm.room.create', + sender: '@user-id:example.com', + state_key: '', + content: { room_version: '11' }, + origin_server_ts: 0, + event_id: '$create-event-id', + room_id: '!room-id:example.com', }); widgetApi.mockSendRoomEvent(mockRoomMessageEvent()); @@ -75,29 +86,34 @@ describe('', () => { screen.findByRole('heading', { name: 'Event Relations' }), ).resolves.toBeInTheDocument(); - expect( - screen.getByRole('textbox', { name: 'Send a message' }), - ).toBeInTheDocument(); + // Wait for the component to calculate permissions + await waitFor(() => { + expect( + screen.getByRole('textbox', { name: 'Send a message' }), + ).toBeInTheDocument(); + }); expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument(); const list = screen.getByRole('list', { name: 'Messages' }); const listitem = await within(list).findByRole('listitem', { - name: 'My message @user-id', + name: 'My message @user-id:example.com', }); expect(within(listitem).getByText('My message')).toBeInTheDocument(); - expect(within(listitem).getByText('@user-id')).toBeInTheDocument(); + expect( + within(listitem).getByText('@user-id:example.com'), + ).toBeInTheDocument(); expect( within(listitem).getByRole('button', { name: 'Remove reaction "Snowflake"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: true, }), ).toBeInTheDocument(); expect( within(listitem).getByRole('button', { name: 'Add reaction "Thumbs Up"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: false, }), ).toBeInTheDocument(); @@ -125,7 +141,7 @@ describe('', () => { await userEvent.type(textfield, 'Hey, how are you?{enter}'); const listbox = await screen.findByRole('listitem', { - name: 'Hey, how are you? @user-id', + name: 'Hey, how are you? @user-id:example.com', }); expect(textfield).toHaveValue(''); @@ -133,14 +149,14 @@ describe('', () => { expect( within(listbox).getByRole('button', { name: 'Add reaction "Snowflake"', - description: 'Hey, how are you? @user-id', + description: 'Hey, how are you? @user-id:example.com', pressed: false, }), ).toBeInTheDocument(); expect( within(listbox).getByRole('button', { name: 'Add reaction "Thumbs Up"', - description: 'Hey, how are you? @user-id', + description: 'Hey, how are you? @user-id:example.com', pressed: false, }), ).toBeInTheDocument(); @@ -152,7 +168,7 @@ describe('', () => { await userEvent.click( await screen.findByRole('button', { name: 'Add reaction "Thumbs Up"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: false, }), ); @@ -160,7 +176,7 @@ describe('', () => { expect( await screen.findByRole('button', { name: 'Remove reaction "Thumbs Up"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: true, }), ).toBeInTheDocument(); @@ -172,7 +188,7 @@ describe('', () => { await userEvent.click( await screen.findByRole('button', { name: 'Remove reaction "Snowflake"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: true, }), ); @@ -180,7 +196,7 @@ describe('', () => { expect( await screen.findByRole('button', { name: 'Add reaction "Snowflake"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: false, }), ).toBeInTheDocument(); @@ -189,12 +205,21 @@ describe('', () => { it('should not be able to send a message if the permission for the state event is missing', async () => { widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users_default: 0 }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', + }); + widgetApi.mockSendStateEvent({ + type: 'm.room.create', + sender: '@user-id:example.com', + state_key: '', + content: { room_version: '11' }, + origin_server_ts: 0, + event_id: '$create-event-id', + room_id: '!room-id:example.com', }); render(, { wrapper }); @@ -209,13 +234,13 @@ describe('', () => { const addReactionButton = await screen.findByRole('button', { name: 'Add reaction "Thumbs Up"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: false, }); const removeReactionButton = await screen.findByRole('button', { name: 'Remove reaction "Snowflake"', - description: 'My message @user-id', + description: 'My message @user-id:example.com', pressed: true, }); @@ -225,16 +250,27 @@ describe('', () => { act(() => { widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { users_default: 0, events_default: 50 }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', + }); + widgetApi.mockSendStateEvent({ + type: 'm.room.create', + sender: '@user-id:example.com', + state_key: '', + content: { room_version: '11' }, + origin_server_ts: 0, + event_id: '$create-event-id', + room_id: '!room-id:example.com', }); }); - expect(addReactionButton).toBeDisabled(); + await waitFor(() => { + expect(addReactionButton).toBeDisabled(); + }); expect(removeReactionButton).toBeDisabled(); }); }); diff --git a/example-widget-mui/src/RelationsPage/RelationsPage.tsx b/example-widget-mui/src/RelationsPage/RelationsPage.tsx index e03bb729..2231f24e 100644 --- a/example-widget-mui/src/RelationsPage/RelationsPage.tsx +++ b/example-widget-mui/src/RelationsPage/RelationsPage.tsx @@ -18,8 +18,10 @@ import { hasActionPower, hasRoomEventPower, hasStateEventPower, + isValidCreateEventSchema, isValidPowerLevelStateEvent, ROOM_EVENT_REDACTION, + STATE_EVENT_CREATE, STATE_EVENT_POWER_LEVELS, } from '@matrix-widget-toolkit/api'; import { MuiCapabilitiesGuard } from '@matrix-widget-toolkit/mui'; @@ -48,7 +50,7 @@ import { import { EventDirection, WidgetEventCapability } from 'matrix-widget-api'; import { FormEvent, ReactElement, ReactNode, useMemo, useState } from 'react'; import { useObservable } from 'react-use'; -import { filter, map } from 'rxjs'; +import { filter, from, map, switchMap } from 'rxjs'; import { ROOM_EVENT_REACTION, ROOM_EVENT_ROOM_MESSAGE, @@ -112,6 +114,10 @@ export const RelationsPage = (): ReactElement => { EventDirection.Send, ROOM_EVENT_REDACTION, ), + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + STATE_EVENT_CREATE, + ), ]} > {/* @@ -357,34 +363,44 @@ function usePermissions() { () => widgetApi.observeStateEvents('m.room.power_levels').pipe( filter(isValidPowerLevelStateEvent), - map((ev) => ({ - canEdit: - hasStateEventPower( - ev.content, - widgetApi.widgetParameters.userId, - STATE_EVENT_MESSAGE_COLLECTION, - ) && - hasRoomEventPower( - ev.content, - widgetApi.widgetParameters.userId, - ROOM_EVENT_REDACTION, - ) && - hasActionPower( - ev.content, - widgetApi.widgetParameters.userId, - 'redact', - ), - canSendReaction: hasRoomEventPower( - ev.content, - widgetApi.widgetParameters.userId, - ROOM_EVENT_REACTION, - ), - canSendRedaction: hasRoomEventPower( - ev.content, - widgetApi.widgetParameters.userId, - ROOM_EVENT_REDACTION, + switchMap((powerLevelsEvent) => + from(widgetApi.receiveSingleStateEvent(STATE_EVENT_CREATE, '')).pipe( + filter(isValidCreateEventSchema), + map((createEvent) => ({ + canEdit: + hasStateEventPower( + powerLevelsEvent.content, + createEvent, + widgetApi.widgetParameters.userId, + STATE_EVENT_MESSAGE_COLLECTION, + ) && + hasRoomEventPower( + powerLevelsEvent.content, + createEvent, + widgetApi.widgetParameters.userId, + ROOM_EVENT_REDACTION, + ) && + hasActionPower( + powerLevelsEvent.content, + createEvent, + widgetApi.widgetParameters.userId, + 'redact', + ), + canSendReaction: hasRoomEventPower( + powerLevelsEvent.content, + createEvent, + widgetApi.widgetParameters.userId, + ROOM_EVENT_REACTION, + ), + canSendRedaction: hasRoomEventPower( + powerLevelsEvent.content, + createEvent, + widgetApi.widgetParameters.userId, + ROOM_EVENT_REDACTION, + ), + })), ), - })), + ), ), [widgetApi], ); diff --git a/example-widget-mui/src/RelationsPage/testUtils.ts b/example-widget-mui/src/RelationsPage/testUtils.ts index ca968994..73e468a0 100644 --- a/example-widget-mui/src/RelationsPage/testUtils.ts +++ b/example-widget-mui/src/RelationsPage/testUtils.ts @@ -30,12 +30,12 @@ export function mockMessageCollectionEvent({ } = {}): StateEvent { return { type: 'net.nordeck.message_collection', - sender: '@user-id', + sender: '@user-id:example.com', state_key, content: { eventIds: ['$message-event-id'], ...content }, origin_server_ts: 0, event_id: '$collection-event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }; } @@ -47,11 +47,11 @@ export function mockRoomMessageEvent({ } = {}): RoomEvent { return { type: 'm.room.message', - sender: '@user-id', + sender: '@user-id:example.com', content: { msgtype: 'm.text', body: 'My message', ...content }, origin_server_ts: 0, event_id: '$message-event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }; } @@ -64,7 +64,7 @@ export function mockReactionEvent({ } = {}): RoomEvent { return { type: 'm.reaction', - sender: '@user-id', + sender: '@user-id:example.com', content: { 'm.relates_to': { rel_type: 'm.annotation', @@ -75,6 +75,6 @@ export function mockReactionEvent({ }, origin_server_ts: 0, event_id, - room_id: '!room-id', + room_id: '!room-id:example.com', }; } diff --git a/example-widget-mui/src/RoomPage/RoomPage.test.tsx b/example-widget-mui/src/RoomPage/RoomPage.test.tsx index d25cab00..06a0b1b2 100644 --- a/example-widget-mui/src/RoomPage/RoomPage.test.tsx +++ b/example-widget-mui/src/RoomPage/RoomPage.test.tsx @@ -35,14 +35,14 @@ beforeEach(() => { widgetApi.mockSendStateEvent({ type: 'm.room.name', - sender: '@user-id', + sender: '@user-id:example.com', state_key: '', content: { name: 'A Test Room', }, origin_server_ts: 0, event_id: '$event-id', - room_id: '!room-id', + room_id: '!room-id:example.com', }); wrapper = ({ children }: PropsWithChildren) => ( diff --git a/example-widget-mui/src/events/messageCollectionEvent.test.ts b/example-widget-mui/src/events/messageCollectionEvent.test.ts index eb91b0d5..93394f5c 100644 --- a/example-widget-mui/src/events/messageCollectionEvent.test.ts +++ b/example-widget-mui/src/events/messageCollectionEvent.test.ts @@ -26,7 +26,7 @@ describe('isValidMessageCollectionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', state_key: '', sender: '@user-id', type: 'net.nordeck.message_collection', @@ -43,7 +43,7 @@ describe('isValidMessageCollectionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', state_key: '', sender: '@user-id', type: 'net.nordeck.message_collection', @@ -66,7 +66,7 @@ describe('isValidMessageCollectionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', state_key: '', sender: '@user-id', type: 'net.nordeck.message_collection', diff --git a/example-widget-mui/src/events/reactionEvent.test.ts b/example-widget-mui/src/events/reactionEvent.test.ts index a53d3b98..a5b58055 100644 --- a/example-widget-mui/src/events/reactionEvent.test.ts +++ b/example-widget-mui/src/events/reactionEvent.test.ts @@ -30,7 +30,7 @@ describe('isValidReactionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.reaction', }), @@ -51,7 +51,7 @@ describe('isValidReactionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.reaction', }), @@ -75,7 +75,7 @@ describe('isValidReactionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.reaction', }), @@ -105,7 +105,7 @@ describe('isValidReactionEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.reaction', }), diff --git a/example-widget-mui/src/events/roomMessageEvent.test.ts b/example-widget-mui/src/events/roomMessageEvent.test.ts index a615a08d..b1a77929 100644 --- a/example-widget-mui/src/events/roomMessageEvent.test.ts +++ b/example-widget-mui/src/events/roomMessageEvent.test.ts @@ -27,7 +27,7 @@ describe('isValidRoomMessageEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), @@ -44,7 +44,7 @@ describe('isValidRoomMessageEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), @@ -68,7 +68,7 @@ describe('isValidRoomMessageEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), diff --git a/example-widget-mui/src/events/roomNameEvent.test.ts b/example-widget-mui/src/events/roomNameEvent.test.ts index fbdc7740..b0272125 100644 --- a/example-widget-mui/src/events/roomNameEvent.test.ts +++ b/example-widget-mui/src/events/roomNameEvent.test.ts @@ -26,7 +26,7 @@ describe('isValidRoomNameEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', state_key: '', sender: '@user-id', type: 'm.room.name', @@ -43,7 +43,7 @@ describe('isValidRoomNameEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', state_key: '', sender: '@user-id', type: 'm.room.name', @@ -62,7 +62,7 @@ describe('isValidRoomNameEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', state_key: '', sender: '@user-id', type: 'm.room.name', diff --git a/example-widget-mui/src/events/throwDiceEvent.test.ts b/example-widget-mui/src/events/throwDiceEvent.test.ts index 396a2951..fa0f46e3 100644 --- a/example-widget-mui/src/events/throwDiceEvent.test.ts +++ b/example-widget-mui/src/events/throwDiceEvent.test.ts @@ -26,7 +26,7 @@ describe('isValidThrowDiceEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'net.nordeck.throw_dice', }), @@ -42,7 +42,7 @@ describe('isValidThrowDiceEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'net.nordeck.throw_dice', }), @@ -60,7 +60,7 @@ describe('isValidThrowDiceEvent', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'net.nordeck.throw_dice', }), diff --git a/example-widget-mui/src/events/uploadImageEvent.test.ts b/example-widget-mui/src/events/uploadImageEvent.test.ts index cfbeab3c..1f9b7831 100644 --- a/example-widget-mui/src/events/uploadImageEvent.test.ts +++ b/example-widget-mui/src/events/uploadImageEvent.test.ts @@ -28,7 +28,7 @@ describe('isValidUploadedImage', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'net.nordeck.uploaded_image', }), @@ -49,7 +49,7 @@ describe('isValidUploadedImage', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'net.nordeck.uploaded_image', }), diff --git a/example-widget-mui/src/store/StoreProvider.tsx b/example-widget-mui/src/store/StoreProvider.tsx index 0789dea2..fe6c735b 100644 --- a/example-widget-mui/src/store/StoreProvider.tsx +++ b/example-widget-mui/src/store/StoreProvider.tsx @@ -22,9 +22,14 @@ import { createStore } from './store'; /** * Create and provide the redux store */ -export function StoreProvider({ children }: PropsWithChildren): ReactElement { +export function StoreProvider({ + children, + preloadedState, +}: PropsWithChildren<{ + preloadedState?: unknown; +}>): ReactElement { const widgetApi = useWidgetApi(); - const [store] = useState(() => createStore({ widgetApi })); + const [store] = useState(() => createStore({ widgetApi, preloadedState })); return {children}; } diff --git a/example-widget-mui/src/store/store.ts b/example-widget-mui/src/store/store.ts index 6ffc73f5..f211d208 100644 --- a/example-widget-mui/src/store/store.ts +++ b/example-widget-mui/src/store/store.ts @@ -22,6 +22,7 @@ import { baseApi } from './baseApi'; type CreateStoreOpts = { /** The widget api instance. */ widgetApi: WidgetApi; + preloadedState?: unknown; }; /** @@ -30,7 +31,7 @@ type CreateStoreOpts = { * @param param0 - {@link CreateStoreOpts} * @returns an initialized store instance */ -export function createStore({ widgetApi }: CreateStoreOpts) { +export function createStore({ widgetApi, preloadedState }: CreateStoreOpts) { const roomId = widgetApi.widgetParameters.roomId; const userId = widgetApi.widgetParameters.userId; @@ -43,6 +44,7 @@ export function createStore({ widgetApi }: CreateStoreOpts) { // register the extensible RTK Query API [baseApi.reducerPath]: baseApi.reducer, }, + preloadedState, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { diff --git a/packages/api/api-report.api.md b/packages/api/api-report.api.md index f09096bc..5e349608 100644 --- a/packages/api/api-report.api.md +++ b/packages/api/api-report.api.md @@ -25,7 +25,7 @@ import { WidgetApi as WidgetApi_2 } from 'matrix-widget-api'; import { WidgetEventCapability } from 'matrix-widget-api'; // @public -export function calculateUserPowerLevel(powerLevelStateEvent: PowerLevelsStateEvent, userId?: string): number; +export function calculateUserPowerLevel(powerLevelStateEvent: PowerLevelsStateEvent | undefined, createRoomStateEvent: StateEvent | undefined, userId: string): UserPowerLevelType; // @public export function compareOriginServerTS(a: RoomEvent, b: RoomEvent): number; @@ -64,13 +64,13 @@ export function getOriginalEventId(event: RoomEventOrNewContent): string; export function getRoomMemberDisplayName(member: StateEvent, allRoomMembers?: StateEvent[]): string; // @public -export function hasActionPower(powerLevelStateEvent: PowerLevelsStateEvent | undefined, userId: string | undefined, action: PowerLevelsActions): boolean; +export function hasActionPower(powerLevelStateEvent: PowerLevelsStateEvent | undefined, createRoomStateEvent: StateEvent | undefined, userId: string | undefined, action: PowerLevelsActions): boolean; // @public -export function hasRoomEventPower(powerLevelStateEvent: PowerLevelsStateEvent | undefined, userId: string | undefined, eventType: string): boolean; +export function hasRoomEventPower(powerLevelStateEvent: PowerLevelsStateEvent | undefined, createRoomStateEvent: StateEvent | undefined, userId: string | undefined, eventType: string): boolean; // @public -export function hasStateEventPower(powerLevelStateEvent: PowerLevelsStateEvent | undefined, userId: string | undefined, eventType: string): boolean; +export function hasStateEventPower(powerLevelStateEvent: PowerLevelsStateEvent | undefined, createRoomStateEvent: StateEvent | undefined, userId: string | undefined, eventType: string): boolean; // @public export function hasWidgetParameters(widgetApi: WidgetApi): boolean; @@ -81,6 +81,9 @@ export function isRoomEvent(event: RoomEvent | StateEvent): event is RoomEvent; // @public export function isStateEvent(event: RoomEvent | StateEvent): event is StateEvent; +// @public +export function isValidCreateEventSchema(event: StateEvent | undefined): event is StateEvent; + // @public export function isValidEventWithRelatesTo(event: RoomEvent): event is EventWithRelatesTo; @@ -170,6 +173,9 @@ export function repairWidgetRegistration(widgetApi: WidgetApi, registration?: Wi // @public export const ROOM_EVENT_REDACTION = "m.room.redaction"; +// @public +export const ROOM_VERSION_12_CREATOR = "ROOM_VERSION_12_CREATOR"; + // @public export type RoomEvent = Omit & { content: T; @@ -188,6 +194,9 @@ export type RoomMemberStateEventContent = { // @public export function sendStateEventWithEventResult(widgetApi: WidgetApi, type: string, stateKey: string, content: T): Promise>; +// @public +export const STATE_EVENT_CREATE = "m.room.create"; + // @public export const STATE_EVENT_POWER_LEVELS = "m.room.power_levels"; @@ -200,6 +209,13 @@ export type StateEvent = Omit = { type: string; @@ -215,6 +231,9 @@ export type TurnServer = { credential: string; }; +// @public (undocumented) +export type UserPowerLevelType = number | typeof ROOM_VERSION_12_CREATOR; + // @public export const WIDGET_CAPABILITY_NAVIGATE = "org.matrix.msc2931.navigate"; diff --git a/packages/api/src/api/WidgetApiImpl.test.ts b/packages/api/src/api/WidgetApiImpl.test.ts index f36eca2f..e8e01b27 100644 --- a/packages/api/src/api/WidgetApiImpl.test.ts +++ b/packages/api/src/api/WidgetApiImpl.test.ts @@ -2044,7 +2044,7 @@ describe('WidgetApiImpl', () => { await expect( widgetApi.readEventRelations('$event-id', { - roomId: '!room-id', + roomId: '!room-id:example.com', limit: 5, from: 'from-token', relationType: 'm.reference', @@ -2057,7 +2057,7 @@ describe('WidgetApiImpl', () => { }); expect(matrixWidgetApi.readEventRelations).toHaveBeenCalledWith( '$event-id', - '!room-id', + '!room-id:example.com', 'm.reference', 'm.room.message', 5, diff --git a/packages/api/src/api/extras/events.test.ts b/packages/api/src/api/extras/events.test.ts index 7654712b..25c74d49 100644 --- a/packages/api/src/api/extras/events.test.ts +++ b/packages/api/src/api/extras/events.test.ts @@ -19,9 +19,12 @@ import { RoomEvent, StateEvent, ToDeviceMessageEvent } from '../types'; import { isRoomEvent, isStateEvent, + isValidCreateEventSchema, + isValidPowerLevelStateEvent, isValidRoomEvent, isValidStateEvent, isValidToDeviceMessageEvent, + StateEventCreateContent, } from './events'; // Mock console.warn for tests @@ -40,7 +43,7 @@ const roomEvent: RoomEvent = { event_id: '$id', content: {}, origin_server_ts: 1739189593951, - room_id: '!room-id', + room_id: '!room-id:example.com', }; const stateEvent: StateEvent = { @@ -213,3 +216,218 @@ describe('isValidToDeviceMessageEvent', () => { ); }); }); + +describe('isValidPowerLevelStateEvent', () => { + it('should permit valid event', () => { + const event: StateEvent = { + content: { + events: { + 'event-name': 50, + }, + users_default: 25, + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.power_levels', + }; + + expect(isValidPowerLevelStateEvent(event)).toEqual(true); + }); + + it('should permit additional properties', () => { + const event: StateEvent = { + content: { + additionalProperty: true, + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.power_levels', + }; + + expect(isValidPowerLevelStateEvent(event)).toEqual(true); + }); + + it('should deny wrong event type', () => { + const event: StateEvent = { + content: {}, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'another-type', + }; + + expect(isValidPowerLevelStateEvent(event)).toEqual(false); + }); + + it('should deny wrong event structure (wrong type for events_default)', () => { + const event: StateEvent = { + content: { + events_default: 'test', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.power_levels', + }; + + expect(isValidPowerLevelStateEvent(event)).toEqual(false); + }); + + it('should deny wrong event structure (null value for events)', () => { + const event: StateEvent = { + content: { + events: null, + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.power_levels', + }; + + expect(isValidPowerLevelStateEvent(event)).toEqual(false); + }); + + it('should deny wrong event structure (wrong type for events key-value pairs)', () => { + const event: StateEvent = { + content: { + events: { + 'event-type': false, + }, + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.power_levels', + }; + + expect(isValidPowerLevelStateEvent(event)).toEqual(false); + }); +}); + +describe('isValidCreateEventSchema', () => { + it('should accept valid create event', () => { + const event: StateEvent = { + content: { + room_version: '12', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }; + + expect(isValidCreateEventSchema(event)).toEqual(true); + }); + + it('should accept additional properties', () => { + const event: StateEvent = { + content: { + room_version: '12', + // @ts-expect-error - additionalProperty is not part of the schema but this is what we want to test + additionalProperty: true, + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }; + + expect(isValidCreateEventSchema(event)).toEqual(true); + }); + + it('should reject wrong event type', () => { + const event: StateEvent = { + content: { + room_version: '12', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'another-type', + }; + + expect(isValidCreateEventSchema(event)).toEqual(false); + }); + + it('should accept room id without a server name', () => { + const event: StateEvent = { + content: { + room_version: '12', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }; + + expect(isValidCreateEventSchema(event)).toEqual(true); + }); + + it('should reject wrong event structure (missing content)', () => { + // @ts-expect-error - we are in a test case + const event: StateEvent = { + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }; + + expect(isValidCreateEventSchema(event)).toEqual(false); + }); + + it('should reject invalid sender', () => { + const event: StateEvent = { + content: { + room_version: '12', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id', + state_key: '', + type: 'm.room.create', + }; + + expect(isValidCreateEventSchema(event)).toEqual(false); + }); + + it('should reject invalid room id', () => { + const event: StateEvent = { + content: { + room_version: '12', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id', + sender: '@user-id', + state_key: '', + type: 'm.room.create', + }; + + expect(isValidCreateEventSchema(event)).toEqual(false); + }); +}); diff --git a/packages/api/src/api/extras/events.ts b/packages/api/src/api/extras/events.ts index c8d592be..af0a3631 100644 --- a/packages/api/src/api/extras/events.ts +++ b/packages/api/src/api/extras/events.ts @@ -17,6 +17,16 @@ import Joi from 'joi'; import { RoomEvent, StateEvent, ToDeviceMessageEvent } from '../types'; +/** + * The type of the power levels state event. + */ +export const STATE_EVENT_POWER_LEVELS = 'm.room.power_levels'; + +/** + * The types of type of the create event. + */ +export const STATE_EVENT_CREATE = 'm.room.create'; + /** * Check if the given event is a {@link StateEvent}. * @@ -94,17 +104,21 @@ export function isValidToDeviceMessageEvent( return true; } -/** - * Base properties to validate for all events. - */ -const eventSchemaProps = { - type: Joi.string().required(), +const eventSchemaBasicProps = { // Do roughly check against the format // https://spec.matrix.org/v1.13/appendices/#common-identifier-format sender: Joi.string().pattern(new RegExp('^@[^\\s:]*:\\S*$')).required(), // Prior versions of the code had checked for a server_name. However in room version 12+ this got dropped. There is no way for us to check this here. room_id: Joi.string().pattern(new RegExp('^!')).required(), +}; + +/** + * Base properties to validate for all events. + */ +const eventSchemaProps = { + type: Joi.string().required(), content: Joi.object().required(), + ...eventSchemaBasicProps, }; export const roomEventSchema = Joi.object({ @@ -126,3 +140,113 @@ export const toDeviceMessageSchema = Joi.object({ encrypted: Joi.boolean().required(), content: Joi.object().required(), }).unknown(); + +export type StateEventCreateContent = { + room_version?: string; // Room version 1 does not have a room version, so we allow it to be undefined. + creator?: string; // The user ID of the creator of the room. + additional_creators?: string[]; // The user IDs of additional creators of the room. +}; + +export const createEventSchema = Joi.object< + StateEvent +>({ + ...eventSchemaBasicProps, + type: Joi.string().equal(STATE_EVENT_CREATE).required(), + content: Joi.object({ + // Room version 1 does not have a room version, so we allow it to be undefined. + room_version: Joi.string().optional(), + // The user ID of the creator of the room. (only from 1-10. after that we must use the sender field) + creator: Joi.string().optional(), + // Room version 12 introduces the additional_creators field. + additional_creators: Joi.array().items(Joi.string()).optional(), + }) + .unknown() + .required(), +}).unknown(); + +/** + * Validates that `event` is has a valid structure for a + * {@link StateEventCreateContent}. + * @param event - The event to validate. + * @returns True, if the event is valid. + */ +export function isValidCreateEventSchema( + event: StateEvent | undefined, +): event is StateEvent { + if (!event) { + return true; + } + const result = createEventSchema.validate(event); + if (result.error) { + console.warn('Invalid room create message event:', result.error.details, { + event, + }); + return false; + } + return true; +} + +/** + * The types of actions. + */ +export type PowerLevelsActions = 'invite' | 'kick' | 'ban' | 'redact'; + +/** + * The content of an `m.room.power_levels` event. + */ +export type PowerLevelsStateEvent = { + events?: { [key: string]: number }; + state_default?: number; + events_default?: number; + users?: { [key: string]: number }; + users_default?: number; + ban?: number; + invite?: number; + kick?: number; + redact?: number; +}; + +export const powerLevelsEventSchema = Joi.object< + StateEvent +>({ + ...eventSchemaBasicProps, + // Strictly require to match the power levels event type + type: Joi.string().equal(STATE_EVENT_POWER_LEVELS).required(), + content: Joi.object({ + ban: Joi.number().optional().default(50), + events: Joi.object().pattern(Joi.string(), Joi.number()).optional(), + events_default: Joi.number().optional().default(0), + invite: Joi.number().optional().default(0), + kick: Joi.number().optional().default(50), + notifications: Joi.object({ + room: Joi.number().optional().default(50), + }) + .unknown() + .optional(), + redact: Joi.number().optional().default(50), + state_default: Joi.number().optional().default(50), + users: Joi.object().pattern(Joi.string(), Joi.number()).optional(), + users_default: Joi.number().optional().default(0), + }) + .unknown() + .required(), +}).unknown(); + +/** + * Validates that `event` is has a valid structure for a + * {@link PowerLevelsStateEvent}. + * @param event - The event to validate. + * @returns True, if the event is valid. + */ +export function isValidPowerLevelStateEvent( + event: StateEvent, +): event is StateEvent { + const result = powerLevelsEventSchema.validate(event); + if (result.error) { + console.warn('Invalid powerlevel event:', result.error.details, { + event, + }); + return false; + } + return true; +} diff --git a/packages/api/src/api/extras/index.ts b/packages/api/src/api/extras/index.ts index e3614b89..470eaba3 100644 --- a/packages/api/src/api/extras/index.ts +++ b/packages/api/src/api/extras/index.ts @@ -17,12 +17,19 @@ export { generateRoomTimelineCapabilities } from './capabilities'; export { getRoomMemberDisplayName } from './displayName'; export { + STATE_EVENT_CREATE, isRoomEvent, isStateEvent, + isValidCreateEventSchema, isValidRoomEvent, isValidStateEvent as isValidStateEVent, isValidToDeviceMessageEvent, } from './events'; +export type { + PowerLevelsActions, + PowerLevelsStateEvent, + StateEventCreateContent, +} from './events'; export { WIDGET_CAPABILITY_NAVIGATE, navigateToRoom } from './navigateTo'; export type { NavigateToRoomOptions } from './navigateTo'; export { compareOriginServerTS } from './originServerTs'; @@ -34,7 +41,7 @@ export { hasStateEventPower, isValidPowerLevelStateEvent, } from './powerLevel'; -export type { PowerLevelsActions, PowerLevelsStateEvent } from './powerLevel'; +export type { ROOM_VERSION_12_CREATOR, UserPowerLevelType } from './powerLevel'; export { ROOM_EVENT_REDACTION, isValidRedactionEvent, diff --git a/packages/api/src/api/extras/powerLevel.test.ts b/packages/api/src/api/extras/powerLevel.test.ts index 249e1e87..3270f920 100644 --- a/packages/api/src/api/extras/powerLevel.test.ts +++ b/packages/api/src/api/extras/powerLevel.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest'; import { StateEvent } from '../types'; +import { StateEventCreateContent } from './events'; import { calculateActionPowerLevel, calculateRoomEventPowerLevel, @@ -24,119 +25,63 @@ import { hasActionPower, hasRoomEventPower, hasStateEventPower, - isValidPowerLevelStateEvent, + ROOM_VERSION_12_CREATOR, } from './powerLevel'; -describe('isValidPowerLevelStateEvent', () => { - it('should permit valid event', () => { - const event: StateEvent = { - content: { - events: { - 'event-name': 50, - }, - users_default: 25, - }, - event_id: 'event-id', - origin_server_ts: 0, - room_id: 'room-id', - sender: 'user-id', - state_key: '', - type: 'm.room.power_levels', - }; - - expect(isValidPowerLevelStateEvent(event)).toEqual(true); - }); - - it('should permit additional properties', () => { - const event: StateEvent = { - content: { - additionalProperty: true, - }, - event_id: 'event-id', - origin_server_ts: 0, - room_id: 'room-id', - sender: 'user-id', - state_key: '', - type: 'm.room.power_levels', - }; - - expect(isValidPowerLevelStateEvent(event)).toEqual(true); - }); - - it('should deny wrong event type', () => { - const event: StateEvent = { - content: {}, - event_id: 'event-id', - origin_server_ts: 0, - room_id: 'room-id', - sender: 'user-id', - state_key: '', - type: 'another-type', - }; - - expect(isValidPowerLevelStateEvent(event)).toEqual(false); - }); - - it('should deny wrong event structure (wrong type for events_default)', () => { - const event: StateEvent = { - content: { - events_default: 'test', - }, - event_id: 'event-id', - origin_server_ts: 0, - room_id: 'room-id', - sender: 'user-id', - state_key: '', - type: 'm.room.power_levels', - }; - - expect(isValidPowerLevelStateEvent(event)).toEqual(false); - }); - - it('should deny wrong event structure (null value for events)', () => { - const event: StateEvent = { - content: { - events: null, - }, - event_id: 'event-id', - origin_server_ts: 0, - room_id: 'room-id', - sender: 'user-id', - state_key: '', - type: 'm.room.power_levels', - }; - - expect(isValidPowerLevelStateEvent(event)).toEqual(false); - }); - - it('should deny wrong event structure (wrong type for events key-value pairs)', () => { - const event: StateEvent = { - content: { - events: { - 'event-type': false, - }, - }, - event_id: 'event-id', - origin_server_ts: 0, - room_id: 'room-id', - sender: 'user-id', - state_key: '', - type: 'm.room.power_levels', - }; - - expect(isValidPowerLevelStateEvent(event)).toEqual(false); - }); -}); +const room_version_11_create_event: StateEvent = { + content: { + room_version: '11', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', +}; + +const room_version_12_create_event: StateEvent = { + content: { + room_version: '12', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', +}; +const room_version_12_create_event_with_additional_creators: StateEvent = + { + content: { + room_version: '12', + additional_creators: ['@other-creator:example.com'], + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }; describe('hasRoomEventPower', () => { it('should permit if event is missing', () => { - expect(hasRoomEventPower(undefined, 'userId', 'my-event')).toEqual(true); + expect( + hasRoomEventPower( + undefined, + room_version_11_create_event, + 'userId', + 'my-event', + ), + ).toEqual(true); }); it('should permit if user level is high enough', () => { expect( hasRoomEventPower( { users: { userId: 30 }, events_default: 30 }, + room_version_11_create_event, 'userId', 'my-event', ), @@ -147,6 +92,7 @@ describe('hasRoomEventPower', () => { expect( hasRoomEventPower( { users: { userId: 10 }, events_default: 20 }, + room_version_11_create_event, 'userId', 'my-event', ), @@ -155,14 +101,22 @@ describe('hasRoomEventPower', () => { }); describe('hasStateEventPower', () => { - it('should permit if event is missing', () => { - expect(hasStateEventPower(undefined, 'userId', 'my-event')).toEqual(true); + it('should NOT permit if event is missing', () => { + expect( + hasStateEventPower( + undefined, + room_version_11_create_event, + 'userId', + 'my-event', + ), + ).toEqual(false); }); it('should permit if user level is high enough', () => { expect( hasStateEventPower( { users: { userId: 30 }, state_default: 30 }, + room_version_11_create_event, 'userId', 'my-event', ), @@ -173,6 +127,7 @@ describe('hasStateEventPower', () => { expect( hasStateEventPower( { users: { userId: 10 }, state_default: 20 }, + room_version_11_create_event, 'userId', 'my-event', ), @@ -182,32 +137,40 @@ describe('hasStateEventPower', () => { describe('hasActionPower', () => { it('should permit if event is missing', () => { - expect(hasActionPower(undefined, 'userId', 'invite')).toEqual(true); + expect( + hasActionPower( + undefined, + room_version_11_create_event, + 'userId', + 'invite', + ), + ).toEqual(true); }); it('should permit if user level is high enough', () => { expect( - hasActionPower({ users: { userId: 30 }, invite: 30 }, 'userId', 'invite'), + hasActionPower( + { users: { userId: 30 }, invite: 30 }, + room_version_11_create_event, + 'userId', + 'invite', + ), ).toEqual(true); }); it('should reject if user level is too low', () => { expect( - hasActionPower({ users: { userId: 10 }, invite: 20 }, 'userId', 'invite'), + hasActionPower( + { users: { userId: 10 }, invite: 20 }, + room_version_11_create_event, + 'userId', + 'invite', + ), ).toEqual(false); }); }); describe('calculateUserLevel', () => { - it('should return default level if no user id is passed', () => { - expect( - calculateUserPowerLevel({ - users: {}, - users_default: 25, - }), - ).toEqual(25); - }); - it('should return default level if users is not part of the event', () => { expect( calculateUserPowerLevel( @@ -215,6 +178,7 @@ describe('calculateUserLevel', () => { users: {}, users_default: 25, }, + room_version_11_create_event, 'my-user-id', ), ).toEqual(25); @@ -226,13 +190,39 @@ describe('calculateUserLevel', () => { { users: { 'my-user-id': 42 }, }, + room_version_11_create_event, 'my-user-id', ), ).toEqual(42); }); it('should return default user level if event is empty', () => { - expect(calculateUserPowerLevel({}, 'my-user-id')).toEqual(0); + expect( + calculateUserPowerLevel({}, room_version_11_create_event, 'my-user-id'), + ).toEqual(0); + }); + + it('should return ROOM_VERSION_12_CREATOR if user is room creator in a room version 12 room', () => { + expect( + calculateUserPowerLevel( + { + users: { '@another-user-id:example.com': 42 }, + }, + room_version_12_create_event, + '@user-id:example.com', + ), + ).toEqual(ROOM_VERSION_12_CREATOR); + }); + it('should return ROOM_VERSION_12_CREATOR if user is additional creator in a room version 12 room', () => { + expect( + calculateUserPowerLevel( + { + users: { '@another-user-id:example.com': 42 }, + }, + room_version_12_create_event_with_additional_creators, + '@other-creator:example.com', + ), + ).toEqual(ROOM_VERSION_12_CREATOR); }); }); @@ -273,6 +263,7 @@ describe('calculateStateEventPowerLevel', () => { events: {}, state_default: 25, }, + room_version_11_create_event, 'my-event', ), ).toEqual(25); @@ -284,13 +275,20 @@ describe('calculateStateEventPowerLevel', () => { { events: { 'my-event': 42 }, }, + room_version_11_create_event, 'my-event', ), ).toEqual(42); }); it('should return fallback event level if power levels definition is empty', () => { - expect(calculateStateEventPowerLevel({}, 'my-event')).toEqual(50); + expect( + calculateStateEventPowerLevel( + {}, + room_version_11_create_event, + 'my-event', + ), + ).toEqual(50); }); }); @@ -331,3 +329,165 @@ describe('calculateActionPowerLevel', () => { }, ); }); + +describe('Room Version 12 Create Event', () => { + it('should not allow m.room.tombstone events with power level 100', () => { + expect( + calculateStateEventPowerLevel( + { + users: { '@user-id:example.com': 100 }, + state_default: 100, + }, + room_version_12_create_event, + 'm.room.tombstone', + ), + ).toEqual(150); + }); + + it('should allow m.room.tombstone events with power level 150', () => { + expect( + hasStateEventPower( + { + users: { '@user-id:example.com': 150 }, + state_default: 150, + }, + room_version_12_create_event, + '@user-id:example.com', + 'm.room.tombstone', + ), + ).toEqual(true); + }); + + it('should allow kick, ban, redact and invite actions as a room creator', () => { + const plEvent = { + users: { '@user-id:example.com': 100 }, + ban: 100, + invite: 100, + kick: 100, + redact: 100, + }; + expect( + hasActionPower( + plEvent, + room_version_12_create_event, + '@user-id:example.com', + 'ban', + ), + ).toEqual(true); + expect( + hasActionPower( + plEvent, + room_version_12_create_event, + '@user-id:example.com', + 'invite', + ), + ).toEqual(true); + expect( + hasActionPower( + plEvent, + room_version_12_create_event, + '@user-id:example.com', + 'kick', + ), + ).toEqual(true); + expect( + hasActionPower( + plEvent, + room_version_12_create_event, + '@user-id:example.com', + 'redact', + ), + ).toEqual(true); + }); + it('should not allow kick, ban, redact and invite actions when user is additional creator', () => { + const plEvent = { + users: { '@user-id:example.com': 100 }, + ban: 100, + invite: 100, + kick: 100, + redact: 100, + }; + expect( + hasActionPower( + plEvent, + room_version_12_create_event_with_additional_creators, + '@other-creator:example.com', + 'ban', + ), + ).toEqual(true); + expect( + hasActionPower( + plEvent, + room_version_12_create_event_with_additional_creators, + '@other-creator:example.com', + 'invite', + ), + ).toEqual(true); + expect( + hasActionPower( + plEvent, + room_version_12_create_event_with_additional_creators, + '@other-creator:example.com', + 'kick', + ), + ).toEqual(true); + expect( + hasActionPower( + plEvent, + room_version_12_create_event_with_additional_creators, + '@other-creator:example.com', + 'redact', + ), + ).toEqual(true); + }); +}); + +describe('Creator is detected correctly in room versions 1-10 vs 11+', () => { + it('should return true for creator in room version 11+', () => { + const plEvent = undefined; + for (const version of ['11', '12']) { + expect( + hasStateEventPower( + plEvent, + { + content: { + room_version: version, + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }, + '@user-id:example.com', + 'm.room.create', + ), + ).toEqual(true); + } + }); + it('should return true for creator in room version 1-10', () => { + const plEvent = undefined; + for (const version of ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']) { + expect( + hasStateEventPower( + plEvent, + { + content: { + room_version: version, + creator: '@user-id:example.com', + }, + event_id: 'event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + sender: '@user-id:example.com', + state_key: '', + type: 'm.room.create', + }, + '@user-id:example.com', + 'm.room.create', + ), + ).toEqual(true); + } + }); +}); diff --git a/packages/api/src/api/extras/powerLevel.ts b/packages/api/src/api/extras/powerLevel.ts index 86522439..a628a3eb 100644 --- a/packages/api/src/api/extras/powerLevel.ts +++ b/packages/api/src/api/extras/powerLevel.ts @@ -15,154 +15,105 @@ */ import { StateEvent } from '../types'; +import type { + PowerLevelsActions, + PowerLevelsStateEvent, + StateEventCreateContent, +} from './events'; +export { + isValidPowerLevelStateEvent, + STATE_EVENT_POWER_LEVELS, +} from './events'; /** - * The name of the power levels state event. + * Room version 12 requires us to have something larger than Max integer for room creators. + * This is a workaround to allow the room creator to always have the highest power level. */ -export const STATE_EVENT_POWER_LEVELS = 'm.room.power_levels'; +export const ROOM_VERSION_12_CREATOR = 'ROOM_VERSION_12_CREATOR'; -/** - * The types of actions. - */ -export type PowerLevelsActions = 'invite' | 'kick' | 'ban' | 'redact'; - -/** - * The content of an `m.room.power_levels` event. - */ -export type PowerLevelsStateEvent = { - events?: { [key: string]: number }; - state_default?: number; - events_default?: number; - users?: { [key: string]: number }; - users_default?: number; - ban?: number; - invite?: number; - kick?: number; - redact?: number; -}; - -function isNumberOrUndefined(value: unknown): boolean { - return value === undefined || typeof value === 'number'; -} - -function isStringToNumberMapOrUndefined(value: unknown) { - return ( - value === undefined || - (value !== null && - typeof value === 'object' && - Object.entries(value).every( - ([k, v]) => typeof k === 'string' && typeof v === 'number', - )) - ); -} +export type UserPowerLevelType = number | typeof ROOM_VERSION_12_CREATOR; -/** - * Validates that `event` is has a valid structure for a - * {@link PowerLevelsStateEvent}. - * @param event - The event to validate. - * @returns True, if the event is valid. - */ -export function isValidPowerLevelStateEvent( - event: StateEvent, -): event is StateEvent { - if ( - event.type !== STATE_EVENT_POWER_LEVELS || - typeof event.content !== 'object' - ) { - return false; - } - - const content = event.content as Partial; - - if (!isStringToNumberMapOrUndefined(content.events)) { - return false; - } - - if (!isNumberOrUndefined(content.state_default)) { - return false; - } - - if (!isNumberOrUndefined(content.events_default)) { - return false; - } - - if (!isStringToNumberMapOrUndefined(content.users)) { - return false; - } - - if (!isNumberOrUndefined(content.users_default)) { - return false; - } - - if (!isNumberOrUndefined(content.ban)) { - return false; - } - - if (!isNumberOrUndefined(content.invite)) { - return false; - } - - if (!isNumberOrUndefined(content.kick)) { - return false; +function compareUserPowerLevelToNormalPowerLevel( + userPowerLevel: UserPowerLevelType, + normalPowerLevel: number, +): boolean { + if (userPowerLevel === ROOM_VERSION_12_CREATOR) { + // Room version 12 creator has the highest power level. + return true; } - - if (!isNumberOrUndefined(content.redact)) { + if (typeof userPowerLevel !== 'number') { + // If the user power level is not a number, we cannot compare it to a normal power level. return false; } - - return true; + // Compare the user power level to the normal power level. + return userPowerLevel >= normalPowerLevel; } /** * Check if a user has the power to send a specific room event. * * @param powerLevelStateEvent - the content of the `m.room.power_levels` event + * @param createRoomStateEvent - the `m.room.create` event for the room * @param userId - the id of the user * @param eventType - the type of room event * @returns if true, the user has the power */ export function hasRoomEventPower( powerLevelStateEvent: PowerLevelsStateEvent | undefined, + createRoomStateEvent: StateEvent | undefined, userId: string | undefined, eventType: string, ): boolean { - if (!powerLevelStateEvent) { - // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L36-L43 - return true; + if (!userId) { + // This is invalid but required to be checked due to widget API which may not know it + throw new Error( + 'Cannot check action power without a user ID. Please provide a user ID.', + ); } - - const userLevel = calculateUserPowerLevel(powerLevelStateEvent, userId); + const userLevel = calculateUserPowerLevel( + powerLevelStateEvent, + createRoomStateEvent, + userId, + ); const eventLevel = calculateRoomEventPowerLevel( powerLevelStateEvent, eventType, ); - return userLevel >= eventLevel; + return compareUserPowerLevelToNormalPowerLevel(userLevel, eventLevel); } /** * Check if a user has the power to send a specific state event. * * @param powerLevelStateEvent - the content of the `m.room.power_levels` event + * @param createRoomStateEvent - the `m.room.create` event for the room * @param userId - the id of the user * @param eventType - the type of state event * @returns if true, the user has the power */ export function hasStateEventPower( powerLevelStateEvent: PowerLevelsStateEvent | undefined, + createRoomStateEvent: StateEvent | undefined, userId: string | undefined, eventType: string, ): boolean { - if (!powerLevelStateEvent) { - // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L36-L43 - return true; + if (!userId) { + // This is invalid but required to be checked due to widget API which may not know it + throw new Error( + 'Cannot check action power without a user ID. Please provide a user ID.', + ); } - - const userLevel = calculateUserPowerLevel(powerLevelStateEvent, userId); + const userLevel = calculateUserPowerLevel( + powerLevelStateEvent, + createRoomStateEvent, + userId, + ); const eventLevel = calculateStateEventPowerLevel( powerLevelStateEvent, + createRoomStateEvent, eventType, ); - return userLevel >= eventLevel; + return compareUserPowerLevelToNormalPowerLevel(userLevel, eventLevel); } /** @@ -175,42 +126,93 @@ export function hasStateEventPower( * * redact: Redact a message from another user * * @param powerLevelStateEvent - the content of the `m.room.power_levels` event + * @param createRoomStateEvent - the `m.room.create` event for the room * @param userId - the id of the user * @param action - the action * @returns if true, the user has the power */ export function hasActionPower( powerLevelStateEvent: PowerLevelsStateEvent | undefined, + createRoomStateEvent: StateEvent | undefined, userId: string | undefined, action: PowerLevelsActions, ): boolean { - if (!powerLevelStateEvent) { - // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L36-L43 - return true; + if (!userId) { + // This is invalid but required to be checked due to widget API which may not know it + throw new Error( + 'Cannot check action power without a user ID. Please provide a user ID.', + ); } - - const userLevel = calculateUserPowerLevel(powerLevelStateEvent, userId); + const userLevel = calculateUserPowerLevel( + powerLevelStateEvent, + createRoomStateEvent, + userId, + ); const eventLevel = calculateActionPowerLevel(powerLevelStateEvent, action); - return userLevel >= eventLevel; + return compareUserPowerLevelToNormalPowerLevel(userLevel, eventLevel); } /** * Calculate the power level of the user based on a `m.room.power_levels` event. * + * Note that we return the @see UserPowerLevelType type instead of a number as Room Version 12 + * gives a Room creator (and additionalCreators) always the highest power level regardless + * of the highest next Powerlevel number. + * * @param powerLevelStateEvent - the content of the `m.room.power_levels` event. + * @param createRoomStateEvent - the `m.room.create` event for the room. * @param userId - the ID of the user. * @returns the power level of the user. */ export function calculateUserPowerLevel( - powerLevelStateEvent: PowerLevelsStateEvent, - userId?: string, -): number { - // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L8-L12 - return ( - (userId ? powerLevelStateEvent.users?.[userId] : undefined) ?? - powerLevelStateEvent.users_default ?? - 0 - ); + powerLevelStateEvent: PowerLevelsStateEvent | undefined, + createRoomStateEvent: StateEvent | undefined, + userId: string, +): UserPowerLevelType { + // This is practically not allowed and therefor not covered by the spec. However a js consumer could still pass an undefined userId so we handle it gracefully. + if (!userId) { + // If no user ID is provided, we return the default user power level or 0 if not set. + return 0; + } + // If we have room version 12 we must check if the user is the creator of the room and needs to have the highest power level. + if ( + createRoomStateEvent?.content?.room_version === '12' || + createRoomStateEvent?.content?.room_version === 'org.matrix.hydra.11' + ) { + // If the user is the creator of the room, we return the special ROOM_VERSION_12_CREATOR value. + if (createRoomStateEvent.sender === userId) { + return ROOM_VERSION_12_CREATOR; + } + if (createRoomStateEvent.content.additional_creators?.includes(userId)) { + // If the user is an additional creator of the room, we return the special ROOM_VERSION_12_CREATOR value. + return ROOM_VERSION_12_CREATOR; + } + } + + // If there is no power level state event, we assume the user has no power unless they are the room creator in which case they get PL 100. + if (!powerLevelStateEvent) { + if ( + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].includes( + createRoomStateEvent?.content?.room_version ?? '1', + ) + ) { + // Room version 1-10 does not have a room version, so we assume the creator has power level 100. + return createRoomStateEvent?.content?.creator === userId ? 100 : 0; + } else { + // For room versions 11 and above, we assume the sender has power level 100. + return createRoomStateEvent?.sender === userId ? 100 : 0; + } + } + if (powerLevelStateEvent.users && userId in powerLevelStateEvent.users) { + // If the user is explicitly listed in the users map, return their power level. + return powerLevelStateEvent.users[userId]; + } else if (powerLevelStateEvent.users_default !== undefined) { + // If the user is not explicitly listed, return the default user power level. + return powerLevelStateEvent.users_default; + } else { + // If no users or default is set, return 0. + return 0; + } } /** @@ -221,13 +223,13 @@ export function calculateUserPowerLevel( * @returns the power level that is needed */ export function calculateRoomEventPowerLevel( - powerLevelStateEvent: PowerLevelsStateEvent, + powerLevelStateEvent: PowerLevelsStateEvent | undefined, eventType: string, ): number { // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L14-L19 return ( - powerLevelStateEvent.events?.[eventType] ?? - powerLevelStateEvent.events_default ?? + powerLevelStateEvent?.events?.[eventType] ?? + powerLevelStateEvent?.events_default ?? 0 ); } @@ -236,17 +238,28 @@ export function calculateRoomEventPowerLevel( * Calculate the power level that a user needs send a specific state event. * * @param powerLevelStateEvent - the content of the `m.room.power_levels` event + * @param createRoomStateEvent - the `m.room.create` event * @param eventType - the type of state event * @returns the power level that is needed */ export function calculateStateEventPowerLevel( - powerLevelStateEvent: PowerLevelsStateEvent, + powerLevelStateEvent: PowerLevelsStateEvent | undefined, + createRoomStateEvent: StateEvent | undefined, eventType: string, ): number { + // In room version 12 (and the beta org.matrix.hydra.11 version) we need 150 for m.room.tombstone events and it cant be changed by the user. + if ( + (createRoomStateEvent?.content?.room_version === '12' || + createRoomStateEvent?.content?.room_version === 'org.matrix.hydra.11') && + eventType === 'm.room.tombstone' + ) { + return 150; + } + // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L14-L19 return ( - powerLevelStateEvent.events?.[eventType] ?? - powerLevelStateEvent.state_default ?? + powerLevelStateEvent?.events?.[eventType] ?? + powerLevelStateEvent?.state_default ?? 50 ); } @@ -265,7 +278,7 @@ export function calculateStateEventPowerLevel( * @returns the power level that is needed */ export function calculateActionPowerLevel( - powerLevelStateEvent: PowerLevelsStateEvent, + powerLevelStateEvent: PowerLevelsStateEvent | undefined, action: PowerLevelsActions, ): number { // See https://github.com/matrix-org/matrix-spec/blob/203b9756f52adfc2a3b63d664f18cdbf9f8bf126/data/event-schemas/schema/m.room.power_levels.yaml#L27-L32 diff --git a/packages/api/src/api/extras/relatesTo.test.ts b/packages/api/src/api/extras/relatesTo.test.ts index e84cf80b..4dd9f5e4 100644 --- a/packages/api/src/api/extras/relatesTo.test.ts +++ b/packages/api/src/api/extras/relatesTo.test.ts @@ -88,7 +88,7 @@ describe('isValidEventWithRelatesTo', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), @@ -108,7 +108,7 @@ describe('isValidEventWithRelatesTo', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), @@ -131,7 +131,7 @@ describe('isValidEventWithRelatesTo', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), @@ -157,7 +157,7 @@ describe('isValidEventWithRelatesTo', () => { }, event_id: '$event-id', origin_server_ts: 0, - room_id: '!room-id', + room_id: '!room-id:example.com', sender: '@user-id', type: 'm.room.message', }), diff --git a/packages/testing/README.md b/packages/testing/README.md index f60a46e9..9fe863f3 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -44,16 +44,16 @@ events: // Prepopulate the power levels event in the room: widgetApi.mockSendStateEvent({ type: 'm.room.power_levels', - sender: '@user-id', + sender: '@user-id:example.com', content: { users: { - '@my-user': 100, + '@my-user:example.com': 100, }, }, state_key: '', origin_server_ts: 0, event_id: '$event-id-0', - room_id: '!room-id', + room_id: '!room-id:example.com', }); // You can receive it using any of the methods of the widget api: diff --git a/packages/testing/src/api/mockWidgetApi.test.ts b/packages/testing/src/api/mockWidgetApi.test.ts index 6ea67cf7..09fc75b0 100644 --- a/packages/testing/src/api/mockWidgetApi.test.ts +++ b/packages/testing/src/api/mockWidgetApi.test.ts @@ -32,9 +32,9 @@ beforeEach(() => { content: { id: 1, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-1', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 1, }); widgetApi.mockSendRoomEvent({ @@ -42,7 +42,7 @@ beforeEach(() => { content: { id: 3, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-3', room_id: '!other-room-id', origin_server_ts: 3, @@ -52,9 +52,9 @@ beforeEach(() => { content: { id: 2, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-2', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 2, }); @@ -64,9 +64,9 @@ beforeEach(() => { content: { id: 1, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-1', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 1, }); widgetApi.mockSendStateEvent({ @@ -75,9 +75,9 @@ beforeEach(() => { content: { id: 2, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-2', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 2, }); widgetApi.mockSendStateEvent({ @@ -86,9 +86,9 @@ beforeEach(() => { content: { id: 3, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-3', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 3, }); widgetApi.mockSendStateEvent({ @@ -97,7 +97,7 @@ beforeEach(() => { content: { id: 4, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-4', room_id: '!other-room-id', origin_server_ts: 4, @@ -114,8 +114,8 @@ describe('sendRoomEvent', () => { }, event_id: expect.any(String), origin_server_ts: expect.any(Number), - room_id: '!room-id', - sender: '@user-id', + room_id: '!room-id:example.com', + sender: '@user-id:example.com', type: 'com.example.test3', }; @@ -136,8 +136,8 @@ describe('sendRoomEvent', () => { content: {}, event_id: expect.any(String), origin_server_ts: expect.any(Number), - room_id: '!room-id', - sender: '@user-id', + room_id: '!room-id:example.com', + sender: '@user-id:example.com', type: 'm.room.redaction', redacts: '$event-id', }; @@ -157,8 +157,8 @@ describe('sendRoomEvent', () => { content: {}, event_id: 'event-1', origin_server_ts: expect.any(Number), - room_id: '!room-id', - sender: '@user-id', + room_id: '!room-id:example.com', + sender: '@user-id:example.com', type: 'com.example.test1', }; @@ -228,9 +228,9 @@ describe('receiveRoomEvents', () => { id: 1, msgtype: 'only', }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-2', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 1, }); @@ -308,9 +308,9 @@ describe('observeRoomEvents', () => { content: { id: 4, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-4', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 4, }); @@ -327,9 +327,9 @@ describe('observeRoomEvents', () => { id: 1, msgtype: 'only', }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-2', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 1, }); @@ -362,8 +362,8 @@ describe('sendStateEvent', () => { }, event_id: expect.any(String), origin_server_ts: expect.any(Number), - room_id: '!room-id', - sender: '@user-id', + room_id: '!room-id:example.com', + sender: '@user-id:example.com', state_key: '', type: 'com.example.test6', }; @@ -383,8 +383,8 @@ describe('sendStateEvent', () => { }, event_id: expect.any(String), origin_server_ts: expect.any(Number), - room_id: '!room-id', - sender: '@user-id', + room_id: '!room-id:example.com', + sender: '@user-id:example.com', state_key: '', type: 'com.example.test6', }; @@ -506,9 +506,9 @@ describe('receiveStateEvents', () => { content: { id: 5, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-5', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 5, }); @@ -588,9 +588,9 @@ describe('observeStateEvents', () => { content: { id: 5, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-5', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 5, }); @@ -615,9 +615,9 @@ describe('observeStateEvents', () => { content: { id: 5, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-5', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 5, }); @@ -735,9 +735,9 @@ describe('readEventRelations', () => { content: { id: 1, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-1', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 1, }); widgetApi.mockSendRoomEvent({ @@ -749,9 +749,9 @@ describe('readEventRelations', () => { event_id: 'event-1', }, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-2', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 3, }); widgetApi.mockSendRoomEvent({ @@ -763,9 +763,9 @@ describe('readEventRelations', () => { event_id: 'event-1', }, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-3', - room_id: '!room-id', + room_id: '!room-id:example.com', origin_server_ts: 2, }); widgetApi.mockSendRoomEvent({ @@ -777,7 +777,7 @@ describe('readEventRelations', () => { event_id: 'event-1', }, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-4', room_id: '!other-room-id', origin_server_ts: 2, @@ -787,7 +787,7 @@ describe('readEventRelations', () => { content: { id: 5, }, - sender: '@user-id', + sender: '@user-id:example.com', event_id: 'event-1', room_id: '!other-room-id', origin_server_ts: 1, @@ -870,7 +870,7 @@ describe('sendToDeviceMessage', () => { ); await widgetApi.sendToDeviceMessage('com.example.message', true, { - '@user-id': { + '@user-id:example.com': { '*': { my: 'content' }, }, }); @@ -878,7 +878,7 @@ describe('sendToDeviceMessage', () => { await expect(messagePromise).resolves.toEqual({ content: { my: 'content' }, encrypted: true, - sender: '@user-id', + sender: '@user-id:example.com', type: 'com.example.message', }); }); @@ -889,7 +889,7 @@ describe('sendToDeviceMessage', () => { ); await widgetApi.sendToDeviceMessage('com.example.message', false, { - '@user-id': { + '@user-id:example.com': { 'device-id': { my: 'content' }, }, }); @@ -899,7 +899,7 @@ describe('sendToDeviceMessage', () => { my: 'content', }, encrypted: false, - sender: '@user-id', + sender: '@user-id:example.com', type: 'com.example.message', }); }); @@ -931,7 +931,7 @@ describe('sendToDeviceMessage', () => { '@other-user-id': { '*': { other: 'content' }, }, - '@user-id': { + '@user-id:example.com': { '*': { my: 'content' }, }, }); @@ -939,7 +939,7 @@ describe('sendToDeviceMessage', () => { await expect(messagesPromise).resolves.toEqual([ { content: { my: 'content' }, - sender: '@user-id', + sender: '@user-id:example.com', encrypted: false, type: 'com.example.message', }, @@ -956,21 +956,21 @@ describe('observeToDeviceMessages', () => { widgetApi.mockSendToDeviceMessage({ content: { other: 'content' }, encrypted: false, - sender: '@user-id', + sender: '@user-id:example.com', type: 'com.example.other', }); widgetApi.mockSendToDeviceMessage({ content: { my: 'content' }, encrypted: false, - sender: '@user-id', + sender: '@user-id:example.com', type: 'com.example.message', }); await expect(messagePromise).resolves.toEqual({ content: { my: 'content' }, encrypted: false, - sender: '@user-id', + sender: '@user-id:example.com', type: 'com.example.message', }); }); diff --git a/packages/testing/src/api/mockWidgetApi.ts b/packages/testing/src/api/mockWidgetApi.ts index b899a7d4..ecc176c0 100644 --- a/packages/testing/src/api/mockWidgetApi.ts +++ b/packages/testing/src/api/mockWidgetApi.ts @@ -115,7 +115,7 @@ export type MockedWidgetApi = { * Use `userId` to specify who uses the widget * (default: '\@user-id'). * Use `roomId` to specify the room where the widget is installed - * (default: '!room-id'). + * (default: '!room-id:example.com'). * Use `widgetId` to specify the ID of the widget * (default: 'widget-id'). * @@ -127,8 +127,8 @@ export function mockWidgetApi(opts?: { widgetId?: string; }): MockedWidgetApi { const { - userId = '@user-id', - roomId = '!room-id', + userId = '@user-id:example.com', + roomId = '!room-id:example.com', widgetId = 'widget-id', } = opts ?? {}; const roomEventSubject = new Subject();