Merge branch 'livekit' into toger5/tiles_based_on_rtc_member

This commit is contained in:
Hugh Nimmo-Smith
2024-11-20 10:15:52 +00:00
58 changed files with 825 additions and 800 deletions

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import "matrix-js-sdk/src/@types/global";
import type { DurationFormat as PolyfillDurationFormat } from "@formatjs/intl-durationformat";
import { Controls } from "../controls";
declare global {
@@ -23,4 +24,9 @@ declare global {
// Safari only supports this prefixed, so tell the type system about it
webkitRequestFullscreen: () => void;
}
namespace Intl {
// Add DurationFormat as part of the Intl namespace because we polyfill it
const DurationFormat: typeof PolyfillDurationFormat;
}
}

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import "i18next";
// import all namespaces (for the default language, only)
import app from "../../public/locales/en-GB/app.json";
import app from "../../locales/en-GB/app.json";
declare module "i18next" {
interface CustomTypeOptions {

View File

@@ -16,19 +16,13 @@ import {
useMemo,
} from "react";
import { useHistory } from "react-router-dom";
import {
ClientEvent,
ICreateClientOpts,
MatrixClient,
} from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { WidgetApi } from "matrix-widget-api";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/client";
import type { WidgetApi } from "matrix-widget-api";
import { ErrorView } from "./FullScreenView";
import { fallbackICEServerAllowed, initClient } from "./utils/matrix";
import { widget } from "./widget";
import {
PosthogAnalytics,
@@ -36,7 +30,6 @@ import {
} from "./analytics/PosthogAnalytics";
import { translatedError } from "./TranslatedError";
import { useEventTarget } from "./useEvents";
import { Config } from "./config/Config";
declare global {
interface Window {
@@ -359,7 +352,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
);
};
type InitResult = {
export type InitResult = {
widgetApi: WidgetApi | null;
client: MatrixClient;
passwordlessUser: boolean;
@@ -376,50 +369,8 @@ async function loadClient(): Promise<InitResult | null> {
passwordlessUser: false,
};
} else {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) {
logger.log("No session stored; continuing without a client");
return null;
}
logger.log("Using a standalone client");
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } = session;
const initClientParams: ICreateClientOpts = {
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
fallbackICEServerAllowed: fallbackICEServerAllowed,
livekitServiceURL: Config.get().livekit?.livekit_service_url,
};
try {
const client = await initClient(initClientParams, true);
return {
widgetApi: null,
client,
passwordlessUser,
};
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
// We can't use this session anymore, so let's log it out
logger.log(
"The session from local store is invalid; continuing without a client",
);
clearSession();
// returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line)
return null;
}
throw err;
}
} catch (err) {
clearSession();
throw err;
}
const { initSPA } = await import("./utils/spa");
return initSPA(loadSession, clearSession);
}
}

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
.modal {
--inset-inline: 520px;
display: flex;
flex-direction: column;
}
@@ -35,7 +36,7 @@ Please see LICENSE in the repository root for full details.
.drawer {
background: var(--cpd-color-bg-canvas-default);
inset-block-end: 0;
inset-inline: max(0px, calc((100% - 520px) / 2));
inset-inline: max(0px, calc((100% - var(--inset-inline)) / 2));
max-block-size: 90%;
border-start-start-radius: var(--border-radius);
border-start-end-radius: var(--border-radius);

View File

@@ -42,8 +42,9 @@ Please see LICENSE in the repository root for full details.
}
.overlay.animate {
--overlay-top: 50%;
left: 50%;
top: 50%;
top: var(--overlay-top);
transform: translate(-50%, -50%);
}

View File

@@ -3,78 +3,99 @@
}
.reactionPopupMenu {
--reaction-button-padding: 10px;
--reaction-button-fontsize: 20px;
--reaction-button-gap: var(--cpd-separator-spacing);
display: flex;
width: fit-content;
}
.reactionPopupMenuModal {
width: fit-content !important;
top: 82vh !important;
@media (max-width: 420px) {
.reactionPopupMenu {
--reaction-button-padding: 8px;
--reaction-button-fontsize: 16px;
--reaction-button-gap: 6px;
}
}
.reactionPopupMenuModal > div > div {
padding-inline: var(--cpd-space-6x) !important;
padding-block: var(--cpd-space-6x) var(--cpd-space-8x) !important;
div.reactionPopupMenuRoot.reactionPopupMenuModal {
--overlay-top: 82vh;
width: fit-content;
}
.reactionPopupMenu menu {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--cpd-separator-spacing);
div.reactionPopupMenuRoot {
/* Center the drawer */
--inset-inline: 30em;
}
.reactionPopupMenuRoot > div {
width: fit-content;
max-width: 100vw;
}
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
padding-inline: var(--cpd-space-6x);
padding-block: var(--cpd-space-6x);
}
.reactionPopupMenu section {
height: fit-content;
margin-top: auto;
margin-bottom: auto;
flex: 1;
max-width: fit-content;
}
.reactionPopupMenuItem {
list-style: none;
.reactionPopupMenu section.reactionsMenuSection {
margin: auto 0;
flex: auto;
}
.reactionsMenu {
min-height: 3em;
margin: 0;
padding: 0;
flex-grow: 1;
gap: var(--reaction-button-gap);
/* Height of 3 rows plus padding. */
max-height: calc(
((var(--reaction-button-fontsize) + var(--cpd-separator-spacing)) * 2) * 3
);
max-width: calc(
((var(--reaction-button-fontsize) + var(--cpd-separator-spacing)) * 2) * 5
);
overflow-x: hidden;
overflow-y: auto;
list-style: none;
flex-wrap: wrap;
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: start;
align-items: auto;
align-content: start;
width: fit-content;
}
.reactionsMenu > * {
flex: 0 0 auto;
}
.reactionButton {
padding: 1em;
font-size: 1.6em;
width: 1.4em;
height: 1.4em;
padding: var(--reaction-button-padding);
border-radius: var(--cpd-radius-pill-effect);
}
@media (max-width: 800px) {
.reactionButton {
padding: 1em;
font-size: 1em;
width: 1em;
height: 1em;
min-block-size: unset;
}
font-size: var(--reaction-button-fontsize);
min-block-size: unset;
border: none;
aspect-ratio: 1 / 1;
height: 100%;
}
.verticalSeperator {
background-color: var(--cpd-color-gray-800);
width: 1px;
width: 2px;
height: auto;
margin-left: var(--cpd-separator-spacing);
margin-right: var(--cpd-separator-spacing);
}
.searchForm {
display: flex;
flex-direction: row;
gap: var(--cpd-separator-spacing);
margin-bottom: var(--cpd-space-3x);
}
.searchForm > label {
flex: auto;
}
.alert {
margin-bottom: var(--cpd-space-3x);
animation: grow-in 200ms;

View File

@@ -5,10 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { fireEvent, render } from "@testing-library/react";
import { act } from "react";
import { render } from "@testing-library/react";
import { expect, test } from "vitest";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { ReactNode } from "react";
@@ -30,18 +28,13 @@ const membership: Record<string, string> = {
function TestComponent({
rtcSession,
room,
}: {
rtcSession: MockRTCSession;
room: MockRoom;
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionToggleButton
rtcSession={rtcSession as unknown as MatrixRTCSession}
client={room.client}
/>
<ReactionToggleButton userId={memberUserIdAlice} />
</TestReactionsWrapper>
</TooltipProvider>
);
@@ -52,9 +45,9 @@ test("Can open menu", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
expect(container).toMatchSnapshot();
});
@@ -63,9 +56,9 @@ test("Can raise hand", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.raise_hand"));
expect(room.testSentEvents).toEqual([
[
@@ -88,10 +81,10 @@ test("Can lower hand", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.lower_hand"));
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
expect(container).toMatchSnapshot();
@@ -102,9 +95,9 @@ test("Can react with emoji", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("common.reactions"));
await user.click(getByText("🐶"));
expect(room.testSentEvents).toEqual([
[
@@ -122,17 +115,15 @@ test("Can react with emoji", async () => {
]);
});
test("Can search for and send emoji", async () => {
test("Can fully expand emoji picker", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByText, container, getByLabelText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
// Search should autofocus.
await user.keyboard("crickets");
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more"));
expect(container).toMatchSnapshot();
await user.click(getByText("🦗"));
@@ -152,63 +143,15 @@ test("Can search for and send emoji", async () => {
]);
});
test("Can search for and send emoji with the keyboard", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByPlaceholderText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
const searchField = getByPlaceholderText("reaction_search");
// Search should autofocus.
await user.keyboard("crickets");
expect(container).toMatchSnapshot();
act(() => {
fireEvent.keyDown(searchField, { key: "Enter" });
});
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
},
],
]);
});
test("Can close search", async () => {
test("Can close reaction dialog", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
await user.click(getByLabelText("action.close_search"));
expect(container).toMatchSnapshot();
});
test("Can close search with the escape key", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container, getByPlaceholderText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
const searchField = getByPlaceholderText("reaction_search");
act(() => {
fireEvent.keyDown(searchField, { key: "Escape" });
});
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more"));
await user.click(getByLabelText("action.show_less"));
expect(container).toMatchSnapshot();
});

View File

@@ -5,24 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { Button as CpdButton, Tooltip, Alert } from "@vector-im/compound-web";
import {
Button as CpdButton,
Tooltip,
Search,
Form,
Alert,
} from "@vector-im/compound-web";
import {
SearchIcon,
CloseIcon,
RaisedHandSolidIcon,
ReactionIcon,
ChevronDownIcon,
ChevronUpIcon,
ReactionSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ChangeEventHandler,
ComponentPropsWithoutRef,
FC,
KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
@@ -31,19 +23,11 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import styles from "./ReactionToggleButton.module.css";
import {
ReactionOption,
ReactionSet,
ElementCallReactionEventType,
} from "../reactions";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
import { Modal } from "../Modal";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
@@ -55,15 +39,14 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
<Tooltip label={t("common.reactions")}>
<CpdButton
className={classNames(raised && styles.raisedButton)}
aria-expanded={open}
aria-haspopup
aria-label={t("action.raise_hand_or_send_reaction")}
kind={raised || open ? "primary" : "secondary"}
iconOnly
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
Icon={raised ? RaisedHandSolidIcon : ReactionSolidIcon}
{...props}
/>
</Tooltip>
@@ -84,43 +67,11 @@ export function ReactionPopupMenu({
canReact: boolean;
}): ReactNode {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [isSearching, setIsSearching] = useState(false);
const onSearch = useCallback<ChangeEventHandler<HTMLInputElement>>((ev) => {
ev.preventDefault();
setSearchText(ev.target.value.trim().toLocaleLowerCase());
}, []);
const [isFullyExpanded, setExpanded] = useState(false);
const filteredReactionSet = useMemo(
() =>
ReactionSet.filter(
(reaction) =>
!isSearching ||
(!!searchText &&
(reaction.name.startsWith(searchText) ||
reaction.alias?.some((a) => a.startsWith(searchText)))),
).slice(0, 6),
[searchText, isSearching],
);
const onSearchKeyDown = useCallback<KeyboardEventHandler<never>>(
(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
if (!canReact) {
return;
}
if (filteredReactionSet.length !== 1) {
return;
}
sendReaction(filteredReactionSet[0]);
setIsSearching(false);
} else if (ev.key === "Escape") {
ev.preventDefault();
setIsSearching(false);
}
},
[sendReaction, filteredReactionSet, canReact, setIsSearching],
() => (isFullyExpanded ? ReactionSet : ReactionSet.slice(0, 5)),
[isFullyExpanded],
);
const label = isHandRaised ? t("action.lower_hand") : t("action.raise_hand");
return (
@@ -136,9 +87,10 @@ export function ReactionPopupMenu({
)}
<div className={styles.reactionPopupMenu}>
<section className={styles.handRaiseSection}>
<Tooltip label={label}>
<Tooltip label={label} caption="H">
<CpdButton
kind={isHandRaised ? "primary" : "secondary"}
aria-keyshortcuts="H"
aria-pressed={isHandRaised}
aria-label={label}
onClick={() => toggleRaisedHand()}
@@ -148,41 +100,33 @@ export function ReactionPopupMenu({
</Tooltip>
</section>
<div className={styles.verticalSeperator} />
<section>
{isSearching ? (
<>
<Form.Root className={styles.searchForm}>
<Search
required
value={searchText}
name="reactionSearch"
placeholder={t("reaction_search")}
onChange={onSearch}
onKeyDown={onSearchKeyDown}
// This is a reasonable use of autofocus, we are focusing when
// the search button is clicked (which matches the Element Web reaction picker)
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<CpdButton
Icon={CloseIcon}
aria-label={t("action.close_search")}
size="sm"
kind="destructive"
onClick={() => setIsSearching(false)}
/>
</Form.Root>
</>
) : null}
<menu className={styles.reactionsMenu}>
{filteredReactionSet.map((reaction) => (
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
<Tooltip label={reaction.name}>
<section className={styles.reactionsMenuSection}>
<menu
className={classNames(
isFullyExpanded && styles.reactionsMenuExpanded,
styles.reactionsMenu,
)}
>
{filteredReactionSet.map((reaction, index) => (
<li key={reaction.name}>
<Tooltip
label={reaction.name}
caption={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
>
<CpdButton
kind="secondary"
className={styles.reactionButton}
disabled={!canReact}
onClick={() => sendReaction(reaction)}
aria-keyshortcuts={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
>
{reaction.emoji}
</CpdButton>
@@ -191,73 +135,56 @@ export function ReactionPopupMenu({
))}
</menu>
</section>
{!isSearching ? (
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
<li key="search" className={styles.reactionPopupMenuItem}>
<Tooltip label={t("common.search")}>
<CpdButton
iconOnly
aria-label={t("action.open_search")}
Icon={SearchIcon}
kind="tertiary"
onClick={() => setIsSearching(true)}
/>
</Tooltip>
</li>
</section>
) : null}
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
<Tooltip
label={
isFullyExpanded ? t("action.show_less") : t("action.show_more")
}
>
<CpdButton
iconOnly
aria-label={
isFullyExpanded ? t("action.show_less") : t("action.show_more")
}
Icon={isFullyExpanded ? ChevronUpIcon : ChevronDownIcon}
kind="tertiary"
onClick={() => setExpanded(!isFullyExpanded)}
/>
</Tooltip>
</section>
</div>
</>
);
}
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
rtcSession: MatrixRTCSession;
client: MatrixClient;
userId: string;
}
export function ReactionToggleButton({
client,
rtcSession,
userId,
...props
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
const { raisedHands, lowerHand, reactions } = useReactions();
const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
useReactions();
const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!raisedHands[userId];
const canReact = !reactions[userId];
useEffect(() => {
// Clear whenever the reactions menu state changes.
setErrorText(undefined);
}, [showReactionsMenu]);
const canReact = !reactions[userId];
const sendRelation = useCallback(
async (reaction: ReactionOption) => {
try {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
throw new Error("Cannot find own membership event");
}
const parentEventId = myMembership.eventId;
setBusy(true);
await client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: parentEventId,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
await sendReaction(reaction);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
@@ -267,59 +194,25 @@ export function ReactionToggleButton({
setBusy(false);
}
},
[memberships, client, userId, rtcSession],
[sendReaction],
);
const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => {
if (isHandRaised) {
try {
setBusy(true);
await lowerHand();
setShowReactionsMenu(false);
} finally {
setBusy(false);
}
} else {
try {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
throw new Error("Cannot find own membership event");
}
const parentEventId = myMembership.eventId;
setBusy(true);
const reaction = await client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: parentEventId,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to raise hand", ex);
} finally {
setBusy(false);
}
const wrappedToggleRaisedHand = useCallback(() => {
const toggleHand = async (): Promise<void> => {
try {
setBusy(true);
await toggleRaisedHand();
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to raise/lower hand", ex);
} finally {
setBusy(false);
}
};
void raiseHand();
}, [
client,
isHandRaised,
memberships,
lowerHand,
rtcSession.room.roomId,
userId,
]);
void toggleHand();
}, [toggleRaisedHand]);
return (
<>
@@ -335,14 +228,15 @@ export function ReactionToggleButton({
title={t("action.pick_reaction")}
hideHeader
classNameModal={styles.reactionPopupMenuModal}
className={styles.reactionPopupMenuRoot}
onDismiss={() => setShowReactionsMenu(false)}
>
<ReactionPopupMenu
errorText={errorText}
isHandRaised={isHandRaised}
canReact={canReact}
canReact={!busy && canReact}
sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand}
toggleRaisedHand={wrappedToggleRaisedHand}
/>
</Modal>
</>

View File

@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Can close search 1`] = `
exports[`Can close reaction dialog 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
@@ -9,8 +9,7 @@ exports[`Can close search 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":rec:"
aria-labelledby=":r9l:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -26,26 +25,27 @@ exports[`Can close search 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
`;
exports[`Can close search with the escape key 1`] = `
<div>
exports[`Can fully expand emoji picker 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":rhh:"
aria-labelledby=":r6c:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
@@ -59,10 +59,9 @@ exports[`Can close search with the escape key 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>
@@ -75,8 +74,7 @@ exports[`Can lower hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r3i:"
aria-labelledby=":r36:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -108,7 +106,6 @@ exports[`Can open menu 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r0:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
@@ -125,10 +122,9 @@ exports[`Can open menu 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>
@@ -141,8 +137,7 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r1p:"
aria-labelledby=":r1j:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-size="lg"
@@ -158,82 +153,9 @@ exports[`Can raise hand 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can search for and send emoji 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r74:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can search for and send emoji with the keyboard 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":ra3:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/>
</svg>
</button>

View File

@@ -28,11 +28,11 @@ export class Config {
const internalInstance = new Config();
Config.internalInstance = internalInstance;
Config.internalInstance.initPromise = downloadConfig(
"../config.json",
).then((config) => {
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
});
Config.internalInstance.initPromise = downloadConfig("/config.json").then(
(config) => {
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
},
);
}
return Config.internalInstance.initPromise;
}
@@ -74,11 +74,7 @@ async function downloadConfig(
configJsonFilename: string,
): Promise<ConfigOptions> {
const url = new URL(configJsonFilename, window.location.href);
url.searchParams.set("cachebuster", Date.now().toString());
const res = await fetch(url, {
cache: "no-cache",
method: "GET",
});
const res = await fetch(url);
if (!res.ok || res.status === 404 || res.status === 0) {
// Lack of a config isn't an error, we should just use the defaults.

View File

@@ -9,9 +9,9 @@ import { expect, test } from "vitest";
import { Initializer } from "../src/initializer";
test("initBeforeReact sets font family from URL param", () => {
test("initBeforeReact sets font family from URL param", async () => {
window.location.hash = "#?font=DejaVu Sans";
Initializer.initBeforeReact();
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-family",
@@ -19,9 +19,9 @@ test("initBeforeReact sets font family from URL param", () => {
).toBe('"DejaVu Sans"');
});
test("initBeforeReact sets font scale from URL param", () => {
test("initBeforeReact sets font scale from URL param", async () => {
window.location.hash = "#?fontScale=1.2";
Initializer.initBeforeReact();
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue("--font-scale"),
).toBe("1.2");

View File

@@ -5,18 +5,85 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import i18n from "i18next";
import i18n, {
type BackendModule,
type ReadCallback,
type ResourceKey,
} from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/src/logger";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { ElementCallOpenTelemetry } from "./otel/otel";
import { platform } from "./Platform";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// {
// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json",
// ...
// }
const locales = import.meta.glob<string>("../locales/*/*.json", {
query: "?url",
import: "default",
eager: true,
});
const getLocaleUrl = (
language: string,
namespace: string,
): string | undefined => locales[`../locales/${language}/${namespace}.json`];
const supportedLngs = [
...new Set(
Object.keys(locales).map((url) => {
// The URLs are of the form ../locales/en-GB/app.json
// This extracts the language code from the URL
const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
if (!lang) {
throw new Error(`Could not parse locale URL ${url}`);
}
return lang;
}),
),
];
// A backend that fetches the locale files from the URLs generated by the glob above
const Backend = {
type: "backend",
init(): void {},
read(language: string, namespace: string, callback: ReadCallback): void {
(async (): Promise<ResourceKey> => {
const url = getLocaleUrl(language, namespace);
if (!url) {
throw new Error(
`Namespace ${namespace} for locale ${language} not found`,
);
}
const response = await fetch(url, {
credentials: "omit",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw Error(`Failed to fetch ${url}`);
}
return await response.json();
})().then(
(data) => callback(null, data),
(error) => callback(error, null),
);
},
} satisfies BackendModule;
enum LoadState {
None,
Loading,
@@ -41,10 +108,17 @@ export class Initializer {
return Initializer.internalInstance?.isInitialized;
}
public static initBeforeReact(): void {
// this maybe also needs to return a promise in the future,
// if we have to do async inits before showing the loading screen
// but this should be avoided if possible
public static async initBeforeReact(): Promise<void> {
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillSegmenter()) {
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
}
if (shouldPolyfillDurationFormat()) {
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force"));
}
await Promise.all(polyfills);
//i18n
const languageDetector = new LanguageDetector();
@@ -54,7 +128,7 @@ export class Initializer {
lookup: () => getUrlParams().lang ?? undefined,
});
i18n
await i18n
.use(Backend)
.use(languageDetector)
.use(initReactI18next)
@@ -65,6 +139,7 @@ export class Initializer {
nsSeparator: false,
pluralSeparator: "_",
contextSeparator: "|",
supportedLngs,
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
@@ -74,9 +149,6 @@ export class Initializer {
order: ["urlFragment", "navigator"],
caches: [],
},
})
.catch((e) => {
logger.error("Failed to initialize i18n", e);
});
// Custom Themeing

View File

@@ -20,8 +20,6 @@ import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,
} from "livekit-client";
import "@formatjs/intl-segmenter/polyfill";
import "@formatjs/intl-durationformat/polyfill";
import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
@@ -57,12 +55,17 @@ if (fatalError !== null) {
throw fatalError; // Stop the app early
}
Initializer.initBeforeReact();
Initializer.initBeforeReact()
.then(() => {
const history = createBrowserHistory();
const history = createBrowserHistory();
root.render(
<StrictMode>
<App history={history} />
</StrictMode>,
);
root.render(
<StrictMode>
<App history={history} />
</StrictMode>,
);
})
.catch((e) => {
logger.error("Failed to initialize app", e);
root.render(e.message);
});

View File

@@ -11,19 +11,12 @@ import {
useCallback,
useEffect,
useState,
useMemo,
} from "react";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { useTranslation } from "react-i18next";
import { ReactionIndicator } from "./ReactionIndicator";
const durationFormatter = new DurationFormat(undefined, {
minutesDisplay: "always",
secondsDisplay: "always",
hoursDisplay: "auto",
style: "digital",
});
export function RaisedHandIndicator({
raisedHandTime,
miniature,
@@ -38,6 +31,17 @@ export function RaisedHandIndicator({
const { t } = useTranslation();
const [raisedHandDuration, setRaisedHandDuration] = useState("");
const durationFormatter = useMemo(
() =>
new Intl.DurationFormat(undefined, {
minutesDisplay: "always",
secondsDisplay: "always",
hoursDisplay: "auto",
style: "digital",
}),
[],
);
const clickCallback = useCallback<MouseEventHandler<HTMLButtonElement>>(
(event) => {
if (!onClick) {
@@ -69,7 +73,7 @@ export function RaisedHandIndicator({
calculateTime();
const to = setInterval(calculateTime, 1000);
return (): void => clearInterval(to);
}, [setRaisedHandDuration, raisedHandTime, showTimer]);
}, [setRaisedHandDuration, raisedHandTime, showTimer, durationFormatter]);
if (!raisedHandTime) {
return;

View File

@@ -1,19 +1,25 @@
.reactionIndicatorWidget {
display: flex;
background-color: var(--cpd-color-bg-subtle-primary);
/* background-color: var(--cpd-color-bg-subtle-primary); */
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-secondary);
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
background: "ffffff40";
backdrop-filter: blur(10px);
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
outline-offset: calc(-1 * var(--cpd-border-width-1));
}
.reactionIndicatorWidget > p {
padding: none;
margin-top: auto;
margin-bottom: auto;
width: 4em;
width: 3em;
}
.reactionIndicatorWidgetLarge > p {
padding: var(--cpd-space-2x);
padding-right: var(--cpd-space-4x);
padding-left: 0;
}
.reactionLarge {
@@ -25,13 +31,13 @@
.reaction {
margin: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
background-color: var(--cpd-color-icon-secondary);
/* background-color: var(--cpd-color-icon-secondary); */
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
/* box-shadow: var(--small-drop-shadow); */
box-sizing: border-box;
max-inline-size: 100%;
max-width: fit-content;

View File

@@ -73,7 +73,9 @@ export const GenericReaction: ReactionOption = {
},
};
// The first 6 reactions are always visible.
export const ReactionsRowSize = 5;
// The first {ReactionsRowSize} reactions are always visible.
export const ReactionSet: ReactionOption[] = [
{
emoji: "👍",

View File

@@ -178,7 +178,8 @@ export const InCallView: FC<InCallViewProps> = ({
onShareClick,
}) => {
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
const { supportsReactions, raisedHands } = useReactions();
const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
useReactions();
const raisedHandCount = useMemo(
() => Object.keys(raisedHands).length,
[raisedHands],
@@ -222,6 +223,8 @@ export const InCallView: FC<InCallViewProps> = ({
toggleMicrophone,
toggleCamera,
(muted) => muteStates.audio.setEnabled?.(!muted),
(reaction) => void sendReaction(reaction),
() => void toggleRaisedHand(),
);
const windowMode = useObservableEagerState(vm.windowMode);
@@ -567,8 +570,7 @@ export const InCallView: FC<InCallViewProps> = ({
<ReactionToggleButton
key="raise_hand"
className={styles.raiseHand}
client={client}
rtcSession={rtcSession}
userId={client.getUserId()!}
onTouchEnd={onControlsTouchEnd}
/>,
);

View File

@@ -8,7 +8,8 @@ Please see LICENSE in the repository root for full details.
.content {
width: 100%;
max-width: 350px;
align-self: center;
margin-left: auto;
margin-right: auto;
}
.avatarFieldRow {

View File

@@ -97,7 +97,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
},
[vm],
);
const { raisedHands, lowerHand, reactions } = useReactions();
const { raisedHands, toggleRaisedHand, reactions } = useReactions();
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
@@ -127,8 +127,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
const currentReaction: ReactionOption | undefined =
reactions[vm.member?.userId ?? ""];
const raisedHandOnClick =
vm.local && handRaised ? (): void => void lowerHand() : undefined;
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;

View File

@@ -102,7 +102,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)}
</div>
<div className={styles.fg}>
<div style={{ display: "flex" }}>
<div style={{ display: "flex", gap: "var(--cpd-space-1x)" }}>
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}

View File

@@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
// Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`.
// The TestComponent just wraps a button around that hook.
interface TestComponentProps {
setMicrophoneMuted: (muted: boolean) => void;
setMicrophoneMuted?: (muted: boolean) => void;
onButtonClick?: () => void;
sendReaction?: () => void;
toggleHandRaised?: () => void;
}
const TestComponent: FC<TestComponentProps> = ({
setMicrophoneMuted,
setMicrophoneMuted = (): void => {},
onButtonClick = (): void => {},
sendReaction = (reaction: ReactionOption): void => {},
toggleHandRaised = (): void => {},
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
@@ -32,6 +37,8 @@ const TestComponent: FC<TestComponentProps> = ({
() => {},
() => {},
setMicrophoneMuted,
sendReaction,
toggleHandRaised,
);
return (
<div ref={ref}>
@@ -74,6 +81,28 @@ test("spacebar prioritizes pressing a button", async () => {
expect(onClick).toBeCalled();
});
test("reactions can be sent via keyboard presses", async () => {
const user = userEvent.setup();
const sendReaction = vi.fn();
render(<TestComponent sendReaction={sendReaction} />);
for (let index = 1; index <= ReactionsRowSize; index++) {
await user.keyboard(index.toString());
expect(sendReaction).toHaveBeenNthCalledWith(index, ReactionSet[index - 1]);
}
});
test("raised hand can be sent via keyboard presses", async () => {
const user = userEvent.setup();
const toggleHandRaised = vi.fn();
render(<TestComponent toggleHandRaised={toggleHandRaised} />);
await user.keyboard("h");
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { RefObject, useCallback, useMemo, useRef } from "react";
import { useEventTarget } from "./useEvents";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
/**
* Determines whether focus is in the same part of the tree as the given
@@ -18,11 +19,17 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
return focusedElement !== null && focusedElement.contains(e);
};
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
);
export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>,
toggleMicrophoneMuted: () => void,
toggleLocalVideoMuted: () => void,
setMicrophoneMuted: (muted: boolean) => void,
sendReaction: (reaction: ReactionOption) => void,
toggleHandRaised: () => void,
): void {
const spacebarHeld = useRef(false);
@@ -49,6 +56,12 @@ export function useCallViewKeyboardShortcuts(
spacebarHeld.current = true;
setMicrophoneMuted(false);
}
} else if (event.key === "h") {
event.preventDefault();
toggleHandRaised();
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction(KeyToReactionMap[event.key]);
}
},
[
@@ -56,6 +69,8 @@ export function useCallViewKeyboardShortcuts(
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setMicrophoneMuted,
sendReaction,
toggleHandRaised,
],
),
// Because this is set on the window, to prevent shortcuts from activating

View File

@@ -40,7 +40,8 @@ interface ReactionsContextType {
raisedHands: Record<string, Date>;
supportsReactions: boolean;
reactions: Record<string, ReactionOption>;
lowerHand: () => Promise<void>;
toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
}
const ReactionsContext = createContext<ReactionsContextType | undefined>(
@@ -104,7 +105,6 @@ export const ReactionsProvider = ({
),
[raisedHands],
);
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands,
@@ -181,6 +181,11 @@ export const ReactionsProvider = ({
const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
const myMembership = useMemo(
() => memberships.find((m) => m.sender === myUserId)?.eventId,
[memberships, myUserId],
);
// This effect handles any *live* reaction/redactions in the room.
useEffect(() => {
const reactionTimeouts = new Set<number>();
@@ -322,22 +327,67 @@ export const ReactionsProvider = ({
latestRaisedHands,
]);
const lowerHand = useCallback(async () => {
if (!myUserId || !raisedHands[myUserId]) {
const toggleRaisedHand = useCallback(async () => {
if (!myUserId) {
return;
}
const myReactionId = raisedHands[myUserId].reactionEventId;
const myReactionId = raisedHands[myUserId]?.reactionEventId;
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
try {
if (!myMembership) {
throw new Error("Cannot find own membership event");
}
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembership,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
}
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
}
}, [myUserId, raisedHands, rtcSession, room]);
}, [myMembership, myUserId, raisedHands, rtcSession, room]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myUserId || reactions[myUserId]) {
// We're still reacting
return;
}
if (!myMembership) {
throw new Error("Cannot find own membership event");
}
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembership,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
},
[myMembership, reactions, room, myUserId, rtcSession],
);
return (
<ReactionsContext.Provider
@@ -345,7 +395,8 @@ export const ReactionsProvider = ({
raisedHands: resultRaisedHands,
supportsReactions,
reactions,
lowerHand,
toggleRaisedHand,
sendReaction,
}}
>
{children}

64
src/utils/spa.ts Normal file
View File

@@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ICreateClientOpts } from "matrix-js-sdk/src/client";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { logger } from "matrix-js-sdk/src/logger";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed, initClient } from "./matrix";
import type { InitResult, Session } from "../ClientContext";
export async function initSPA(
loadSession: () => Session | undefined,
clearSession: () => void,
): Promise<InitResult | null> {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) {
logger.log("No session stored; continuing without a client");
return null;
}
logger.log("Using a standalone client");
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } = session;
const initClientParams: ICreateClientOpts = {
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
fallbackICEServerAllowed,
livekitServiceURL: Config.get().livekit?.livekit_service_url,
};
try {
const client = await initClient(initClientParams, true);
return {
widgetApi: null,
client,
passwordlessUser,
};
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
// We can't use this session anymore, so let's log it out
logger.log(
"The session from local store is invalid; continuing without a client",
);
clearSession();
// returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line)
return null;
}
throw err;
}
} catch (err) {
clearSession();
throw err;
}
}

View File

@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
*/
import "global-jsdom/register";
import "@formatjs/intl-durationformat/polyfill";
import "@formatjs/intl-segmenter/polyfill";
import i18n from "i18next";
import posthog from "posthog-js";
import { initReactI18next } from "react-i18next";
@@ -14,6 +16,7 @@ import { cleanup } from "@testing-library/react";
import "vitest-axe/extend-expect";
import { logger } from "matrix-js-sdk/src/logger";
import EN_GB from "../locales/en-GB/app.json";
import { Config } from "./config/Config";
// Bare-minimum i18n config
@@ -22,6 +25,13 @@ i18n
.init({
lng: "en-GB",
fallbackLng: "en-GB",
supportedLngs: ["en-GB"],
// We embed the translations, so that it never needs to fetch
resources: {
"en-GB": {
app: EN_GB,
},
},
interpolation: {
escapeValue: false, // React has built-in XSS protections
},