Voice broadcast indicator in room list (#9709)

pull/28217/head
Michael Weimann 2022-12-06 10:56:29 +01:00 committed by GitHub
parent 89439d4f10
commit 474f464e48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 295 additions and 36 deletions

View File

@ -375,4 +375,5 @@
@import "./voice-broadcast/atoms/_PlaybackControlButton.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss";
@import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss";

View File

@ -0,0 +1,22 @@
/*
Copyright 2022 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.
*/
.mx_RoomTile .mx_RoomTile_titleContainer .mx_RoomTile_subtitle.mx_RoomTile_subtitle--voice-broadcast {
align-items: center;
color: $alert;
display: flex;
gap: $spacing-4;
}

View File

@ -48,20 +48,25 @@ import { RoomTileCallSummary } from "./RoomTileCallSummary";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useHasRoomLiveVoiceBroadcast, VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast";
interface IProps {
interface Props {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
}
interface ClassProps extends Props {
hasLiveVoiceBroadcast: boolean;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState {
interface State {
selected: boolean;
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
notificationsMenuPosition: PartialDOMRect | null;
generalMenuPosition: PartialDOMRect | null;
call: Call | null;
messagePreview?: string;
}
@ -76,13 +81,13 @@ export const contextMenuBelow = (elementRect: PartialDOMRect) => {
return { left, top, chevronFace };
};
export default class RoomTile extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
export class RoomTile extends React.PureComponent<ClassProps, State> {
private dispatcherRef?: string;
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
constructor(props: IProps) {
constructor(props: ClassProps) {
super(props);
this.state = {
@ -120,7 +125,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
return !this.props.isMinimized && this.props.showMessagePreview;
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) {
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>) {
const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview;
const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized;
if (showMessageChanged || minimizedChanged) {
@ -169,7 +174,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.onRoomPreviewChanged,
);
this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
@ -218,12 +223,14 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
ev.stopPropagation();
const action = getKeyBindingsManager().getAccessibilityAction(ev);
const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array<string | undefined>)
.includes(action);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: [KeyBindingAction.Enter, KeyBindingAction.Space].includes(action),
clear_search: clearSearch,
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
@ -233,7 +240,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ selected: isActive });
};
private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
private onNotificationsMenuOpenClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -246,7 +253,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ notificationsMenuPosition: null });
};
private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
private onGeneralMenuOpenClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -271,7 +278,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ generalMenuPosition: null });
};
private renderNotificationsMenu(isActive: boolean): React.ReactElement {
private renderNotificationsMenu(isActive: boolean): React.ReactElement | null {
if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived ||
!this.showContextMenu || this.props.isMinimized
) {
@ -313,7 +320,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
}
private renderGeneralMenu(): React.ReactElement {
private renderGeneralMenu(): React.ReactElement | null {
if (!this.showContextMenu) return null; // no menu to show
return (
<React.Fragment>
@ -379,6 +386,8 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
<RoomTileCallSummary call={this.state.call} />
</div>
);
} else if (this.props.hasLiveVoiceBroadcast) {
subtitle = <VoiceBroadcastRoomSubtitle />;
} else if (this.showMessagePreview && this.state.messagePreview) {
subtitle = (
<div
@ -472,3 +481,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
}
}
const RoomTileHOC: React.FC<Props> = (props: Props) => {
const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room);
return <RoomTile {...props} hasLiveVoiceBroadcast={hasLiveVoiceBroadcast} />;
};
export default RoomTileHOC;

View File

@ -0,0 +1,27 @@
/*
Copyright 2022 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 { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
import { _t } from "../../../languageHandler";
export const VoiceBroadcastRoomSubtitle = () => {
return <div className="mx_RoomTile_subtitle mx_RoomTile_subtitle--voice-broadcast">
<LiveIcon className="mx_Icon mx_Icon_16" />
{ _t("Live") }
</div>;
};

View File

@ -0,0 +1,35 @@
/*
Copyright 2022 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 { useState } from "react";
import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { hasRoomLiveVoiceBroadcast } from "../utils/hasRoomLiveVoiceBroadcast";
import { useTypedEventEmitter } from "../../hooks/useEventEmitter";
export const useHasRoomLiveVoiceBroadcast = (room: Room) => {
const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(hasRoomLiveVoiceBroadcast(room).hasBroadcast);
useTypedEventEmitter(
room.currentState,
RoomStateEvent.Update,
() => {
setHasLiveVoiceBroadcast(hasRoomLiveVoiceBroadcast(room).hasBroadcast);
},
);
return hasLiveVoiceBroadcast;
};

View File

@ -29,12 +29,14 @@ export * from "./components/VoiceBroadcastBody";
export * from "./components/atoms/LiveBadge";
export * from "./components/atoms/VoiceBroadcastControl";
export * from "./components/atoms/VoiceBroadcastHeader";
export * from "./components/atoms/VoiceBroadcastRoomSubtitle";
export * from "./components/molecules/VoiceBroadcastPlaybackBody";
export * from "./components/molecules/VoiceBroadcastPreRecordingPip";
export * from "./components/molecules/VoiceBroadcastRecordingBody";
export * from "./components/molecules/VoiceBroadcastRecordingPip";
export * from "./hooks/useCurrentVoiceBroadcastPreRecording";
export * from "./hooks/useCurrentVoiceBroadcastRecording";
export * from "./hooks/useHasRoomLiveVoiceBroadcast";
export * from "./hooks/useVoiceBroadcastRecording";
export * from "./stores/VoiceBroadcastPlaybacksStore";
export * from "./stores/VoiceBroadcastPreRecordingStore";

View File

@ -32,7 +32,7 @@ import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-l
import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore";
import RoomList from "../../../../src/components/views/rooms/RoomList";
import RoomSublist from "../../../../src/components/views/rooms/RoomSublist";
import RoomTile from "../../../../src/components/views/rooms/RoomTile";
import { RoomTile } from "../../../../src/components/views/rooms/RoomTile";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from '../../../test-utils';
import ResizeNotifier from '../../../../src/utils/ResizeNotifier';

View File

@ -15,12 +15,13 @@ limitations under the License.
*/
import React from "react";
import { render, screen, act } from "@testing-library/react";
import { render, screen, act, RenderResult } from "@testing-library/react";
import { mocked, Mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
@ -30,6 +31,7 @@ import {
MockedCall,
useMockedCalls,
setupAsyncStoreWithClient,
filterConsole,
} from "../../../test-utils";
import { CallStore } from "../../../../src/stores/CallStore";
import RoomTile from "../../../../src/components/views/rooms/RoomTile";
@ -39,38 +41,79 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import PlatformPeg from "../../../../src/PlatformPeg";
import BasePlatform from "../../../../src/BasePlatform";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils";
describe("RoomTile", () => {
jest.spyOn(PlatformPeg, "get")
.mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform);
useMockedCalls();
const setUpVoiceBroadcast = (state: VoiceBroadcastInfoState): void => {
voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent(
room.roomId,
state,
client.getUserId(),
client.getDeviceId(),
);
act(() => {
room.currentState.setStateEvents([voiceBroadcastInfoEvent]);
});
};
const renderRoomTile = (): void => {
renderResult = render(
<RoomTile
room={room}
showMessagePreview={false}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>,
);
};
let client: Mocked<MatrixClient>;
let restoreConsole: () => void;
let voiceBroadcastInfoEvent: MatrixEvent;
let room: Room;
let renderResult: RenderResult;
beforeEach(() => {
restoreConsole = filterConsole(
// irrelevant for this test
"Room !1:example.org does not have an m.room.create event",
);
stubClient();
client = mocked(MatrixClientPeg.get());
DMRoomMap.makeShared();
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
renderRoomTile();
});
afterEach(() => {
restoreConsole();
jest.clearAllMocks();
});
describe("call subtitle", () => {
let room: Room;
it("should render the room", () => {
expect(renderResult.container).toMatchSnapshot();
});
describe("when a call starts", () => {
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
@ -83,18 +126,10 @@ describe("RoomTile", () => {
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
render(
<RoomTile
room={room}
showMessagePreview={false}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>,
);
});
afterEach(() => {
renderResult.unmount();
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
@ -147,5 +182,45 @@ describe("RoomTile", () => {
act(() => { call.participants = new Map(); });
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
describe("and a live broadcast starts", () => {
beforeEach(() => {
setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
});
it("should still render the call subtitle", () => {
expect(screen.queryByText("Video")).toBeInTheDocument();
expect(screen.queryByText("Live")).not.toBeInTheDocument();
});
});
});
describe("when a live voice broadcast starts", () => {
beforeEach(() => {
setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
});
it("should render the »Live« subtitle", () => {
expect(screen.queryByText("Live")).toBeInTheDocument();
});
describe("and the broadcast stops", () => {
beforeEach(() => {
const stopEvent = mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Stopped,
client.getUserId(),
client.getDeviceId(),
voiceBroadcastInfoEvent,
);
act(() => {
room.currentState.setStateEvents([stopEvent]);
});
});
it("should not render the »Live« subtitle", () => {
expect(screen.queryByText("Live")).not.toBeInTheDocument();
});
});
});
});

View File

@ -0,0 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomTile should render the room 1`] = `
<div>
<div
aria-label="!1:example.org Unread messages."
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
>
!
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleHasUnreadEvents"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
/>
</div>
</div>
`;

View File

@ -39,7 +39,7 @@ export const filterConsole = (...ignoreList: string[]): () => void => {
return;
}
originalFunction(data);
originalFunction(...data);
};
}