diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts
index 501d8a3bd6..4dc537aab0 100644
--- a/src/IConfigOptions.ts
+++ b/src/IConfigOptions.ts
@@ -119,6 +119,7 @@ export interface IConfigOptions {
};
element_call: {
url?: string;
+ guest_spa_url?: string;
use_exclusively?: boolean;
participant_limit?: number;
brand?: string;
diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx
index aba35e70e2..9e6ee7fc8a 100644
--- a/src/components/views/dialogs/ShareDialog.tsx
+++ b/src/components/views/dialogs/ShareDialog.tsx
@@ -62,11 +62,28 @@ const socials = [
];
interface BaseProps {
+ /**
+ * A function that is called when the dialog is dismissed
+ */
onFinished(): void;
+ /**
+ * An optional string to use as the dialog title.
+ * If not provided, an appropriate title for the target type will be used.
+ */
+ customTitle?: string;
+ /**
+ * An optional string to use as the dialog subtitle
+ */
+ subtitle?: string;
}
interface Props extends BaseProps {
- target: Room | User | RoomMember;
+ /**
+ * The target to link to.
+ * This can be a Room, User, RoomMember, or MatrixEvent or an already computed URL.
+ * A matrix.to link will be generated out of it if it's not already a url.
+ */
+ target: Room | User | RoomMember | URL;
permalinkCreator?: RoomPermalinkCreator;
}
@@ -109,7 +126,9 @@ export default class ShareDialog extends React.PureComponent 0) {
@@ -146,9 +167,9 @@ export default class ShareDialog extends React.PureComponent
+ {this.props.subtitle && {this.props.subtitle}
}
matrixToUrl}>
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index 6d1ea9f8eb..8ca26ebcde 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -18,6 +18,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg";
+import { Icon as ExternalLinkIcon } from "@vector-im/compound-design-tokens/icons/link.svg";
import { Icon as CloseCallIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg";
import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.svg";
@@ -26,6 +27,7 @@ import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error
import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.svg";
import { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
+import { logger } from "matrix-js-sdk/src/logger";
import { useRoomName } from "../../../hooks/useRoomName";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
@@ -54,6 +56,8 @@ import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications";
+import Modal from "../../../Modal";
+import ShareDialog from "../dialogs/ShareDialog";
export default function RoomHeader({
room,
@@ -78,6 +82,8 @@ export default function RoomHeader({
videoCallClick,
toggleCallMaximized: toggleCall,
isViewingCall,
+ generateCallLink,
+ canGenerateCallLink,
isConnectedToCall,
hasActiveCallSession,
callOptions,
@@ -118,6 +124,20 @@ export default function RoomHeader({
const videoClick = useCallback((ev) => videoCallClick(ev, callOptions[0]), [callOptions, videoCallClick]);
+ const shareClick = useCallback(() => {
+ try {
+ // generateCallLink throws if the permissions are not met
+ const target = generateCallLink();
+ Modal.createDialog(ShareDialog, {
+ target,
+ customTitle: _t("share|share_call"),
+ subtitle: _t("share|share_call_subtitle"),
+ });
+ } catch (e) {
+ logger.error("Could not generate call link.", e);
+ }
+ }, [generateCallLink]);
+
const toggleCallButton = (
@@ -125,7 +145,13 @@ export default function RoomHeader({
);
-
+ const createExternalLinkButton = (
+
+
+
+
+
+ );
const joinCallButton = (
);
})}
-
+ {isViewingCall && canGenerateCallLink && createExternalLinkButton}
{((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && }
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts
index 8d9045825c..92f350087f 100644
--- a/src/hooks/room/useRoomCall.ts
+++ b/src/hooks/room/useRoomCall.ts
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Room } from "matrix-js-sdk/src/matrix";
+import { JoinRule, Room } from "matrix-js-sdk/src/matrix";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
+import { logger } from "matrix-js-sdk/src/logger";
import { useFeatureEnabled } from "../useSettings";
import SdkConfig from "../../SdkConfig";
@@ -39,6 +40,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../dispatcher/actions";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
+import { calculateRoomVia } from "../../utils/permalinks/Permalinks";
export enum PlatformCallType {
ElementCall,
@@ -78,27 +80,35 @@ export const useRoomCall = (
videoCallClick(evt: React.MouseEvent | undefined, selectedType: PlatformCallType): void;
toggleCallMaximized: () => void;
isViewingCall: boolean;
+ generateCallLink: () => URL;
+ canGenerateCallLink: boolean;
isConnectedToCall: boolean;
hasActiveCallSession: boolean;
callOptions: PlatformCallType[];
} => {
+ // settings
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively;
}, []);
+ const guestSpaUrl = useMemo(() => {
+ return SdkConfig.get("element_call").guest_spa_url;
+ }, []);
+
const hasLegacyCall = useEventEmitterState(
LegacyCallHandler.instance,
LegacyCallHandlerEvent.CallsChanged,
() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null,
);
-
+ // settings
const widgets = useWidgets(room);
const jitsiWidget = useMemo(() => widgets.find((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
const hasJitsiWidget = !!jitsiWidget;
const managedHybridWidget = useMemo(() => widgets.find(isManagedHybridWidget), [widgets]);
const hasManagedHybridWidget = !!managedHybridWidget;
+ // group call
const groupCall = useCall(room.roomId);
const isConnectedToCall = useConnectionState(groupCall) === ConnectionState.Connected;
const hasGroupCall = groupCall !== null;
@@ -107,11 +117,14 @@ export const useRoomCall = (
SdkContextClass.instance.roomViewStore.isViewingCall(),
);
+ // room
const memberCount = useRoomMemberCount(room);
- const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [
+ const [mayEditWidgets, mayCreateElementCalls, canJoinWithoutInvite] = useRoomState(room, () => [
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
room.currentState.mayClientSendStateEvent(ElementCall.MEMBER_EVENT_TYPE.name, room.client),
+ room.getJoinRule() === "public" || room.getJoinRule() === JoinRule.Knock,
+ /*|| room.getJoinRule() === JoinRule.Restricted <- rule for joining via token?*/
]);
// The options provided to the RoomHeader.
@@ -131,7 +144,7 @@ export const useRoomCall = (
return [PlatformCallType.ElementCall];
}
if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) {
- // only allow joining joining the ongoing Element call if there is one.
+ // only allow joining the ongoing Element call if there is one.
return [PlatformCallType.ElementCall];
}
}
@@ -258,6 +271,26 @@ export const useRoomCall = (
});
}, [isViewingCall, room.roomId]);
+ const generateCallLink = useCallback(() => {
+ if (!canJoinWithoutInvite)
+ throw new Error("Cannot create link for room that users can not join without invite.");
+ if (!guestSpaUrl) throw new Error("No guest SPA url for external links provided.");
+ const url = new URL(guestSpaUrl);
+ url.pathname = "/room/";
+ // Set params for the sharable url
+ url.searchParams.set("roomId", room.roomId);
+ url.searchParams.set("perParticipantE2EE", "true");
+ for (const server of calculateRoomVia(room)) {
+ url.searchParams.set("viaServers", server);
+ }
+
+ // Move params into hash
+ url.hash = "/" + room.name + url.search;
+ url.search = "";
+
+ logger.info("Generated element call external url:", url);
+ return url;
+ }, [canJoinWithoutInvite, guestSpaUrl, room]);
/**
* We've gone through all the steps
*/
@@ -268,6 +301,8 @@ export const useRoomCall = (
videoCallClick,
toggleCallMaximized: toggleCallMaximized,
isViewingCall: isViewingCall,
+ generateCallLink,
+ canGenerateCallLink: guestSpaUrl !== undefined && canJoinWithoutInvite,
isConnectedToCall: isConnectedToCall,
hasActiveCallSession: hasActiveCallSession,
callOptions,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index c974dc5f8b..348b7a9ed5 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2896,6 +2896,9 @@
"link_title": "Link to room",
"permalink_message": "Link to selected message",
"permalink_most_recent": "Link to most recent message",
+ "share_call": "Conference invite link",
+ "share_call_subtitle": "Link for external users to join the call without a matrix account:",
+ "title_link": "Share Link",
"title_message": "Share Room Message",
"title_room": "Share Room",
"title_user": "Share User"
@@ -3828,6 +3831,7 @@
"expand": "Return to call",
"failed_call_live_broadcast_description": "You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.",
"failed_call_live_broadcast_title": "Can’t start a call",
+ "get_call_link": "Share call link",
"hangup": "Hangup",
"hide_sidebar_button": "Hide sidebar",
"input_devices": "Input devices",
diff --git a/test/components/views/dialogs/ShareDialog-test.tsx b/test/components/views/dialogs/ShareDialog-test.tsx
new file mode 100644
index 0000000000..a78f4cf62f
--- /dev/null
+++ b/test/components/views/dialogs/ShareDialog-test.tsx
@@ -0,0 +1,130 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { EventTimeline, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
+import { render, RenderOptions } from "@testing-library/react";
+import { TooltipProvider } from "@vector-im/compound-web";
+
+import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
+import SettingsStore from "../../../../src/settings/SettingsStore";
+import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
+import { _t } from "../../../../src/languageHandler";
+import ShareDialog from "../../../../src/components/views/dialogs/ShareDialog";
+import { UIFeature } from "../../../../src/settings/UIFeature";
+import { stubClient } from "../../../test-utils";
+jest.mock("../../../../src/utils/ShieldUtils");
+
+function getWrapper(): RenderOptions {
+ return {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ };
+}
+
+describe("ShareDialog", () => {
+ let room: Room;
+
+ const ROOM_ID = "!1:example.org";
+
+ beforeEach(async () => {
+ stubClient();
+ room = new Room(ROOM_ID, MatrixClientPeg.get()!, "@alice:example.org");
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("renders room share dialog", () => {
+ const { container: withoutEvents } = render( , getWrapper());
+ expect(withoutEvents).toHaveTextContent(_t("share|title_room"));
+
+ jest.spyOn(room, "getLiveTimeline").mockReturnValue({ getEvents: () => [{} as MatrixEvent] } as EventTimeline);
+ const { container: withEvents } = render( , getWrapper());
+ expect(withEvents).toHaveTextContent(_t("share|permalink_most_recent"));
+ });
+
+ it("renders user share dialog", () => {
+ mockRoomMembers(room, 1);
+ const { container } = render(
+ ,
+ getWrapper(),
+ );
+ expect(container).toHaveTextContent(_t("share|title_user"));
+ });
+
+ it("renders link share dialog", () => {
+ mockRoomMembers(room, 1);
+ const { container } = render(
+ ,
+ getWrapper(),
+ );
+ expect(container).toHaveTextContent(_t("share|title_link"));
+ });
+
+ it("renders the QR code if configured", () => {
+ const originalGetValue = SettingsStore.getValue;
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => {
+ if (feature === UIFeature.ShareQRCode) return true;
+ return originalGetValue(feature);
+ });
+ const { container } = render( , getWrapper());
+ const qrCodesVisible = container.getElementsByClassName("mx_ShareDialog_qrcode_container").length > 0;
+ expect(qrCodesVisible).toBe(true);
+ });
+
+ it("renders the social button if configured", () => {
+ const originalGetValue = SettingsStore.getValue;
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => {
+ if (feature === UIFeature.ShareSocial) return true;
+ return originalGetValue(feature);
+ });
+ const { container } = render( , getWrapper());
+ const qrCodesVisible = container.getElementsByClassName("mx_ShareDialog_social_container").length > 0;
+ expect(qrCodesVisible).toBe(true);
+ });
+ it("renders custom title and subtitle", () => {
+ const { container } = render(
+ ,
+ getWrapper(),
+ );
+ expect(container).toHaveTextContent("test_title_123");
+ expect(container).toHaveTextContent("custom_subtitle_1234");
+ });
+});
+/**
+ *
+ * @param count the number of users to create
+ */
+function mockRoomMembers(room: Room, count: number) {
+ const members = Array(count)
+ .fill(0)
+ .map((_, index) => new RoomMember(room.roomId, "@alice:example.org"));
+
+ room.currentState.setJoinedMemberCount(members.length);
+ room.getJoinedMembers = jest.fn().mockReturnValue(members);
+}
diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx
index f051738c8a..427b68634a 100644
--- a/test/components/views/rooms/RoomHeader-test.tsx
+++ b/test/components/views/rooms/RoomHeader-test.tsx
@@ -55,9 +55,12 @@ import { Call, ElementCall } from "../../../../src/models/Call";
import * as ShieldUtils from "../../../../src/utils/ShieldUtils";
import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
+import { _t } from "../../../../src/languageHandler";
import * as UseCall from "../../../../src/hooks/useCall";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import WidgetStore, { IApp } from "../../../../src/stores/WidgetStore";
+import ShareDialog from "../../../../src/components/views/dialogs/ShareDialog";
+import Modal from "../../../../src/Modal";
jest.mock("../../../../src/utils/ShieldUtils");
function getWrapper(): RenderOptions {
@@ -491,6 +494,96 @@ describe("RoomHeader", () => {
});
});
+ describe("External conference", () => {
+ const oldGet = SdkConfig.get;
+ beforeEach(() => {
+ jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
+ if (key === "element_call") {
+ return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
+ }
+ return oldGet(key);
+ });
+ mockRoomMembers(room, 3);
+ jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
+ });
+
+ it("shows the external conference if the room has public join rules", () => {
+ jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
+
+ const { container } = render( , getWrapper());
+ expect(getByLabelText(container, _t("voip|get_call_link"))).toBeInTheDocument();
+ });
+
+ it("shows the external conference if the room has Knock join rules", () => {
+ jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
+
+ const { container } = render( , getWrapper());
+ expect(getByLabelText(container, _t("voip|get_call_link"))).toBeInTheDocument();
+ });
+
+ it("don't show external conference button if the call is not shown", () => {
+ jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
+ jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(false);
+
+ let { container } = render( , getWrapper());
+ expect(screen.queryByLabelText(_t("voip|get_call_link"))).not.toBeInTheDocument();
+
+ jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
+
+ container = render( , getWrapper()).container;
+
+ expect(getByLabelText(container, _t("voip|get_call_link"))).toBeInTheDocument();
+ });
+
+ it("don't show external conference button if now guest spa link is configured", () => {
+ jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
+ jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
+
+ jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
+ if (key === "element_call") {
+ return { url: "https://example2.com" };
+ }
+ return oldGet(key);
+ });
+
+ render( , getWrapper());
+
+ // We only change the SdkConfig and show that this everything else is
+ // configured so that the call link button is shown.
+
+ jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
+ if (key === "element_call") {
+ return { guest_spa_url: "https://guest_spa_url.com", url: "https://example2.com" };
+ }
+ return oldGet(key);
+ });
+
+ expect(screen.queryByLabelText(_t("voip|get_call_link"))).not.toBeInTheDocument();
+ const { container } = render( , getWrapper());
+ expect(getByLabelText(container, _t("voip|get_call_link"))).toBeInTheDocument();
+ });
+ it("opens the share dialog with the correct share link", () => {
+ jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
+ jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall").mockReturnValue(true);
+
+ const { container } = render( , getWrapper());
+ const modalSpy = jest.spyOn(Modal, "createDialog");
+ fireEvent.click(getByLabelText(container, _t("voip|get_call_link")));
+ const target =
+ "https://guest_spa_url.com/room/#/!1:example.org?roomId=%211%3Aexample.org&perParticipantE2EE=true&viaServers=example.org";
+ expect(modalSpy).toHaveBeenCalled();
+ const arg0 = modalSpy.mock.calls[0][0];
+ const arg1 = modalSpy.mock.calls[0][1] as any;
+ expect(arg0).toEqual(ShareDialog);
+ const { customTitle, subtitle } = arg1;
+ expect({ customTitle, subtitle }).toEqual({
+ customTitle: "Conference invite link",
+ subtitle: _t("share|share_call_subtitle"),
+ });
+ expect(arg1.target.toString()).toEqual(target);
+ });
+ });
+
describe("public room", () => {
it("shows a globe", () => {
const joinRuleEvent = new MatrixEvent({