Merge branch 'develop' into t3chguy/safari-compat

This commit is contained in:
Michael Telatynski
2025-01-23 09:53:58 +00:00
committed by GitHub
65 changed files with 1775 additions and 1282 deletions

View File

@@ -96,3 +96,4 @@ jobs:
projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }}
directory: _deploy
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
branch: main

View File

@@ -51,7 +51,7 @@ jobs:
- name: Build and push
id: build-and-push
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6
with:
context: .
push: true

View File

@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@56cd38caf0615dd03f49d42ed301f1469911ac61
uses: guibranco/github-status-action-v2@ecd54a02cf761e85a8fb328fe937710fd4227cda
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success

View File

@@ -8,11 +8,13 @@
#### develop
The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable. It corresponds to the develop.element.io CD platform.
The develop branch holds the very latest and greatest code we have to offer, as such it may be less stable.
It is auto-deployed on every commit to element-web or matrix-js-sdk to develop.element.io via GitHub Actions `build_develop.yml`.
#### staging
The staging branch corresponds to the very latest release regardless of whether it is an RC or not. Deployed to staging.element.io manually.
It is auto-deployed on every release of element-web to staging.element.io via GitHub Actions `deploy.yml`.
#### master
@@ -215,7 +217,7 @@ We ship Element Web to dockerhub, `*.element.io`, and packages.element.io.
We ship Element Desktop to packages.element.io.
- [ ] Check that element-web has shipped to dockerhub
- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio)
- [ ] Check that the staging [deployment](https://github.com/element-hq/element-web/actions/workflows/deploy.yml) has completed successfully
- [ ] Test staging.element.io
For final releases additionally do these steps:
@@ -225,6 +227,9 @@ For final releases additionally do these steps:
- [ ] Ensure Element Web package has shipped to packages.element.io
- [ ] Ensure Element Desktop packages have shipped to packages.element.io
If you need to roll back a deployment to staging.element.io,
you can run the `deploy.yml` automation choosing an older tag which you wish to deploy.
# Housekeeping
We have some manual housekeeping to do in order to prepare for the next release.

View File

@@ -74,7 +74,7 @@
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001690",
"caniuse-lite": "1.0.30001692",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -178,7 +178,7 @@
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.40.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@sentry/webpack-plugin": "^2.7.1",
"@sentry/webpack-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^2.9.0",
"@svgr/webpack": "^8.0.0",
"@testcontainers/postgresql": "^10.16.0",
@@ -230,7 +230,7 @@
"dotenv": "^16.0.2",
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "0.8.5",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^28.0.0",
@@ -287,7 +287,7 @@
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^10.16.0",
"ts-node": "^10.9.1",
"typescript": "5.7.2",
"typescript": "5.7.3",
"util": "^0.12.5",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",

View File

@@ -42,7 +42,7 @@ test.describe("Memberlist", () => {
await app.viewRoomByName(ROOM_NAME);
const memberlist = await app.toggleMemberlistPanel();
await expect(memberlist.locator(".mx_MemberTileView")).toHaveCount(4);
await expect(memberlist.getByText("(Invited)")).toHaveCount(1);
await expect(memberlist.getByText("Invited")).toHaveCount(1);
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
});
});

View File

@@ -69,11 +69,6 @@ const test = base.extend<{
});
test.describe("Sliding Sync", () => {
test.skip(
({ homeserverType }) => homeserverType === "pinecone",
"due to a bug in Pinecone https://github.com/element-hq/dendrite/issues/3490",
);
const checkOrder = async (wantOrder: string[], page: Page) => {
await expect(page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile_title")).toHaveText(wantOrder);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts";
const TAG = "develop@sha256:e48308d68dec00af6ce43a05785d475de21a37bc2afaabb440d3a575bcc3d57d";
const TAG = "develop@sha256:3594fba0d21ad44f407225baed4be0542da8abcb6e1a7e2e16d3be35c278a7cb";
const DEFAULT_CONFIG = {
server_name: "localhost",

View File

@@ -283,6 +283,7 @@
@import "./views/rooms/_EventTile.pcss";
@import "./views/rooms/_HistoryTile.pcss";
@import "./views/rooms/_IRCLayout.pcss";
@import "./views/rooms/_InvitedIconView.pcss";
@import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss";

View File

@@ -26,7 +26,8 @@ Please see LICENSE files in the repository root for full details.
}
&.mx_UserPill_me,
&.mx_AtRoomPill {
&.mx_AtRoomPill,
&.mx_KeywordPill {
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
}
@@ -45,7 +46,8 @@ Please see LICENSE files in the repository root for full details.
}
/* We don't want to indicate clickability */
&.mx_AtRoomPill:hover {
&.mx_AtRoomPill:hover,
&.mx_KeywordPill:hover {
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
cursor: unset;
}

View File

@@ -35,6 +35,8 @@ Please see LICENSE files in the repository root for full details.
.mx_DisambiguatedProfile_mxid {
margin-inline-start: 0;
font: var(--cpd-font-body-sm-regular);
text-overflow: ellipsis;
overflow: hidden;
}
span:not(.mx_DisambiguatedProfile_mxid) {

View File

@@ -135,12 +135,6 @@ $left-gutter: 64px;
}
}
&.mx_EventTile_highlight,
&.mx_EventTile_highlight .markdown-body,
&.mx_EventTile_highlight .mx_EventTile_edited {
color: $alert;
}
&.mx_EventTile_bubbleContainer {
display: grid;
grid-template-columns: 1fr 100px;

View File

@@ -0,0 +1,10 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_InvitedIconView {
color: var(--cpd-color-icon-tertiary);
}

View File

@@ -14,4 +14,10 @@ Please see LICENSE files in the repository root for full details.
.mx_MemberListView_container {
height: 100%;
}
.mx_MemberListView_separator {
margin: 0;
border: none;
border-top: 2px solid var(--cpd-color-bg-subtle-primary);
}
}

View File

@@ -31,9 +31,11 @@ Please see LICENSE files in the repository root for full details.
min-width: 0;
}
.mx_MemberTileView_user_label {
.mx_MemberTileView_userLabel {
font: var(--cpd-font-body-sm-regular);
font-size: 13px;
color: var(--cpd-color-text-secondary);
margin-left: var(--cpd-space-4x);
}
.mx_MemberTileView_avatar {
@@ -41,18 +43,4 @@ Please see LICENSE files in the repository root for full details.
height: 32px;
width: 32px;
}
.mx_E2EIconView {
display: flex;
justify-content: center;
align-items: center;
}
.mx_E2EIconView_warning {
color: var(--cpd-color-icon-critical-primary);
}
.mx_E2EIconView_verified {
color: var(--cpd-color-icon-success-primary);
}
}

View File

@@ -99,8 +99,12 @@ export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member {
};
}
export const SEPARATOR = "SEPARATOR";
export type MemberWithSeparator = Member | typeof SEPARATOR;
export interface MemberListViewState {
members: Member[];
members: MemberWithSeparator[];
memberCount: number;
search: (searchQuery: string) => void;
isPresenceEnabled: boolean;
shouldShowInvite: boolean;
@@ -118,10 +122,16 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
}
const sdkContext = useContext(SDKContext);
const [memberMap, setMemberMap] = useState<Map<string, Member>>(new Map());
const [memberMap, setMemberMap] = useState<Map<string, MemberWithSeparator>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(true);
// This is the last known total number of members in this room.
const [totalMemberCount, setTotalMemberCount] = useState(0);
/**
* This is the current number of members in the list.
* This number will be less than the total number of members
* in the room when the search functionality is used.
*/
const [memberCount, setMemberCount] = useState(0);
const loadMembers = useMemo(
() =>
@@ -131,24 +141,34 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
roomId,
searchQuery,
);
const newMemberMap = new Map<string, Member>();
// First add the invited room members
for (const member of invitedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
// Then add the third party invites
const threePidInvited = getPending3PidInvites(room, searchQuery);
for (const invited of threePidInvited) {
const key = invited.threePidInvite!.event.getContent().display_name;
newMemberMap.set(key, invited);
}
// Finally add the joined room members
const newMemberMap = new Map<string, MemberWithSeparator>();
// First add the joined room members
for (const member of joinedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
// Then a separator if needed
if (joinedSdk.length > 0 && (invitedSdk.length > 0 || threePidInvited.length > 0))
newMemberMap.set(SEPARATOR, SEPARATOR);
// Then add the invited room members
for (const member of invitedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
// Finally add the third party invites
for (const invited of threePidInvited) {
const key = invited.threePidInvite!.event.getContent().display_name;
newMemberMap.set(key, invited);
}
setMemberMap(newMemberMap);
setMemberCount(joinedSdk.length + invitedSdk.length + threePidInvited.length);
if (!searchQuery) {
/**
* Since searching for members only gives you the relevant
@@ -241,6 +261,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
return {
members: Array.from(memberMap.values()),
memberCount,
search: loadMembers,
shouldShowInvite,
isPresenceEnabled,

View File

@@ -145,7 +145,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
userLabel = _t(PowerLabel[powerStatus]);
}
if (props.member.isInvite) {
userLabel = `(${_t("member_list|invited_label")})`;
userLabel = _t("member_list|invited_label");
}
return {

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import dis from "../../../../dispatcher/dispatcher";
import { Action } from "../../../../dispatcher/actions";
import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite";
import { _t } from "../../../../languageHandler";
interface ThreePidTileViewModelProps {
threePidInvite: ThreePIDInvite;
@@ -16,6 +17,7 @@ interface ThreePidTileViewModelProps {
export interface ThreePidTileViewState {
name: string;
onClick: () => void;
userLabel?: string;
}
export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState {
@@ -28,8 +30,11 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr
});
};
const userLabel = _t("member_list|invited_label");
return {
name,
onClick,
userLabel,
};
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import classNames from "classnames";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import ToggleSwitch from "./ToggleSwitch";
import { Caption } from "../typography/Caption";
@@ -36,7 +36,7 @@ interface IProps {
}
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
private readonly id = `mx_LabelledToggleSwitch_${randomString(12)}`;
private readonly id = `mx_LabelledToggleSwitch_${secureRandomString(12)}`;
public render(): React.ReactNode {
// This is a minimal version of a SettingsFlag

View File

@@ -25,6 +25,7 @@ export enum PillType {
AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention
EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM",
EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM",
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
}
export const pillRoomNotifPos = (text: string | null): number => {
@@ -76,14 +77,32 @@ export interface PillProps {
room?: Room;
// Whether to include an avatar in the pill
shouldShowPillAvatar?: boolean;
// Explicitly-provided text to display in the pill
text?: string;
}
export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => {
const { event, member, onClick, resourceId, targetRoom, text, type } = usePermalink({
export const Pill: React.FC<PillProps> = ({
type: propType,
url,
inMessage,
room,
shouldShowPillAvatar = true,
text: customPillText,
}) => {
const {
event,
member,
onClick,
resourceId,
targetRoom,
text: linkText,
type,
} = usePermalink({
room,
type: propType,
url,
});
const text = customPillText ?? linkText;
if (!type || !text) {
return null;
@@ -96,6 +115,7 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
mx_UserPill: type === PillType.UserMention,
mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
mx_KeywordPill: type === PillType.Keyword,
});
let avatar: ReactElement | null = null;
@@ -131,6 +151,8 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
case PillType.UserMention:
avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />;
break;
case PillType.Keyword:
break;
default:
return null;
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
@@ -35,7 +35,7 @@ interface IState {
}
export default class SettingsFlag extends React.Component<IProps, IState> {
private readonly id = `mx_SettingsFlag_${randomString(12)}`;
private readonly id = `mx_SettingsFlag_${secureRandomString(12)}`;
public constructor(props: IProps) {
super(props);

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { Ref } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import classnames from "classnames";
export enum CheckboxStyle {
@@ -33,7 +33,7 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
public constructor(props: IProps) {
super(props);
// 56^10 so unlikely chance of collision.
this.id = this.props.id || "checkbox_" + randomString(10);
this.id = this.props.id || "checkbox_" + secureRandomString(10);
}
public render(): React.ReactNode {

View File

@@ -18,7 +18,7 @@ import {
ContentHelpers,
M_BEACON,
} from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import classNames from "classnames";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@@ -81,10 +81,10 @@ const useBeaconState = (
// eg thread and main timeline, reply
// maplibregl needs a unique id to attach the map instance to
const useUniqueId = (eventId: string): string => {
const [id, setId] = useState(`${eventId}_${randomString(8)}`);
const [id, setId] = useState(`${eventId}_${secureRandomString(8)}`);
useEffect(() => {
setId(`${eventId}_${randomString(8)}`);
setId(`${eventId}_${secureRandomString(8)}`);
}, [eventId]);
return id;

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { MatrixEvent, ClientEvent, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
@@ -41,7 +41,7 @@ export default class MLocationBody extends React.Component<IBodyProps, IState> {
// multiple instances of same map might be in document
// eg thread and main timeline, reply
const idSuffix = `${props.mxEvent.getId()}_${randomString(8)}`;
const idSuffix = `${props.mxEvent.getId()}_${secureRandomString(8)}`;
this.mapId = `mx_MLocationBody_${idSuffix}`;
this.reconnectedListener = createReconnectedListener(this.clearError);

View File

@@ -7,8 +7,9 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, SyntheticEvent, MouseEvent, StrictMode } from "react";
import { MsgType } from "matrix-js-sdk/src/matrix";
import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web";
import { globToRegexp } from "matrix-js-sdk/src/utils";
import * as HtmlUtils from "../../../HtmlUtils";
import { formatDate } from "../../../DateUtils";
@@ -35,6 +36,7 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { IEventTileOps } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import CodeBlock from "./CodeBlock";
import { Pill, PillType } from "../elements/Pill";
import { ReactRootManager } from "../../../utils/react";
interface IState {
@@ -100,6 +102,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
}
// Highlight notification keywords using pills
const pushDetails = this.props.mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
this.pillifyNotificationKeywords([content], this.regExpForKeywordPattern(pushDetails.rule.pattern));
}
}
private addCodeElement(pre: HTMLPreElement): void {
@@ -210,6 +222,55 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
}
/**
* Marks the text that activated a push-notification keyword pattern.
*/
private pillifyNotificationKeywords(nodes: ArrayLike<Element>, exp: RegExp): void {
let node: Node | null = nodes[0];
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue;
if (!text) {
node = node.nextSibling;
continue;
}
const match = text.match(exp);
if (!match || match.length < 3) {
node = node.nextSibling;
continue;
}
const keywordText = match[2];
const idx = match.index! + match[1].length;
const before = text.substring(0, idx);
const after = text.substring(idx + keywordText.length);
const container = document.createElement("span");
const newContent = (
<>
{before}
<TooltipProvider>
<Pill text={keywordText} type={PillType.Keyword} />
</TooltipProvider>
{after}
</>
);
this.reactRoots.render(newContent, container, node);
node.parentNode?.replaceChild(container, node);
} else if (node.childNodes && node.childNodes.length) {
this.pillifyNotificationKeywords(node.childNodes as NodeListOf<Element>, exp);
}
node = node.nextSibling;
}
}
private regExpForKeywordPattern(pattern: string): RegExp {
// Reflects the push notification pattern-matching implementation at
// https://github.com/matrix-org/matrix-js-sdk/blob/dbd7d26968b94700827bac525c39afff2c198e61/src/pushprocessor.ts#L570
return new RegExp("(^|\\W)(" + globToRegexp(pattern) + ")(\\W|$)", "i");
}
private findLinks(nodes: ArrayLike<Element>): string[] {
let links: string[] = [];

View File

@@ -88,12 +88,10 @@ function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode {
</Flex>
);
}
const filteredMemberCount = vm.members.length;
if (filteredMemberCount === 0) {
if (vm.memberCount === 0) {
return _t("member_list|no_matches");
}
return _t("member_list|count", { count: filteredMemberCount });
return _t("member_list|count", { count: vm.memberCount });
}
export const MemberListHeaderView: React.FC<Props> = (props: Props) => {

View File

@@ -11,7 +11,11 @@ import { List, ListRowProps } from "react-virtualized/dist/commonjs/List";
import { AutoSizer } from "react-virtualized";
import { Flex } from "../../../utils/Flex";
import { useMemberListViewModel } from "../../../viewmodels/memberlist/MemberListViewModel";
import {
MemberWithSeparator,
SEPARATOR,
useMemberListViewModel,
} from "../../../viewmodels/memberlist/MemberListViewModel";
import { RoomMemberTileView } from "./tiles/RoomMemberTileView";
import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
@@ -26,10 +30,41 @@ interface IProps {
const MemberListView: React.FC<IProps> = (props: IProps) => {
const vm = useMemberListViewModel(props.roomId);
const memberCount = vm.members.length;
const totalRows = vm.members.length;
const getRowComponent = (item: MemberWithSeparator): React.JSX.Element => {
if (item === SEPARATOR) {
return <hr className="mx_MemberListView_separator" />;
} else if (item.member) {
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
} else {
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
}
};
const getRowHeight = ({ index }: { index: number }): number => {
if (vm.members[index] === SEPARATOR) {
/**
* This is a separator of 2px height rendered between
* joined and invited members.
*/
return 2;
} else if (totalRows && index === totalRows) {
/**
* The empty spacer div rendered at the bottom should
* have a height of 32px.
*/
return 32;
} else {
/**
* The actual member tiles have a height of 56px.
*/
return 56;
}
};
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => {
if (index === memberCount) {
if (index === totalRows) {
// We've rendered all the members,
// now we render an empty div to add some space to the end of the list.
return <div key={key} style={style} />;
@@ -37,11 +72,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
const item = vm.members[index];
return (
<div key={key} style={style}>
{item.member ? (
<RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />
) : (
<ThreePidInviteTileView threePidInvite={item.threePidInvite} />
)}
{getRowComponent(item)}
</div>
);
};
@@ -63,11 +94,9 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
// All the member tiles will have a height of 56px.
// The additional empty div at the end of the list should have a height of 32px.
rowHeight={({ index }) => (index === memberCount ? 32 : 56)}
rowHeight={getRowHeight}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={memberCount + 1}
rowCount={totalRows + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}

View File

@@ -14,7 +14,8 @@ import { E2EIconView } from "./common/E2EIconView";
import AvatarPresenceIconView from "./common/PresenceIconView";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { _t } from "../../../../../languageHandler";
import { MemberTileLayout } from "./common/MemberTileLayout";
import { MemberTileView } from "./common/MemberTileView";
import { InvitedIconView } from "./common/InvitedIconView";
interface IProps {
member: RoomMember;
@@ -43,25 +44,23 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
presenceJSX = <AvatarPresenceIconView presenceState={presenceState} />;
}
let userLabelJSX;
if (vm.userLabel) {
userLabelJSX = <div className="mx_MemberTileView_user_label">{vm.userLabel}</div>;
}
let e2eIcon;
let iconJsx;
if (vm.e2eStatus) {
e2eIcon = <E2EIconView status={vm.e2eStatus} />;
iconJsx = <E2EIconView status={vm.e2eStatus} />;
}
if (member.isInvite) {
iconJsx = <InvitedIconView isThreePid={false} />;
}
return (
<MemberTileLayout
<MemberTileView
title={vm.title}
onClick={vm.onClick}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabelJsx={userLabelJSX}
e2eIconJsx={e2eIcon}
userLabel={vm.userLabel}
iconJsx={iconJsx}
/>
);
}

View File

@@ -10,7 +10,8 @@ import React from "react";
import { useThreePidTileViewModel } from "../../../../viewmodels/memberlist/tiles/ThreePidTileViewModel";
import { ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { MemberTileLayout } from "./common/MemberTileLayout";
import { MemberTileView } from "./common/MemberTileView";
import { InvitedIconView } from "./common/InvitedIconView";
interface Props {
threePidInvite: ThreePIDInvite;
@@ -19,5 +20,15 @@ interface Props {
export function ThreePidInviteTileView(props: Props): JSX.Element {
const vm = useThreePidTileViewModel(props);
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
return <MemberTileLayout nameJsx={vm.name} avatarJsx={av} onClick={vm.onClick} />;
const iconJsx = <InvitedIconView isThreePid={true} />;
return (
<MemberTileView
nameJsx={vm.name}
avatarJsx={av}
onClick={vm.onClick}
userLabel={vm.userLabel}
iconJsx={iconJsx}
/>
);
}

View File

@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { Flex } from "../../../../../utils/Flex";
interface Props {
isThreePid: boolean;
}
export function InvitedIconView({ isThreePid }: Props): JSX.Element {
const Icon = isThreePid ? EmailIcon : UserAddIcon;
return (
<Flex align="center" className="mx_InvitedIconView">
<Icon height="16px" width="16px" />
</Flex>
);
}

View File

@@ -15,11 +15,16 @@ interface Props {
onClick: () => void;
title?: string;
presenceJsx?: JSX.Element;
userLabelJsx?: JSX.Element;
e2eIconJsx?: JSX.Element;
userLabel?: React.ReactNode;
iconJsx?: JSX.Element;
}
export function MemberTileLayout(props: Props): JSX.Element {
export function MemberTileView(props: Props): JSX.Element {
let userLabelJsx: React.ReactNode;
if (props.userLabel) {
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
}
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
@@ -31,8 +36,8 @@ export function MemberTileLayout(props: Props): JSX.Element {
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
</div>
<div className="mx_MemberTileView_right">
{props.userLabelJsx}
{props.e2eIconJsx}
{userLabelJsx}
{props.iconJsx}
</div>
</AccessibleButton>
</div>

View File

@@ -31,8 +31,7 @@ export function shouldShowQr(
): boolean {
const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"];
const deviceAuthorizationGrantSupported =
oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
const deviceAuthorizationGrantSupported = oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
return (
!!deviceAuthorizationGrantSupported &&

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react";
import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { defer } from "matrix-js-sdk/src/utils";
@@ -163,10 +163,7 @@ const SessionManagerTab: React.FC<{
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
const oidcClientConfig = useAsyncMemo(async () => {
try {
const authIssuer = await matrixClient?.getAuthIssuer();
if (authIssuer) {
return discoverAndValidateOIDCIssuerWellKnown(authIssuer.issuer);
}
return await matrixClient?.getAuthMetadata();
} catch (e) {
logger.error("Failed to discover OIDC metadata", e);
}

View File

@@ -18,7 +18,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { KnownMembership, Membership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
import { IWidgetApiRequest } from "matrix-widget-api";
@@ -743,7 +743,7 @@ export class ElementCall extends Call {
const url = ElementCall.generateWidgetUrl(client, roomId);
return WidgetStore.instance.addVirtualWidget(
{
id: randomString(24), // So that it's globally unique
id: secureRandomString(24), // So that it's globally unique
creatorUserId: client.getUserId()!,
name: "Element Call",
type: WidgetType.CALL.preferred,

View File

@@ -31,7 +31,7 @@ Please see LICENSE files in the repository root for full details.
// the frequency with which we flush to indexeddb
import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { getCircularReplacer } from "../utils/JSON";
@@ -135,7 +135,7 @@ export class IndexedDBLogStore {
private indexedDB: IDBFactory,
private logger: ConsoleLogger,
) {
this.id = "instance-" + randomString(16);
this.id = "instance-" + secureRandomString(16);
}
/**

View File

@@ -50,11 +50,8 @@ export class OidcClientStore {
} else {
// We are not in OIDC Native mode, as we have no locally stored issuer. Check if the server delegates auth to OIDC.
try {
const authIssuer = await this.matrixClient.getAuthIssuer();
const { accountManagementEndpoint, metadata } = await discoverAndValidateOIDCIssuerWellKnown(
authIssuer.issuer,
);
this.setAccountManagementEndpoint(accountManagementEndpoint, metadata.issuer);
const authMetadata = await this.matrixClient.getAuthMetadata();
this.setAccountManagementEndpoint(authMetadata.account_management_uri, authMetadata.issuer);
} catch (e) {
console.log("Auth issuer not found", e);
}
@@ -153,14 +150,11 @@ export class OidcClientStore {
try {
const clientId = getStoredOidcClientId();
const { accountManagementEndpoint, metadata, signingKeys } = await discoverAndValidateOIDCIssuerWellKnown(
this.authenticatedIssuer,
);
this.setAccountManagementEndpoint(accountManagementEndpoint, metadata.issuer);
const authMetadata = await discoverAndValidateOIDCIssuerWellKnown(this.authenticatedIssuer);
this.setAccountManagementEndpoint(authMetadata.account_management_uri, authMetadata.issuer);
this.oidcClient = new OidcClient({
...metadata,
authority: metadata.issuer,
signingKeys,
authority: authMetadata.issuer,
signingKeys: authMetadata.signingKeys ?? undefined,
redirect_uri: PlatformPeg.get()!.getOidcCallbackUrl().href,
client_id: clientId,
});

View File

@@ -6,7 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
import { Room, MatrixEvent, MatrixEventEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
import {
Room,
MatrixEvent,
MatrixEventEvent,
MatrixClient,
ClientEvent,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import {
ClientWidgetApi,
@@ -26,7 +33,6 @@ import {
WidgetApiFromWidgetAction,
WidgetKind,
} from "matrix-widget-api";
import { Optional } from "matrix-events-sdk";
import { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
@@ -56,6 +62,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import Modal from "../../Modal";
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
import { SdkContextClass } from "../../contexts/SDKContext";
import { UPDATE_EVENT } from "../AsyncStore";
// TODO: Destroy all of this code
@@ -151,6 +158,9 @@ export class StopGapWidget extends EventEmitter {
private mockWidget: ElementWidget;
private scalarToken?: string;
private roomId?: string;
// The room that we're currently allowing the widget to interact with. Only
// used for account widgets, which may follow the user to different rooms.
private viewedRoomId: string | null = null;
private kind: WidgetKind;
private readonly virtual: boolean;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
@@ -177,17 +187,6 @@ export class StopGapWidget extends EventEmitter {
this.stickyPromise = appTileProps.stickyPromise;
}
private get eventListenerRoomId(): Optional<string> {
// When widgets are listening to events, we need to make sure they're only
// receiving events for the right room. In particular, room widgets get locked
// to the room they were added in while account widgets listen to the currently
// active room.
if (this.roomId) return this.roomId;
return SdkContextClass.instance.roomViewStore.getRoomId();
}
public get widgetApi(): ClientWidgetApi | null {
return this.messaging;
}
@@ -259,6 +258,17 @@ export class StopGapWidget extends EventEmitter {
});
}
};
// This listener is only active for account widgets, which may follow the
// user to different rooms
private onRoomViewStoreUpdate = (): void => {
const roomId = SdkContextClass.instance.roomViewStore.getRoomId() ?? null;
if (roomId !== this.viewedRoomId) {
this.messaging!.setViewedRoomId(roomId);
this.viewedRoomId = roomId;
}
};
/**
* This starts the messaging for the widget if it is not in the state `started` yet.
* @param iframe the iframe the widget should use
@@ -285,6 +295,17 @@ export class StopGapWidget extends EventEmitter {
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
// When widgets are listening to events, we need to make sure they're only
// receiving events for the right room
if (this.roomId === undefined) {
// Account widgets listen to the currently active room
this.messaging.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null);
SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate);
} else {
// Room widgets get locked to the room they were added in
this.messaging.setViewedRoomId(this.roomId);
}
// Always attach a handler for ViewRoom, but permission check it internally
this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
ev.preventDefault(); // stop the widget API from auto-rejecting this
@@ -329,6 +350,7 @@ export class StopGapWidget extends EventEmitter {
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(RoomStateEvent.Events, this.onStateUpdate);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.messaging.on(
@@ -457,8 +479,11 @@ export class StopGapWidget extends EventEmitter {
WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId);
this.messaging = null;
SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.off(RoomStateEvent.Events, this.onStateUpdate);
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
@@ -471,6 +496,14 @@ export class StopGapWidget extends EventEmitter {
this.feedEvent(ev);
};
private onStateUpdate = (ev: MatrixEvent): void => {
if (this.messaging === null) return;
const raw = ev.getEffectiveEvent();
this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => {
logger.error("Error sending state update to widget: ", e);
});
};
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
@@ -570,7 +603,7 @@ export class StopGapWidget extends EventEmitter {
this.eventsToFeed.add(ev);
} else {
const raw = ev.getEffectiveEvent();
this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId!).catch((e) => {
this.messaging.feedEvent(raw as IRoomEvent).catch((e) => {
logger.error("Error sending event to widget: ", e);
});
}

View File

@@ -19,7 +19,6 @@ import {
MatrixCapabilities,
OpenIDRequestState,
SimpleObservable,
Symbols,
Widget,
WidgetDriver,
WidgetEventCapability,
@@ -36,7 +35,6 @@ import {
IContent,
MatrixError,
MatrixEvent,
Room,
Direction,
THREAD_RELATION_TYPE,
SendDelayedEventResponse,
@@ -469,70 +467,69 @@ export class StopGapWidgetDriver extends WidgetDriver {
}
}
private pickRooms(roomIds?: (string | Symbols.AnyRoom)[]): Room[] {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not attached to a client");
const targetRooms = roomIds
? roomIds.includes(Symbols.AnyRoom)
? client.getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors"))
: roomIds.map((r) => client.getRoom(r))
: [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()!)];
return targetRooms.filter((r) => !!r) as Room[];
}
public async readRoomEvents(
/**
* Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
* the user has access to. The widget API will have already verified that the widget is
* capable of receiving the events. Less events than the limit are allowed to be returned,
* but not more.
* @param roomId The ID of the room to look within.
* @param eventType The event type to be read.
* @param msgtype The msgtype of the events to be read, if applicable/defined.
* @param stateKey The state key of the events to be read, if applicable/defined.
* @param limit The maximum number of events to retrieve. Will be zero to denote "as many as
* possible".
* @param since When null, retrieves the number of events specified by the "limit" parameter.
* Otherwise, the event ID at which only subsequent events will be returned, as many as specified
* in "limit".
* @returns {Promise<IRoomEvent[]>} Resolves to the room events, or an empty array.
*/
public async readRoomTimeline(
roomId: string,
eventType: string,
msgtype: string | undefined,
limitPerRoom: number,
roomIds?: (string | Symbols.AnyRoom)[],
stateKey: string | undefined,
limit: number,
since: string | undefined,
): Promise<IRoomEvent[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const rooms = this.pickRooms(roomIds);
const allResults: IRoomEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i > 0; i--) {
if (results.length >= limitPerRoom) break;
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (room === null) return [];
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i];
if (results.length >= limit) break;
if (since !== undefined && ev.getId() === since) break;
const ev = events[i];
if (ev.getType() !== eventType || ev.isState()) continue;
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue;
results.push(ev);
}
results.forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent));
if (ev.getType() !== eventType || ev.isState()) continue;
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue;
if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey) continue;
results.push(ev);
}
return allResults;
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
}
public async readStateEvents(
eventType: string,
stateKey: string | undefined,
limitPerRoom: number,
roomIds?: (string | Symbols.AnyRoom)[],
): Promise<IRoomEvent[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
/**
* Reads the current values of all matching room state entries.
* @param roomId The ID of the room.
* @param eventType The event type of the entries to be read.
* @param stateKey The state key of the entry to be read. If undefined,
* all room state entries with a matching event type should be returned.
* @returns {Promise<IRoomEvent[]>} Resolves to the events representing the
* current values of the room state entries.
*/
public async readRoomState(roomId: string, eventType: string, stateKey: string | undefined): Promise<IRoomEvent[]> {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (room === null) return [];
const state = room.getLiveTimeline().getState(Direction.Forward);
if (state === undefined) return [];
const rooms = this.pickRooms(roomIds);
const allResults: IRoomEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const state = room.currentState.events.get(eventType);
if (state) {
if (stateKey === "" || !!stateKey) {
const forKey = state.get(stateKey);
if (forKey) results.push(forKey);
} else {
results.push(...Array.from(state.values()));
}
}
results.slice(0, limitPerRoom).forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent));
}
return allResults;
if (stateKey === undefined)
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
const event = state.getStateEvents(eventType, stateKey);
return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent];
}
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
@@ -693,6 +690,17 @@ export class StopGapWidgetDriver extends WidgetDriver {
return { file: blob };
}
/**
* Gets the IDs of all joined or invited rooms currently known to the
* client.
* @returns The room IDs.
*/
public getKnownRooms(): string[] {
return MatrixClientPeg.safeGet()
.getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors"))
.map((r) => r.roomId);
}
/**
* Expresses a {@link MatrixError} as a JSON payload
* for use by Widget API error responses.

View File

@@ -11,7 +11,6 @@ import {
AutoDiscovery,
AutoDiscoveryError,
ClientConfig,
discoverAndValidateOIDCIssuerWellKnown,
IClientWellKnown,
MatrixClient,
MatrixError,
@@ -293,8 +292,7 @@ export default class AutoDiscoveryUtils {
let delegatedAuthenticationError: Error | undefined;
try {
const tempClient = new MatrixClient({ baseUrl: preferredHomeserverUrl });
const { issuer } = await tempClient.getAuthIssuer();
delegatedAuthentication = await discoverAndValidateOIDCIssuerWellKnown(issuer);
delegatedAuthentication = await tempClient.getAuthMetadata();
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404 && e.errcode === "M_UNRECOGNIZED") {
// 404 M_UNRECOGNIZED means the server does not support OIDC

View File

@@ -9,12 +9,13 @@ Please see LICENSE files in the repository root for full details.
import { useCallback, useEffect, useState } from "react";
import { base32 } from "rfc4648";
import { capitalize } from "lodash";
import { IWidget, IWidgetData } from "matrix-widget-api";
import { Room, ClientEvent, MatrixClient, RoomStateEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { randomString, randomLowercaseString, randomUppercaseString } from "matrix-js-sdk/src/randomstring";
import { LOWERCASE, secureRandomString, secureRandomStringFrom } from "matrix-js-sdk/src/randomstring";
import PlatformPeg from "../PlatformPeg";
import SdkConfig from "../SdkConfig";
@@ -427,7 +428,10 @@ export default class WidgetUtils {
): Promise<void> {
const domain = Jitsi.getInstance().preferredDomain;
const auth = (await Jitsi.getInstance().getJitsiAuth()) ?? undefined;
const widgetId = randomString(24); // Must be globally unique
// Must be globally unique, although predicatablity is not important, the js-sdk has functions to generate
// secure ranom strings, and speed is not important here.
const widgetId = secureRandomString(24);
let confId: string;
if (auth === "openidtoken-jwt") {
@@ -437,8 +441,8 @@ export default class WidgetUtils {
// https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
confId = base32.stringify(new TextEncoder().encode(roomId), { pad: false });
} else {
// Create a random conference ID
confId = `Jitsi${randomUppercaseString(1)}${randomLowercaseString(23)}`;
// Create a random conference ID (capitalised so the name looks sensible in Jitsi)
confId = `Jitsi${capitalize(secureRandomStringFrom(24, LOWERCASE))}`;
}
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets

View File

@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { completeAuthorizationCodeGrant, generateOidcAuthorizationUrl } from "matrix-js-sdk/src/oidc/authorize";
import { QueryDict } from "matrix-js-sdk/src/utils";
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { IdTokenClaims } from "oidc-client-ts";
import { OidcClientError } from "./error";
@@ -34,12 +34,12 @@ export const startOidcLogin = async (
): Promise<void> => {
const redirectUri = PlatformPeg.get()!.getOidcCallbackUrl().href;
const nonce = randomString(10);
const nonce = secureRandomString(10);
const prompt = isRegistration ? "create" : undefined;
const authorizationUrl = await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig.metadata,
metadata: delegatedAuthConfig,
redirectUri,
clientId,
homeserverUrl,

View File

@@ -15,8 +15,6 @@ import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
* @returns whether user registration is supported
*/
export const isUserRegistrationSupported = (delegatedAuthConfig: OidcClientConfig): boolean => {
// The OidcMetadata type from oidc-client-ts does not include `prompt_values_supported`
// even though it is part of the OIDC spec, so cheat TS here to access it
const supportedPrompts = (delegatedAuthConfig.metadata as Record<string, unknown>)["prompt_values_supported"];
const supportedPrompts = delegatedAuthConfig.prompt_values_supported;
return Array.isArray(supportedPrompts) && supportedPrompts?.includes("create");
};

View File

@@ -40,9 +40,9 @@ export const getOidcClientId = async (
delegatedAuthConfig: OidcClientConfig,
staticOidcClients?: IConfigOptions["oidc_static_clients"],
): Promise<string> => {
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.metadata.issuer, staticOidcClients);
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients);
if (staticClientId) {
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.metadata.issuer}`);
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
return staticClientId;
}
return await registerOidcClient(delegatedAuthConfig, await PlatformPeg.get()!.getOidcClientMetadata());

View File

@@ -12,7 +12,7 @@ Please see LICENSE files in the repository root for full details.
import { MatrixClient, Room, MatrixEvent, OidcRegistrationClientMetadata } from "matrix-js-sdk/src/matrix";
import React from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { logger } from "matrix-js-sdk/src/logger";
import BasePlatform, { UpdateCheckStatus, UpdateStatus } from "../../BasePlatform";
@@ -93,7 +93,7 @@ export default class ElectronPlatform extends BasePlatform {
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
// this is the opaque token we pass to the HS which when we get it in our callback we can resolve to a profile
private readonly ssoID: string = randomString(32);
private readonly ssoID: string = secureRandomString(32);
public constructor() {
super();

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import "@testing-library/jest-dom";
import "blob-polyfill";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { mocked } from "jest-mock";
import { PredictableRandom } from "./test-utils/predictableRandom"; // https://github.com/jsdom/jsdom/issues/2555
@@ -25,7 +25,8 @@ jest.mock("matrix-js-sdk/src/randomstring");
beforeEach(() => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const mockRandom = new PredictableRandom();
mocked(randomString).mockImplementation((len) => {
// needless to say, the mock is not cryptographically secure
mocked(secureRandomString).mockImplementation((len) => {
let ret = "";
for (let i = 0; i < len; ++i) {
const v = mockRandom.get() * chars.length;

View File

@@ -6,41 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { ValidatedIssuerMetadata } from "matrix-js-sdk/src/oidc/validate";
/**
* Makes a valid OidcClientConfig with minimum valid values
* @param issuer used as the base for all other urls
* @returns OidcClientConfig
*/
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
const metadata = mockOpenIdConfiguration(issuer);
return {
accountManagementEndpoint: issuer + "account",
registrationEndpoint: metadata.registration_endpoint,
authorizationEndpoint: metadata.authorization_endpoint,
tokenEndpoint: metadata.token_endpoint,
metadata,
};
};
/**
* Useful for mocking <issuer>/.well-known/openid-configuration
* @param issuer used as the base for all other urls
* @returns ValidatedIssuerMetadata
*/
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
issuer,
revocation_endpoint: issuer + "revoke",
token_endpoint: issuer + "token",
authorization_endpoint: issuer + "auth",
registration_endpoint: issuer + "registration",
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
account_management_uri: issuer + "account",
});
export { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "matrix-js-sdk/src/testing";

View File

@@ -18,7 +18,7 @@ import {
M_POLL_RESPONSE,
M_TEXT,
} from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { flushPromises } from "./utilities";
@@ -67,7 +67,7 @@ export const makePollEndEvent = (
id?: string,
): MatrixEvent => {
return new MatrixEvent({
event_id: id || randomString(16),
event_id: id || secureRandomString(16),
room_id: roomId,
origin_server_ts: ts,
type: M_POLL_END.name,
@@ -91,7 +91,7 @@ export const makePollResponseEvent = (
ts = 0,
): MatrixEvent =>
new MatrixEvent({
event_id: randomString(16),
event_id: secureRandomString(16),
room_id: roomId,
origin_server_ts: ts,
type: M_POLL_RESPONSE.name,

View File

@@ -749,11 +749,8 @@ describe("Lifecycle", () => {
"eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg";
beforeAll(() => {
fetchMock.get(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
delegatedAuthConfig.metadata,
);
fetchMock.get(`${delegatedAuthConfig.metadata.issuer}jwks`, {
fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig);
fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
@@ -772,9 +769,7 @@ describe("Lifecycle", () => {
await setLoggedIn(credentials);
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
});
it("should not try to create a token refresher without a deviceId", async () => {
@@ -785,9 +780,7 @@ describe("Lifecycle", () => {
});
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
});
it("should not try to create a token refresher without an issuer in session storage", async () => {
@@ -803,9 +796,7 @@ describe("Lifecycle", () => {
});
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
});
it("should create a client with a tokenRefreshFunction", async () => {

View File

@@ -384,7 +384,7 @@ describe("Login", function () {
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registration_endpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
@@ -394,25 +394,25 @@ describe("Login", function () {
it("should attempt to register oidc client", async () => {
// dont mock, spy so we can check config values were correctly passed
jest.spyOn(registerClientUtils, "getOidcClientId");
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
fetchMock.post(delegatedAuth.registration_endpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registration_endpoint, expect.any(Object));
// called with values from config
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(delegatedAuth, oidcStaticClientsConfig);
});
it("should fallback to normal login when client registration fails", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
fetchMock.post(delegatedAuth.registration_endpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registration_endpoint, expect.any(Object));
expect(logger.error).toHaveBeenCalledWith(new Error(OidcError.DynamicRegistrationFailed));
// continued with normal setup
@@ -423,7 +423,7 @@ describe("Login", function () {
// short term during active development, UI will be added in next PRs
it("should show continue button when oidc native flow is correctly configured", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { client_id: "abc123" });
fetchMock.post(delegatedAuth.registration_endpoint!, { client_id: "abc123" });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
@@ -455,7 +455,7 @@ describe("Login", function () {
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registration_endpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// oidc-aware 'continue' button displayed

View File

@@ -158,24 +158,26 @@ describe("Registration", function () {
describe("when delegated authentication is configured and enabled", () => {
const authConfig = makeDelegatedAuthConfig();
const clientId = "test-client-id";
// @ts-ignore
authConfig.metadata["prompt_values_supported"] = ["create"];
authConfig.prompt_values_supported = ["create"];
beforeEach(() => {
// mock a statically registered client to avoid dynamic registration
SdkConfig.put({
oidc_static_clients: {
[authConfig.metadata.issuer]: {
[authConfig.issuer]: {
client_id: clientId,
},
},
});
fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, {
issuer: authConfig.metadata.issuer,
issuer: authConfig.issuer,
});
fetchMock.get("https://auth.org/.well-known/openid-configuration", authConfig.metadata);
fetchMock.get(authConfig.metadata.jwks_uri!, { keys: [] });
fetchMock.get("https://auth.org/.well-known/openid-configuration", {
...authConfig,
signingKeys: undefined,
});
fetchMock.get(authConfig.jwks_uri!, { keys: [] });
});
it("should display oidc-native continue button", async () => {

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClient, MatrixEvent, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import { render, waitFor } from "jest-matrix-react";
@@ -228,6 +228,23 @@ describe("<TextualBody />", () => {
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot();
});
it("should pillify a keyword responsible for triggering a notification", () => {
const ev = mkRoomTextMessage("foo bar baz");
ev.setPushDetails(undefined, {
actions: [],
pattern: "bar",
rule_id: "bar",
default: false,
enabled: true,
kind: PushRuleKind.ContentSpecific,
});
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"<span>foo <bdi><span tabindex="0"><span class="mx_Pill mx_KeywordPill"><span class="mx_Pill_text">bar</span></span></span></bdi> baz</span>"`,
);
});
});
describe("renders formatted m.text correctly", () => {

View File

@@ -224,7 +224,29 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
</div>
<div
class="mx_MemberTileView_right"
/>
>
<div
class="mx_MemberTileView_userLabel"
>
Invited
</div>
<div
class="mx_Flex mx_InvitedIconView"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
>
<svg
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm0 5.111a1 1 0 0 0 .514.874l7 3.89a1 1 0 0 0 .972 0l7-3.89a1 1 0 1 0-.972-1.748L12 11.856 5.486 8.237A1 1 0 0 0 4 9.111Z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>

View File

@@ -23,7 +23,7 @@ import {
IThreepid,
ThreepidMedium,
} from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import {
act,
fireEvent,
@@ -287,7 +287,7 @@ describe("<Notifications />", () => {
beforeEach(async () => {
let i = 0;
mocked(randomString).mockImplementation(() => {
mocked(secureRandomString).mockImplementation(() => {
return "testid_" + i++;
});

View File

@@ -57,7 +57,7 @@ import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { getClientInformationEventType } from "../../../../../../../src/utils/device/clientInformation";
import { SDKContext, SdkContextClass } from "../../../../../../../src/contexts/SDKContext";
import { OidcClientStore } from "../../../../../../../src/stores/oidc/OidcClientStore";
import { mockOpenIdConfiguration } from "../../../../../../test-utils/oidc";
import { makeDelegatedAuthConfig } from "../../../../../../test-utils/oidc";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
mockPlatformPeg();
@@ -215,7 +215,7 @@ describe("<SessionManagerTab />", () => {
getPushers: jest.fn(),
setPusher: jest.fn(),
setLocalNotificationSettings: jest.fn(),
getAuthIssuer: jest.fn().mockReturnValue(new Promise(() => {})),
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
});
jest.clearAllMocks();
jest.spyOn(logger, "error").mockRestore();
@@ -1615,7 +1615,6 @@ describe("<SessionManagerTab />", () => {
describe("MSC4108 QR code login", () => {
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
const issuer = "https://issuer.org";
const openIdConfiguration = mockOpenIdConfiguration(issuer);
beforeEach(() => {
settingsValueSpy.mockClear().mockReturnValue(true);
@@ -1631,16 +1630,16 @@ describe("<SessionManagerTab />", () => {
enabled: true,
},
});
mockClient.getAuthIssuer.mockResolvedValue({ issuer });
mockCrypto.exportSecretsBundle = jest.fn();
fetchMock.mock(`${issuer}/.well-known/openid-configuration`, {
...openIdConfiguration,
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
mockClient.getAuthMetadata.mockResolvedValue({
...delegatedAuthConfig,
grant_types_supported: [
...openIdConfiguration.grant_types_supported,
...delegatedAuthConfig.grant_types_supported,
"urn:ietf:params:oauth:grant-type:device_code",
],
});
fetchMock.mock(openIdConfiguration.jwks_uri!, {
mockCrypto.exportSecretsBundle = jest.fn();
fetchMock.mock(delegatedAuthConfig.jwks_uri!, {
status: 200,
headers: {
"Content-Type": "application/json",

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { mocked } from "jest-mock";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { act, fireEvent, render, RenderResult } from "jest-matrix-react";
import { EventType, MatrixClient, Room, GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/matrix";
@@ -92,7 +92,7 @@ describe("<SpaceSettingsVisibilityTab />", () => {
beforeEach(() => {
let i = 0;
mocked(randomString).mockImplementation(() => {
mocked(secureRandomString).mockImplementation(() => {
return "testid_" + i++;
});

View File

@@ -15,7 +15,7 @@ import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OidcClientStore } from "../../../../src/stores/oidc/OidcClientStore";
import { flushPromises, getMockClientWithEventEmitter, mockPlatformPeg } from "../../../test-utils";
import { mockOpenIdConfiguration } from "../../../test-utils/oidc";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
@@ -24,28 +24,30 @@ jest.mock("matrix-js-sdk/src/matrix", () => ({
describe("OidcClientStore", () => {
const clientId = "test-client-id";
const metadata = mockOpenIdConfiguration();
const account = metadata.issuer + "account";
const authConfig = makeDelegatedAuthConfig();
const account = authConfig.issuer + "account";
const mockClient = getMockClientWithEventEmitter({
getAuthIssuer: jest.fn(),
getAuthMetadata: jest.fn(),
});
beforeEach(() => {
localStorage.clear();
localStorage.setItem("mx_oidc_client_id", clientId);
localStorage.setItem("mx_oidc_token_issuer", metadata.issuer);
localStorage.setItem("mx_oidc_token_issuer", authConfig.issuer);
mocked(discoverAndValidateOIDCIssuerWellKnown).mockClear().mockResolvedValue({
metadata,
accountManagementEndpoint: account,
authorizationEndpoint: "authorization-endpoint",
tokenEndpoint: "token-endpoint",
});
mocked(discoverAndValidateOIDCIssuerWellKnown)
.mockClear()
.mockResolvedValue({
...authConfig,
account_management_uri: account,
authorization_endpoint: "authorization-endpoint",
token_endpoint: "token-endpoint",
});
jest.spyOn(logger, "error").mockClear();
fetchMock.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
fetchMock.get(`${metadata.issuer}jwks`, { keys: [] });
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(`${authConfig.issuer}jwks`, { keys: [] });
mockPlatformPeg();
});
@@ -116,7 +118,7 @@ describe("OidcClientStore", () => {
const client = await store.getOidcClient();
expect(client?.settings.client_id).toEqual(clientId);
expect(client?.settings.authority).toEqual(metadata.issuer);
expect(client?.settings.authority).toEqual(authConfig.issuer);
});
it("should set account management endpoint when configured", async () => {
@@ -129,17 +131,19 @@ describe("OidcClientStore", () => {
});
it("should set account management endpoint to issuer when not configured", async () => {
mocked(discoverAndValidateOIDCIssuerWellKnown).mockClear().mockResolvedValue({
metadata,
accountManagementEndpoint: undefined,
authorizationEndpoint: "authorization-endpoint",
tokenEndpoint: "token-endpoint",
});
mocked(discoverAndValidateOIDCIssuerWellKnown)
.mockClear()
.mockResolvedValue({
...authConfig,
account_management_uri: undefined,
authorization_endpoint: "authorization-endpoint",
token_endpoint: "token-endpoint",
});
const store = new OidcClientStore(mockClient);
await store.readyPromise;
expect(store.accountManagementEndpoint).toEqual(metadata.issuer);
expect(store.accountManagementEndpoint).toEqual(authConfig.issuer);
});
it("should reuse initialised oidc client", async () => {
@@ -175,7 +179,7 @@ describe("OidcClientStore", () => {
fetchMock.resetHistory();
fetchMock.post(
metadata.revocation_endpoint,
authConfig.revocation_endpoint,
{
status: 200,
},
@@ -197,7 +201,7 @@ describe("OidcClientStore", () => {
await store.revokeTokens(accessToken, refreshToken);
expect(fetchMock).toHaveFetchedTimes(2, metadata.revocation_endpoint);
expect(fetchMock).toHaveFetchedTimes(2, authConfig.revocation_endpoint);
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(refreshToken, "refresh_token");
});
@@ -206,14 +210,14 @@ describe("OidcClientStore", () => {
// fail once, then succeed
fetchMock
.postOnce(
metadata.revocation_endpoint,
authConfig.revocation_endpoint,
{
status: 404,
},
{ overwriteRoutes: true, sendAsJson: true },
)
.post(
metadata.revocation_endpoint,
authConfig.revocation_endpoint,
{
status: 200,
},
@@ -226,7 +230,7 @@ describe("OidcClientStore", () => {
"Failed to revoke tokens",
);
expect(fetchMock).toHaveFetchedTimes(2, metadata.revocation_endpoint);
expect(fetchMock).toHaveFetchedTimes(2, authConfig.revocation_endpoint);
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
});
});
@@ -237,7 +241,10 @@ describe("OidcClientStore", () => {
});
it("should resolve account management endpoint", async () => {
mockClient.getAuthIssuer.mockResolvedValue({ issuer: metadata.issuer });
mockClient.getAuthMetadata.mockResolvedValue({
...authConfig,
account_management_uri: account,
});
const store = new OidcClientStore(mockClient);
await store.readyPromise;
expect(store.accountManagementEndpoint).toBe(account);

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { mocked, MockedObject } from "jest-mock";
import { mocked, MockedFunction, MockedObject } from "jest-mock";
import { last } from "lodash";
import {
MatrixEvent,
@@ -15,15 +15,20 @@ import {
EventTimeline,
EventType,
MatrixEventEvent,
RoomStateEvent,
RoomState,
} from "matrix-js-sdk/src/matrix";
import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api";
import { waitFor } from "jest-matrix-react";
import { Optional } from "matrix-events-sdk";
import { stubClient, mkRoom, mkEvent } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget";
import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
@@ -53,6 +58,7 @@ describe("StopGapWidget", () => {
// Start messaging without an iframe, since ClientWidgetApi is mocked
widget.startMessaging(null as unknown as HTMLIFrameElement);
messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!);
messaging.feedStateUpdate.mockResolvedValue();
});
afterEach(() => {
@@ -84,6 +90,20 @@ describe("StopGapWidget", () => {
expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false);
});
it("feeds incoming state updates to the widget", () => {
const event = mkEvent({
event: true,
type: "org.example.foo",
skey: "",
user: "@alice:example.org",
content: { hello: "world" },
room: "!1:example.org",
});
client.emit(RoomStateEvent.Events, event, {} as unknown as RoomState, null);
expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent());
});
describe("feed event", () => {
let event1: MatrixEvent;
let event2: MatrixEvent;
@@ -118,24 +138,24 @@ describe("StopGapWidget", () => {
it("feeds incoming event to the widget", async () => {
client.emit(ClientEvent.Event, event1);
expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent());
client.emit(ClientEvent.Event, event2);
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent());
});
it("should not feed incoming event to the widget if seen already", async () => {
client.emit(ClientEvent.Event, event1);
expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent());
client.emit(ClientEvent.Event, event2);
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent());
client.emit(ClientEvent.Event, event1);
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent());
});
it("feeds decrypted events asynchronously", async () => {
@@ -165,7 +185,7 @@ describe("StopGapWidget", () => {
decryptingSpy2.mockReturnValue(false);
client.emit(MatrixEventEvent.Decrypted, event2Encrypted);
expect(messaging.feedEvent).toHaveBeenCalledTimes(1);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent());
// …then event 1
event1Encrypted.event.type = event1.getType();
event1Encrypted.event.content = event1.getContent();
@@ -175,7 +195,7 @@ describe("StopGapWidget", () => {
// doesn't have to be blocked on the decryption of event 1 (or
// worse, dropped)
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent());
});
it("should not feed incoming event if not in timeline", () => {
@@ -191,7 +211,7 @@ describe("StopGapWidget", () => {
});
client.emit(ClientEvent.Event, event);
expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent());
});
it("feeds incoming event that is not in timeline but relates to unknown parent to the widget", async () => {
@@ -211,18 +231,19 @@ describe("StopGapWidget", () => {
});
client.emit(ClientEvent.Event, event1);
expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent());
client.emit(ClientEvent.Event, event);
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent());
client.emit(ClientEvent.Event, event1);
expect(messaging.feedEvent).toHaveBeenCalledTimes(2);
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org");
expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent());
});
});
});
describe("StopGapWidget with stickyPromise", () => {
let client: MockedObject<MatrixClient>;
let widget: StopGapWidget;
@@ -288,3 +309,49 @@ describe("StopGapWidget with stickyPromise", () => {
waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 });
});
});
describe("StopGapWidget as an account widget", () => {
let widget: StopGapWidget;
let messaging: MockedObject<ClientWidgetApi>;
let getRoomId: MockedFunction<() => Optional<string>>;
beforeEach(() => {
stubClient();
// I give up, getting the return type of spyOn right is hopeless
getRoomId = jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") as unknown as MockedFunction<
() => Optional<string>
>;
getRoomId.mockReturnValue("!1:example.org");
widget = new StopGapWidget({
app: {
id: "test",
creatorUserId: "@alice:example.org",
type: "example",
url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme",
roomId: "!1:example.org",
},
userId: "@alice:example.org",
creatorUserId: "@alice:example.org",
waitForIframeLoad: true,
userWidget: false,
});
// Start messaging without an iframe, since ClientWidgetApi is mocked
widget.startMessaging(null as unknown as HTMLIFrameElement);
messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!);
});
afterEach(() => {
widget.stopMessaging();
getRoomId.mockRestore();
});
it("updates viewed room", () => {
expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(1);
expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!1:example.org");
getRoomId.mockReturnValue("!2:example.org");
SdkContextClass.instance.roomViewStore.emit(UPDATE_EVENT);
expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(2);
expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!2:example.org");
});
});

View File

@@ -17,6 +17,7 @@ import {
MatrixEvent,
MsgType,
RelationType,
Room,
} from "matrix-js-sdk/src/matrix";
import {
Widget,
@@ -38,7 +39,7 @@ import {
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver";
import { stubClient } from "../../../test-utils";
import { mkEvent, stubClient } from "../../../test-utils";
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";
import dis from "../../../../src/dispatcher/dispatcher";
import Modal from "../../../../src/Modal";
@@ -569,7 +570,7 @@ describe("StopGapWidgetDriver", () => {
it("passes the flag through to getVisibleRooms", () => {
const driver = mkDefaultDriver();
driver.readRoomEvents(EventType.CallAnswer, "", 0, ["*"]);
driver.getKnownRooms();
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
@@ -584,7 +585,7 @@ describe("StopGapWidgetDriver", () => {
it("passes the flag through to getVisibleRooms", () => {
const driver = mkDefaultDriver();
driver.readRoomEvents(EventType.CallAnswer, "", 0, ["*"]);
driver.getKnownRooms();
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
@@ -692,4 +693,107 @@ describe("StopGapWidgetDriver", () => {
await expect(file.text()).resolves.toEqual("test contents");
});
});
describe("readRoomTimeline", () => {
const event1 = mkEvent({
event: true,
id: "$event-id1",
type: "org.example.foo",
user: "@alice:example.org",
content: { hello: "world" },
room: "!1:example.org",
});
const event2 = mkEvent({
event: true,
id: "$event-id2",
type: "org.example.foo",
user: "@alice:example.org",
content: { hello: "world" },
room: "!1:example.org",
});
let driver: WidgetDriver;
beforeEach(() => {
driver = mkDefaultDriver();
client.getRoom.mockReturnValue({
getLiveTimeline: () => ({ getEvents: () => [event1, event2] }),
} as unknown as Room);
});
it("reads all events", async () => {
expect(
await driver.readRoomTimeline("!1:example.org", "org.example.foo", undefined, undefined, 10, undefined),
).toEqual([event2, event1].map((e) => e.getEffectiveEvent()));
});
it("reads up to a limit", async () => {
expect(
await driver.readRoomTimeline("!1:example.org", "org.example.foo", undefined, undefined, 1, undefined),
).toEqual([event2.getEffectiveEvent()]);
});
it("reads up to a specific event", async () => {
expect(
await driver.readRoomTimeline(
"!1:example.org",
"org.example.foo",
undefined,
undefined,
10,
event1.getId(),
),
).toEqual([event2.getEffectiveEvent()]);
});
});
describe("readRoomState", () => {
const event1 = mkEvent({
event: true,
id: "$event-id1",
type: "org.example.foo",
user: "@alice:example.org",
content: { hello: "world" },
skey: "1",
room: "!1:example.org",
});
const event2 = mkEvent({
event: true,
id: "$event-id2",
type: "org.example.foo",
user: "@alice:example.org",
content: { hello: "world" },
skey: "2",
room: "!1:example.org",
});
let driver: WidgetDriver;
let getStateEvents: jest.Mock;
beforeEach(() => {
driver = mkDefaultDriver();
getStateEvents = jest.fn();
client.getRoom.mockReturnValue({
getLiveTimeline: () => ({ getState: () => ({ getStateEvents }) }),
} as unknown as Room);
});
it("reads a specific state key", async () => {
getStateEvents.mockImplementation((eventType, stateKey) => {
if (eventType === "org.example.foo" && stateKey === "1") return event1;
return undefined;
});
expect(await driver.readRoomState("!1:example.org", "org.example.foo", "1")).toEqual([
event1.getEffectiveEvent(),
]);
});
it("reads all state keys", async () => {
getStateEvents.mockImplementation((eventType, stateKey) => {
if (eventType === "org.example.foo" && stateKey === undefined) return [event1, event2];
return [];
});
expect(await driver.readRoomState("!1:example.org", "org.example.foo", undefined)).toEqual(
[event1, event2].map((e) => e.getEffectiveEvent()),
);
});
});
});

View File

@@ -355,21 +355,19 @@ describe("AutoDiscoveryUtils", () => {
hsNameIsDifferent: true,
hsName: serverName,
delegatedAuthentication: expect.objectContaining({
accountManagementActionsSupported: [
issuer,
account_management_actions_supported: [
"org.matrix.profile",
"org.matrix.sessions_list",
"org.matrix.session_view",
"org.matrix.session_end",
"org.matrix.cross_signing_reset",
],
accountManagementEndpoint: "https://auth.matrix.org/account/",
authorizationEndpoint: "https://auth.matrix.org/auth",
metadata: expect.objectContaining({
issuer,
}),
registrationEndpoint: "https://auth.matrix.org/registration",
account_management_uri: "https://auth.matrix.org/account/",
authorization_endpoint: "https://auth.matrix.org/auth",
registration_endpoint: "https://auth.matrix.org/registration",
signingKeys: [],
tokenEndpoint: "https://auth.matrix.org/token",
token_endpoint: "https://auth.matrix.org/token",
}),
warning: null,
});

View File

@@ -38,7 +38,7 @@ describe("TokenRefresher", () => {
};
beforeEach(() => {
fetchMock.get(`${issuer}.well-known/openid-configuration`, authConfig.metadata);
fetchMock.get(`${issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(`${issuer}jwks`, {
status: 200,
headers: {

View File

@@ -49,7 +49,7 @@ describe("OIDC authorization", () => {
origin: baseUrl,
};
jest.spyOn(randomStringUtils, "randomString").mockRestore();
jest.spyOn(randomStringUtils, "secureRandomString").mockRestore();
mockPlatformPeg();
Object.defineProperty(window, "crypto", {
value: {
@@ -61,10 +61,7 @@ describe("OIDC authorization", () => {
});
beforeAll(() => {
fetchMock.get(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
delegatedAuthConfig.metadata,
);
fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig);
});
afterAll(() => {

View File

@@ -58,7 +58,7 @@ describe("getOidcClientId()", () => {
const authConfigWithoutRegistration: OidcClientConfig = makeDelegatedAuthConfig(
"https://issuerWithoutStaticClientId.org/",
);
authConfigWithoutRegistration.registrationEndpoint = undefined;
authConfigWithoutRegistration.registration_endpoint = undefined;
await expect(getOidcClientId(authConfigWithoutRegistration, staticOidcClients)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
);
@@ -69,7 +69,7 @@ describe("getOidcClientId()", () => {
it("should handle when staticOidcClients object is falsy", async () => {
const authConfigWithoutRegistration: OidcClientConfig = {
...delegatedAuthConfig,
registrationEndpoint: undefined,
registration_endpoint: undefined,
};
await expect(getOidcClientId(authConfigWithoutRegistration)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
@@ -79,14 +79,14 @@ describe("getOidcClientId()", () => {
});
it("should make correct request to register client", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(await getOidcClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
// didn't try to register
expect(fetchMockJest).toHaveBeenCalledWith(
delegatedAuthConfig.registrationEndpoint!,
delegatedAuthConfig.registration_endpoint!,
expect.objectContaining({
headers: {
"Accept": "application/json",
@@ -111,14 +111,14 @@ describe("getOidcClientId()", () => {
});
it("should throw when registration request fails", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
status: 500,
});
await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationFailed);
});
it("should throw when registration response is invalid", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
fetchMockJest.post(delegatedAuthConfig.registration_endpoint!, {
status: 200,
// no clientId in response
body: "{}",

1960
yarn.lock

File diff suppressed because it is too large Load Diff