Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit d35fce1

Browse files
toger5robintown
andauthored
Call Guest Access, give user the option to change the acces level so they can generate a call link. (#12401)
* Ask the user to change the room access settings if they click the create link button. Signed-off-by: Timo K <[email protected]> * disable call button if appropriate. Signed-off-by: Timo K <[email protected]> * Add tests Refactor tests to be in CallGuestLinkButton-test instead of the RoomHeader Signed-off-by: Timo K <[email protected]> * add test for: no button if cannot change join rule and room not public nor knock Signed-off-by: Timo K <[email protected]> * fix tests Signed-off-by: Timo K <[email protected]> * add JoinRuleDialog tests Signed-off-by: Timo K <[email protected]> * move spy into before each Signed-off-by: Timo K <[email protected]> * Update src/i18n/strings/en_EN.json Co-authored-by: Robin <[email protected]> * remove inline css and update modal style Signed-off-by: Timo K <[email protected]> * Update src/i18n/strings/en_EN.json Co-authored-by: Robin <[email protected]> * Update src/i18n/strings/en_EN.json Co-authored-by: Robin <[email protected]> * Invite state was not reactive. Changing power level did not update the ui. Signed-off-by: Timo K <[email protected]> * linter Signed-off-by: Timo K <[email protected]> * make useGuestAccessInformation use useRoomState Signed-off-by: Timo K <[email protected]> * fix tests and simplify logic * fix tests * review Signed-off-by: Timo K <[email protected]> --------- Signed-off-by: Timo K <[email protected]> Co-authored-by: Robin <[email protected]>
1 parent 59395ab commit d35fce1

File tree

11 files changed

+588
-175
lines changed

11 files changed

+588
-175
lines changed

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@
272272
@import "./views/rooms/_Autocomplete.pcss";
273273
@import "./views/rooms/_AuxPanel.pcss";
274274
@import "./views/rooms/_BasicMessageComposer.pcss";
275+
@import "./views/rooms/_CallGuestLinkButton.pcss";
275276
@import "./views/rooms/_DecryptionFailureBar.pcss";
276277
@import "./views/rooms/_E2EIcon.pcss";
277278
@import "./views/rooms/_EditMessageComposer.pcss";
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.mx_JoinRuleDialog {
2+
.mx_JoinRuleDialogButtons {
3+
display: flex;
4+
column-gap: 5px;
5+
justify-content: center;
6+
}
7+
}

src/components/views/context_menus/RoomContextMenu.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import React, { useContext } from "react";
18-
import { Room } from "matrix-js-sdk/src/matrix";
18+
import { Room, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
1919
import { KnownMembership } from "matrix-js-sdk/src/types";
2020

2121
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
@@ -117,9 +117,9 @@ const RoomContextMenu: React.FC<IProps> = ({ room, onFinished, ...props }) => {
117117
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
118118
const isVideoRoom =
119119
videoRoomsEnabled && (room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom()));
120-
120+
const canInvite = useEventEmitterState(cli, RoomMemberEvent.PowerLevel, () => room.canInvite(cli.getUserId()!));
121121
let inviteOption: JSX.Element | undefined;
122-
if (room.canInvite(cli.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers)) {
122+
if (canInvite && !isDm && shouldShowComponent(UIComponent.InviteUsers)) {
123123
const onInviteClick = (ev: ButtonEvent): void => {
124124
ev.preventDefault();
125125
ev.stopPropagation();

src/components/views/right_panel/RoomSummaryCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { Icon as LockIcon } from "@vector-im/compound-design-tokens/icons/lock-s
3232
import { Icon as LockOffIcon } from "@vector-im/compound-design-tokens/icons/lock-off.svg";
3333
import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.svg";
3434
import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
35-
import { EventType, JoinRule, Room } from "matrix-js-sdk/src/matrix";
35+
import { EventType, JoinRule, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
3636

3737
import MatrixClientContext from "../../../contexts/MatrixClientContext";
3838
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
@@ -393,6 +393,7 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, permalinkCreator, onClose, on
393393
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
394394
RoomListStore.instance.getTagsForRoom(room),
395395
);
396+
const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room));
396397
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
397398

398399
return (
@@ -439,7 +440,7 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, permalinkCreator, onClose, on
439440
<MenuItem
440441
Icon={UserAddIcon}
441442
label={_t("action|invite")}
442-
disabled={!canInviteTo(room)}
443+
disabled={!canInviteToState}
443444
onSelect={() => inviteToRoom(room)}
444445
/>
445446
<MenuItem Icon={LinkIcon} label={_t("action|copy_link")} onSelect={onShareRoomClick} />

src/components/views/rooms/RoomHeader.tsx

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
1818
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
1919
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
2020
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg";
21-
import { Icon as ExternalLinkIcon } from "@vector-im/compound-design-tokens/icons/link.svg";
2221
import { Icon as CloseCallIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
2322
import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg";
2423
import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.svg";
@@ -27,7 +26,6 @@ import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error
2726
import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.svg";
2827
import { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix";
2928
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
30-
import { logger } from "matrix-js-sdk/src/logger";
3129

3230
import { useRoomName } from "../../../hooks/useRoomName";
3331
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
@@ -56,8 +54,7 @@ import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
5654
import { RoomKnocksBar } from "./RoomKnocksBar";
5755
import { isVideoRoom } from "../../../utils/video-rooms";
5856
import { notificationLevelToIndicator } from "../../../utils/notifications";
59-
import Modal from "../../../Modal";
60-
import ShareDialog from "../dialogs/ShareDialog";
57+
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
6158

6259
export default function RoomHeader({
6360
room,
@@ -82,8 +79,6 @@ export default function RoomHeader({
8279
videoCallClick,
8380
toggleCallMaximized: toggleCall,
8481
isViewingCall,
85-
generateCallLink,
86-
canGenerateCallLink,
8782
isConnectedToCall,
8883
hasActiveCallSession,
8984
callOptions,
@@ -124,34 +119,14 @@ export default function RoomHeader({
124119

125120
const videoClick = useCallback((ev) => videoCallClick(ev, callOptions[0]), [callOptions, videoCallClick]);
126121

127-
const shareClick = useCallback(() => {
128-
try {
129-
// generateCallLink throws if the permissions are not met
130-
const target = generateCallLink();
131-
Modal.createDialog(ShareDialog, {
132-
target,
133-
customTitle: _t("share|share_call"),
134-
subtitle: _t("share|share_call_subtitle"),
135-
});
136-
} catch (e) {
137-
logger.error("Could not generate call link.", e);
138-
}
139-
}, [generateCallLink]);
140-
141122
const toggleCallButton = (
142123
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
143124
<IconButton onClick={toggleCall}>
144125
<VideoCallIcon />
145126
</IconButton>
146127
</Tooltip>
147128
);
148-
const createExternalLinkButton = (
149-
<Tooltip label={_t("voip|get_call_link")}>
150-
<IconButton onClick={shareClick} aria-label={_t("voip|get_call_link")}>
151-
<ExternalLinkIcon />
152-
</IconButton>
153-
</Tooltip>
154-
);
129+
155130
const joinCallButton = (
156131
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
157132
<Button
@@ -227,7 +202,10 @@ export default function RoomHeader({
227202
const voiceCallButton = (
228203
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
229204
<IconButton
230-
disabled={!!voiceCallDisabledReason}
205+
// We need both: isViewingCall and isConnectedToCall
206+
// - in the Lobby we are viewing a call but are not connected to it.
207+
// - in pip view we are connected to the call but not viewing it.
208+
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
231209
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
232210
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
233211
>
@@ -335,7 +313,8 @@ export default function RoomHeader({
335313
</Tooltip>
336314
);
337315
})}
338-
{isViewingCall && canGenerateCallLink && createExternalLinkButton}
316+
317+
{isViewingCall && <CallGuestLinkButton room={room} />}
339318
{((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && <VideoRoomChatButton room={room} />}
340319

341320
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
import { Icon as ExternalLinkIcon } from "@vector-im/compound-design-tokens/icons/link.svg";
17+
import { Button, IconButton, Tooltip } from "@vector-im/compound-web";
18+
import React, { useCallback } from "react";
19+
import { logger } from "matrix-js-sdk/src/logger";
20+
import { EventType, IJoinRuleEventContent, JoinRule, Room } from "matrix-js-sdk/src/matrix";
21+
22+
import Modal from "../../../../Modal";
23+
import ShareDialog from "../../dialogs/ShareDialog";
24+
import { _t } from "../../../../languageHandler";
25+
import SettingsStore from "../../../../settings/SettingsStore";
26+
import { calculateRoomVia } from "../../../../utils/permalinks/Permalinks";
27+
import BaseDialog from "../../dialogs/BaseDialog";
28+
import { useGuestAccessInformation } from "../../../../hooks/room/useGuestAccessInformation";
29+
30+
/**
31+
* Display a button to open a dialog to share a link to the call using a element call guest spa url (`element_call:guest_spa_url` in the EW config).
32+
* @param room
33+
* @returns Nothing if there is not the option to share a link (No guest_spa_url is set) or a button to open a dialog to share the link.
34+
*/
35+
export const CallGuestLinkButton: React.FC<{ room: Room }> = ({ room }) => {
36+
const { canInviteGuests, guestSpaUrl, isRoomJoinable, canInvite } = useGuestAccessInformation(room);
37+
38+
const generateCallLink = useCallback(() => {
39+
if (!isRoomJoinable()) throw new Error("Cannot create link for room that users can not join without invite.");
40+
if (!guestSpaUrl) throw new Error("No guest SPA url for external links provided.");
41+
const url = new URL(guestSpaUrl);
42+
url.pathname = "/room/";
43+
// Set params for the sharable url
44+
url.searchParams.set("roomId", room.roomId);
45+
if (room.hasEncryptionStateEvent()) url.searchParams.set("perParticipantE2EE", "true");
46+
for (const server of calculateRoomVia(room)) {
47+
url.searchParams.set("viaServers", server);
48+
}
49+
50+
// Move params into hash
51+
url.hash = "/" + room.name + url.search;
52+
url.search = "";
53+
54+
logger.info("Generated element call external url:", url);
55+
return url;
56+
}, [guestSpaUrl, isRoomJoinable, room]);
57+
58+
const showLinkModal = useCallback(() => {
59+
try {
60+
// generateCallLink throws if the invite rules are not met
61+
const target = generateCallLink();
62+
Modal.createDialog(ShareDialog, {
63+
target,
64+
customTitle: _t("share|share_call"),
65+
subtitle: _t("share|share_call_subtitle"),
66+
});
67+
} catch (e) {
68+
logger.error("Could not generate call link.", e);
69+
}
70+
}, [generateCallLink]);
71+
72+
const shareClick = useCallback(() => {
73+
if (isRoomJoinable()) {
74+
showLinkModal();
75+
} else {
76+
// the room needs to be set to public or knock to generate a link
77+
Modal.createDialog(JoinRuleDialog, {
78+
room,
79+
// If the user cannot invite the Knocking is not given as an option.
80+
canInvite,
81+
}).finished.then(() => {
82+
// we need to use the function here because the callback got called before the state was updated.
83+
if (isRoomJoinable()) showLinkModal();
84+
});
85+
}
86+
}, [isRoomJoinable, showLinkModal, room, canInvite]);
87+
88+
return (
89+
<>
90+
{canInviteGuests && (
91+
<Tooltip label={_t("voip|get_call_link")}>
92+
<IconButton onClick={shareClick} aria-label={_t("voip|get_call_link")}>
93+
<ExternalLinkIcon />
94+
</IconButton>
95+
</Tooltip>
96+
)}
97+
</>
98+
);
99+
};
100+
101+
/**
102+
* A dialog to change the join rule of a room to public or knock.
103+
* @param room The room to change the join rule of.
104+
* @param onFinished Callback that is getting called if the dialog wants to close.
105+
*/
106+
export const JoinRuleDialog: React.FC<{
107+
onFinished(): void;
108+
room: Room;
109+
canInvite: boolean;
110+
}> = ({ onFinished, room, canInvite }) => {
111+
const askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
112+
const [isUpdating, setIsUpdating] = React.useState<undefined | JoinRule>(undefined);
113+
const changeJoinRule = useCallback(
114+
async (newRule: JoinRule) => {
115+
if (isUpdating !== undefined) return;
116+
setIsUpdating(newRule);
117+
await room.client.sendStateEvent(
118+
room.roomId,
119+
EventType.RoomJoinRules,
120+
{
121+
join_rule: newRule,
122+
} as IJoinRuleEventContent,
123+
"",
124+
);
125+
// Show the dialog for a bit to give the user feedback
126+
setTimeout(() => onFinished(), 500);
127+
},
128+
[isUpdating, onFinished, room.client, room.roomId],
129+
);
130+
return (
131+
<BaseDialog title={_t("update_room_access_modal|title")} onFinished={onFinished} className="mx_JoinRuleDialog">
132+
<p>{_t("update_room_access_modal|description")}</p>
133+
<div className="mx_JoinRuleDialogButtons">
134+
{askToJoinEnabled && canInvite && (
135+
<Button
136+
kind="secondary"
137+
className="mx_Dialog_nonDialogButton"
138+
disabled={isUpdating === JoinRule.Knock}
139+
onClick={() => changeJoinRule(JoinRule.Knock)}
140+
>
141+
{_t("action|ask_to_join")}
142+
</Button>
143+
)}
144+
<Button
145+
className="mx_Dialog_nonDialogButton"
146+
kind="destructive"
147+
disabled={isUpdating === JoinRule.Public}
148+
onClick={() => changeJoinRule(JoinRule.Public)}
149+
>
150+
{_t("common|public")}
151+
</Button>
152+
</div>
153+
<p>{_t("update_room_access_modal|dont_change_description")}</p>
154+
<div className="mx_JoinRuleDialogButtons">
155+
<Button
156+
kind="tertiary"
157+
className="mx_Dialog_nonDialogButton"
158+
onClick={() => {
159+
if (isUpdating === undefined) onFinished();
160+
}}
161+
>
162+
{_t("update_room_access_modal|no_change")}
163+
</Button>
164+
</div>
165+
</BaseDialog>
166+
);
167+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { useMemo } from "react";
18+
import { EventType, JoinRule, Room } from "matrix-js-sdk/src/matrix";
19+
20+
import SdkConfig from "../../SdkConfig";
21+
import { useRoomState } from "../useRoomState";
22+
23+
interface GuestAccessInformation {
24+
canInviteGuests: boolean;
25+
guestSpaUrl?: string;
26+
isRoomJoinable: () => boolean;
27+
canInvite: boolean;
28+
}
29+
30+
/**
31+
* Helper to retrieve the guest access related information for a room.
32+
* @param room
33+
* @returns The GuestAccessInformation which helps decide what options the user should be given.
34+
*/
35+
export const useGuestAccessInformation = (room: Room): GuestAccessInformation => {
36+
const guestSpaUrl = useMemo(() => {
37+
return SdkConfig.get("element_call").guest_spa_url;
38+
}, []);
39+
40+
// We use the direct function only in functions triggered by user interaction to avoid computation on every render.
41+
const { joinRule, canInvite, canChangeJoinRule } = useRoomState(room, (roomState) => ({
42+
joinRule: room.getJoinRule(),
43+
canInvite: room.canInvite(room.myUserId),
44+
canChangeJoinRule: roomState.maySendStateEvent(EventType.RoomJoinRules, room.myUserId),
45+
}));
46+
const isRoomJoinable = useMemo(
47+
() => joinRule === JoinRule.Public || (joinRule === JoinRule.Knock && canInvite),
48+
[canInvite, joinRule],
49+
);
50+
const canInviteGuests = useMemo(
51+
() => (canChangeJoinRule || isRoomJoinable) && guestSpaUrl !== undefined,
52+
[canChangeJoinRule, isRoomJoinable, guestSpaUrl],
53+
);
54+
55+
const isRoomJoinableFunction = (): boolean =>
56+
room.getJoinRule() === JoinRule.Public || (joinRule === JoinRule.Knock && room.canInvite(room.myUserId));
57+
return { canInviteGuests, guestSpaUrl, isRoomJoinable: isRoomJoinableFunction, canInvite };
58+
};

0 commit comments

Comments
 (0)