Merge branch 'develop' into feat/reply-support-wysiwyg-composer

This commit is contained in:
Florian Duros 2022-10-14 15:53:19 +02:00 committed by GitHub
commit 4e8b731d2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 160 additions and 20 deletions

View file

@ -20,7 +20,13 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState } from "../../../models/Call"; import { Call, ConnectionState } from "../../../models/Call";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall"; import {
useCall,
useConnectionState,
useJoinCallButtonDisabled,
useJoinCallButtonTooltip,
useParticipants,
} from "../../../hooks/useCall";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
@ -106,7 +112,8 @@ interface ActiveLoadedCallEventProps {
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => { const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
const connectionState = useConnectionState(call); const connectionState = useConnectionState(call);
const participants = useParticipants(call); const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
const connect = useCallback((ev: ButtonEvent) => { const connect = useCallback((ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -138,8 +145,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
participants={participants} participants={participants}
buttonText={buttonText} buttonText={buttonText}
buttonKind={buttonKind} buttonKind={buttonKind}
buttonDisabled={Boolean(joinCallButtonDisabledTooltip)} buttonDisabled={joinCallButtonDisabled}
buttonTooltip={joinCallButtonDisabledTooltip} buttonTooltip={joinCallButtonTooltip}
onButtonClick={onButtonClick} onButtonClick={onButtonClick}
/>; />;
}); });

View file

@ -22,7 +22,13 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { ConnectionState } from "../../../models/Call"; import type { ConnectionState } from "../../../models/Call";
import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"; import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call";
import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall"; import {
useCall,
useConnectionState,
useJoinCallButtonDisabled,
useJoinCallButtonTooltip,
useParticipants,
} from "../../../hooks/useCall";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AppTile from "../elements/AppTile"; import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@ -110,11 +116,12 @@ const MAX_FACES = 8;
interface LobbyProps { interface LobbyProps {
room: Room; room: Room;
connect: () => Promise<void>; connect: () => Promise<void>;
joinCallButtonDisabledTooltip?: string; joinCallButtonTooltip?: string;
joinCallButtonDisabled?: boolean;
children?: ReactNode; children?: ReactNode;
} }
export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => {
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
@ -237,11 +244,11 @@ export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, con
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_CallView_connectButton" className="mx_CallView_connectButton"
kind="primary" kind="primary"
disabled={connecting || Boolean(joinCallButtonDisabledTooltip)} disabled={connecting || joinCallButtonDisabled}
onClick={onConnectClick} onClick={onConnectClick}
title={_t("Join")} title={_t("Join")}
label={_t("Join")} label={_t("Join")}
tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip} tooltip={connecting ? _t("Connecting") : joinCallButtonTooltip}
/> />
</div>; </div>;
}; };
@ -323,7 +330,8 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const connected = isConnected(useConnectionState(call)); const connected = isConnected(useConnectionState(call));
const participants = useParticipants(call); const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
const connect = useCallback(async () => { const connect = useCallback(async () => {
// Disconnect from any other active calls first, since we don't yet support holding // Disconnect from any other active calls first, since we don't yet support holding
@ -350,7 +358,8 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
lobby = <Lobby lobby = <Lobby
room={room} room={room}
connect={connect} connect={connect}
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip} joinCallButtonTooltip={joinCallButtonTooltip}
joinCallButtonDisabled={joinCallButtonDisabled}
> >
{ facePile } { facePile }
</Lobby>; </Lobby>;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { useState, useCallback } from "react"; import { useState, useCallback, useMemo } from "react";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState, ElementCall, Layout } from "../models/Call"; import { Call, ConnectionState, ElementCall, Layout } from "../models/Call";
@ -24,6 +24,7 @@ import { CallStore, CallStoreEvent } from "../stores/CallStore";
import { useEventEmitter } from "./useEventEmitter"; import { useEventEmitter } from "./useEventEmitter";
import SdkConfig, { DEFAULTS } from "../SdkConfig"; import SdkConfig, { DEFAULTS } from "../SdkConfig";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import { MatrixClientPeg } from "../MatrixClientPeg";
export const useCall = (roomId: string): Call | null => { export const useCall = (roomId: string): Call | null => {
const [call, setCall] = useState(() => CallStore.instance.getCall(roomId)); const [call, setCall] = useState(() => CallStore.instance.getCall(roomId));
@ -56,15 +57,33 @@ export const useFull = (call: Call): boolean => {
); );
}; };
export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => { export const useIsAlreadyParticipant = (call: Call): boolean => {
const client = MatrixClientPeg.get();
const participants = useParticipants(call);
return useMemo(() => {
return participants.has(client.getRoom(call.roomId).getMember(client.getUserId()));
}, [participants, client, call]);
};
export const useJoinCallButtonTooltip = (call: Call): string | null => {
const isFull = useFull(call); const isFull = useFull(call);
const state = useConnectionState(call); const state = useConnectionState(call);
const isAlreadyParticipant = useIsAlreadyParticipant(call);
if (state === ConnectionState.Connecting) return _t("Connecting"); if (state === ConnectionState.Connecting) return _t("Connecting");
if (isFull) return _t("Sorry — this call is currently full"); if (isFull) return _t("Sorry — this call is currently full");
if (isAlreadyParticipant) return _t("You have already joined this call from another device");
return null; return null;
}; };
export const useJoinCallButtonDisabled = (call: Call): boolean => {
const isFull = useFull(call);
const state = useConnectionState(call);
return isFull || state === ConnectionState.Connecting;
};
export const useLayout = (call: ElementCall): Layout => export const useLayout = (call: ElementCall): Layout =>
useTypedEventEmitterState( useTypedEventEmitterState(
call, call,

View file

@ -1023,6 +1023,7 @@
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Connecting": "Connecting", "Connecting": "Connecting",
"Sorry — this call is currently full": "Sorry — this call is currently full", "Sorry — this call is currently full": "Sorry — this call is currently full",
"You have already joined this call from another device": "You have already joined this call from another device",
"Create account": "Create account", "Create account": "Create account",
"You made it!": "You made it!", "You made it!": "You made it!",
"Find and invite your friends": "Find and invite your friends", "Find and invite your friends": "Find and invite your friends",

View file

@ -17,7 +17,7 @@ limitations under the License.
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { RoomEvent } from "matrix-js-sdk/src/models/room"; import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { CallType } from "matrix-js-sdk/src/webrtc/call";
@ -606,8 +606,11 @@ export interface ElementCallMemberContent {
export class ElementCall extends Call { export class ElementCall extends Call {
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call"); public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member"); public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
public static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device";
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private kickedOutByAnotherDevice = false;
private connectionTime: number | null = null;
private participantsExpirationTimer: number | null = null; private participantsExpirationTimer: number | null = null;
private terminationTimer: number | null = null; private terminationTimer: number | null = null;
@ -785,6 +788,16 @@ export class ElementCall extends Call {
audioInput: MediaDeviceInfo | null, audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null,
): Promise<void> { ): Promise<void> {
this.kickedOutByAnotherDevice = false;
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.connectionTime = Date.now();
await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, {
[this.client.getUserId()]: {
"*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime },
},
});
try { try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null, audioInput: audioInput?.label ?? null,
@ -808,6 +821,7 @@ export class ElementCall extends Call {
} }
public setDisconnected() { public setDisconnected() {
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
@ -845,8 +859,13 @@ export class ElementCall extends Call {
} }
private get mayTerminate(): boolean { private get mayTerminate(): boolean {
return this.groupCall.getContent()["m.intent"] !== "m.room" if (this.kickedOutByAnotherDevice) return false;
&& this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client); if (this.groupCall.getContent()["m.intent"] === "m.room") return false;
if (
!this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client)
) return false;
return true;
} }
private async terminate(): Promise<void> { private async terminate(): Promise<void> {
@ -868,6 +887,17 @@ export class ElementCall extends Call {
if ("m.terminated" in newGroupCall.getContent()) this.destroy(); if ("m.terminated" in newGroupCall.getContent()) this.destroy();
}; };
private onToDeviceEvent = (event: MatrixEvent): void => {
const content = event.getContent();
if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return;
if (event.getSender() !== this.client.getUserId()) return;
if (content.device_id === this.client.getDeviceId()) return;
if (content.timestamp <= this.connectionTime) return;
this.kickedOutByAnotherDevice = true;
this.disconnect();
};
private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => { private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
if ( if (
(state === ConnectionState.Connected && !isConnected(prevState)) (state === ConnectionState.Connected && !isConnected(prevState))

View file

@ -30,7 +30,7 @@ import {
LiveContentSummaryWithCall, LiveContentSummaryWithCall,
LiveContentType, LiveContentType,
} from "../components/views/rooms/LiveContentSummary"; } from "../components/views/rooms/LiveContentSummary";
import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall"; import { useCall, useJoinCallButtonDisabled, useJoinCallButtonTooltip } from "../hooks/useCall";
import { useRoomState } from "../hooks/useRoomState"; import { useRoomState } from "../hooks/useRoomState";
import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import { ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher"; import { useDispatcher } from "../hooks/useDispatcher";
@ -45,12 +45,13 @@ interface JoinCallButtonWithCallProps {
} }
function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) { function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) {
const tooltip = useJoinCallButtonDisabledTooltip(call); const tooltip = useJoinCallButtonTooltip(call);
const disabled = useJoinCallButtonDisabled(call);
return <AccessibleTooltipButton return <AccessibleTooltipButton
className="mx_IncomingCallToast_joinButton" className="mx_IncomingCallToast_joinButton"
onClick={onClick} onClick={onClick}
disabled={Boolean(tooltip)} disabled={disabled}
tooltip={tooltip} tooltip={tooltip}
kind="primary" kind="primary"
> >

View file

@ -18,10 +18,11 @@ import EventEmitter from "events";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { waitFor } from "@testing-library/react"; import { waitFor } from "@testing-library/react";
import { RoomType } from "matrix-js-sdk/src/@types/event"; import { RoomType } from "matrix-js-sdk/src/@types/event";
import { PendingEventOrdering } from "matrix-js-sdk/src/client"; import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api"; import { Widget } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Mocked } from "jest-mock"; import type { Mocked } from "jest-mock";
import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client"; import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
@ -85,6 +86,7 @@ const setUpClientRoomAndStores = (): {
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]); client.getRooms.mockReturnValue([room]);
client.getUserId.mockReturnValue(alice.userId); client.getUserId.mockReturnValue(alice.userId);
client.getDeviceId.mockReturnValue("alices_device");
client.reEmitter.reEmit(room, [RoomStateEvent.Events]); client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
if (roomId !== room.roomId) throw new Error("Unknown room"); if (roomId !== room.roomId) throw new Error("Unknown room");
@ -814,6 +816,77 @@ describe("ElementCall", () => {
call.off(CallEvent.Destroy, onDestroy); call.off(CallEvent.Destroy, onDestroy);
}); });
describe("being kicked out by another device", () => {
const onDestroy = jest.fn();
beforeEach(async () => {
await call.connect();
call.on(CallEvent.Destroy, onDestroy);
jest.advanceTimersByTime(100);
jest.clearAllMocks();
});
afterEach(() => {
call.off(CallEvent.Destroy, onDestroy);
});
it("does not terminate the call if we are the last", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
getSender: () => (client.getUserId()),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeTruthy();
});
it("ignores messages from our device", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (client.getUserId()),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: client.getDeviceId(), timestamp: Date.now() }),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
it("ignores messages from other users", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (bob.userId),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
it("ignores messages from the past", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (client.getUserId()),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: 0 }),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
});
it("ends the call after a random delay if the last participant leaves without ending it", async () => { it("ends the call after a random delay if the last participant leaves without ending it", async () => {
// Bob connects // Bob connects
await client.sendStateEvent( await client.sendStateEvent(