diff --git a/res/css/compound/_Icon.pcss b/res/css/compound/_Icon.pcss index a40558ccc0..1c8e9c98b1 100644 --- a/res/css/compound/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -25,6 +25,12 @@ limitations under the License. padding: 1px; } +.mx_Icon_8 { + height: 8px; + flex: 0 0 8px; + width: 8px; +} + .mx_Icon_16 { height: 16px; flex: 0 0 16px; diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index cc99a843e5..511d9153e4 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -30,6 +30,20 @@ limitations under the License. pointer-events: none; /* makes the avatar non-draggable */ } } + + .mx_UserMenu_userAvatarLive { + align-items: center; + background-color: $alert; + border-radius: 6px; + color: $live-badge-color; + display: flex; + height: 12px; + justify-content: center; + left: 25px; + position: absolute; + top: 20px; + width: 12px; + } } .mx_UserMenu_name { diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index fdb380c94d..7f71696bb8 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -51,6 +51,12 @@ import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import UserIdentifierCustomisations from "../../customisations/UserIdentifier"; import PosthogTrackers from "../../PosthogTrackers"; import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; +import { Icon as LiveIcon } from "../../../res/img/element-icons/live.svg"; +import { + VoiceBroadcastRecording, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecordingsStoreEvent, +} from "../../voice-broadcast"; interface IProps { isPanelCollapsed: boolean; @@ -59,10 +65,11 @@ interface IProps { type PartialDOMRect = Pick; interface IState { - contextMenuPosition: PartialDOMRect; + contextMenuPosition: PartialDOMRect | null; isDarkTheme: boolean; isHighContrast: boolean; - selectedSpace?: Room; + selectedSpace?: Room | null; + showLiveAvatarAddon: boolean; } const toRightOf = (rect: PartialDOMRect) => { @@ -86,6 +93,7 @@ export default class UserMenu extends React.Component { private themeWatcherRef: string; private readonly dndWatcherRef: string; private buttonRef: React.RefObject = createRef(); + private voiceBroadcastRecordingStore = VoiceBroadcastRecordingsStore.instance(); constructor(props: IProps) { super(props); @@ -95,6 +103,7 @@ export default class UserMenu extends React.Component { isDarkTheme: this.isUserOnDarkTheme(), isHighContrast: this.isUserOnHighContrastTheme(), selectedSpace: SpaceStore.instance.activeSpaceRoom, + showLiveAvatarAddon: this.voiceBroadcastRecordingStore.hasCurrent(), }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); @@ -105,7 +114,17 @@ export default class UserMenu extends React.Component { return !!getHomePageUrl(SdkConfig.get()); } + private onCurrentVoiceBroadcastRecordingChanged = (recording: VoiceBroadcastRecording): void => { + this.setState({ + showLiveAvatarAddon: recording !== null, + }); + }; + public componentDidMount() { + this.voiceBroadcastRecordingStore.on( + VoiceBroadcastRecordingsStoreEvent.CurrentChanged, + this.onCurrentVoiceBroadcastRecordingChanged, + ); this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); } @@ -116,6 +135,10 @@ export default class UserMenu extends React.Component { if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); + this.voiceBroadcastRecordingStore.off( + VoiceBroadcastRecordingsStoreEvent.CurrentChanged, + this.onCurrentVoiceBroadcastRecordingChanged, + ); } private isUserOnDarkTheme(): boolean { @@ -414,6 +437,12 @@ export default class UserMenu extends React.Component { ; } + const liveAvatarAddon = this.state.showLiveAvatarAddon + ?
+ +
+ : null; + return
{ resizeMethod="crop" className="mx_UserMenu_userAvatar_BaseAvatar" /> + { liveAvatarAddon }
{ name } - { this.renderContextMenu() } diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts index b5c78a1b0e..b6c8191f54 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts @@ -31,7 +31,7 @@ interface EventMap { * This store provides access to the current and specific Voice Broadcast recordings. */ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { - private current: VoiceBroadcastRecording | null; + private current: VoiceBroadcastRecording | null = null; private recordings = new Map(); public constructor() { @@ -55,6 +55,10 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter", () => { + let client: MatrixClient; + let renderResult: RenderResult; + let voiceBroadcastInfoEvent: MatrixEvent; + let voiceBroadcastRecording: VoiceBroadcastRecording; + let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; + + beforeAll(() => { + client = stubClient(); + voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( + "!room:example.com", + VoiceBroadcastInfoState.Started, + client.getUserId() || "", + client.getDeviceId() || "", + ); + voiceBroadcastRecording = new VoiceBroadcastRecording( + voiceBroadcastInfoEvent, + client, + ); + }); + + beforeEach(() => { + voiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance(); + }); + + describe("when rendered", () => { + beforeEach(() => { + renderResult = render(); + }); + + it("should render as expected", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + + describe("and a live voice broadcast starts", () => { + beforeEach(() => { + act(() => { + voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); + }); + }); + + it("should render the live voice broadcast avatar addon", () => { + expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument(); + }); + + describe("and the broadcast ends", () => { + beforeEach(() => { + act(() => { + voiceBroadcastRecordingsStore.clearCurrent(); + }); + }); + + it("should not render the live voice broadcast avatar addon", () => { + expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument(); + }); + }); + }); + }); +}); diff --git a/test/components/structures/__snapshots__/UserMenu-test.tsx.snap b/test/components/structures/__snapshots__/UserMenu-test.tsx.snap new file mode 100644 index 0000000000..769711434a --- /dev/null +++ b/test/components/structures/__snapshots__/UserMenu-test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` when rendered should render as expected 1`] = ` +
+
+ +
+
+`; diff --git a/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts b/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts index a18f6c55db..3cea40df0a 100644 --- a/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts +++ b/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts @@ -75,6 +75,7 @@ describe("VoiceBroadcastRecordingsStore", () => { }); it("should return it as current", () => { + expect(recordings.hasCurrent()).toBe(true); expect(recordings.getCurrent()).toBe(recording); }); @@ -103,6 +104,7 @@ describe("VoiceBroadcastRecordingsStore", () => { }); it("should clear the current recording", () => { + expect(recordings.hasCurrent()).toBe(false); expect(recordings.getCurrent()).toBeNull(); });