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();