Merge branch 'livekit' into johannes/qr

This commit is contained in:
Johannes Marbach 2024-09-02 08:40:15 +02:00
commit 5b09a5ebd8
91 changed files with 1061 additions and 3463 deletions

View File

@ -23,3 +23,5 @@ jobs:
run: "yarn run lint:eslint"
- name: Type check
run: "yarn run lint:types"
- name: Dead code analysis
run: "yarn run lint:knip"

View File

@ -21,6 +21,8 @@ jobs:
run: "yarn run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: unittests
fail_ci_if_error: true

View File

@ -141,6 +141,10 @@ Run backend components:
yarn backend
```
### Test Coverage
<img src="https://codecov.io/github/element-hq/element-call/graphs/tree.svg?token=O6CFVKK6I1"></img>
### Add a new translation key
To add a new translation key you can do these steps:

30
knip.ts Normal file
View File

@ -0,0 +1,30 @@
import { KnipConfig } from "knip";
export default {
entry: ["src/main.tsx", "i18next-parser.config.ts"],
ignoreBinaries: [
// This is deprecated, so Knip doesn't actually recognize it as a globally
// installed binary. TODO We should switch to Compose v2:
// https://docs.docker.com/compose/migrate/
"docker-compose",
],
ignoreDependencies: [
// Used in CSS
"normalize.css",
// Used for its global type declarations
"@types/grecaptcha",
// Because we use matrix-js-sdk as a Git dependency rather than consuming
// the proper release artifacts, and also import directly from src/, we're
// forced to re-install some of the types that it depends on even though
// these look unused to Knip
"@types/content-type",
"@types/sdp-transform",
"@types/uuid",
// We obviously use this, but if the package has been linked with yarn link,
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
"matrix-widget-api",
],
ignoreExportsUsedInFile: true,
} satisfies KnipConfig;

View File

@ -8,107 +8,60 @@
"serve": "vite preview",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
"lint": "yarn lint:types && yarn lint:eslint",
"lint": "yarn lint:types && yarn lint:eslint && yarn lint:knip",
"lint:eslint": "eslint --max-warnings 0 src",
"lint:eslint-fix": "eslint --max-warnings 0 src --fix",
"lint:knip": "knip",
"lint:types": "tsc",
"i18n": "node_modules/i18next-parser/bin/cli.js",
"i18n:check": "node_modules/i18next-parser/bin/cli.js --fail-on-warnings --fail-on-update",
"i18n": "i18next",
"i18n:check": "i18next --fail-on-warnings --fail-on-update",
"test": "vitest",
"test:coverage": "vitest --coverage",
"backend": "docker-compose -f backend-docker-compose.yml up"
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@livekit/components-core": "^0.11.0",
"@livekit/components-react": "^2.0.0",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/context-zone": "^1.9.1",
"@opentelemetry/exporter-jaeger": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
"@opentelemetry/instrumentation-document-load": "^0.39.0",
"@opentelemetry/instrumentation-user-interaction": "^0.39.0",
"@opentelemetry/sdk-trace-web": "^1.9.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-aria/button": "^3.3.4",
"@react-aria/focus": "^3.5.0",
"@react-aria/menu": "^3.3.0",
"@react-aria/overlays": "^3.7.3",
"@react-aria/select": "^3.6.0",
"@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0",
"@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4",
"@react-stately/select": "^3.1.3",
"@react-stately/tooltip": "^3.0.5",
"@react-stately/tree": "^3.2.0",
"@sentry/react": "^8.0.0",
"@sentry/tracing": "^7.0.0",
"@types/lodash": "^4.14.199",
"@types/qrcode": "^1.5.5",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.0.0",
"@vector-im/compound-web": "^6.0.0",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1",
"buffer": "^6.0.3",
"classnames": "^2.3.1",
"events": "^3.3.0",
"i18next": "^23.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.0.0",
"livekit-client": "^2.0.2",
"lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#467908703bc67fa3e23d978f5549e2709d4acf74",
"matrix-widget-api": "^1.8.2",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
"postcss-preset-env": "^10.0.0",
"posthog-js": "^1.29.0",
"qrcode": "^1.5.4",
"react": "18",
"react-dom": "18",
"react-i18next": "^15.0.0",
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sdp-transform": "^2.14.1",
"tinyqueue": "^3.0.0",
"unique-names-generator": "^4.6.0",
"uuid": "10",
"vaul": "^0.9.0"
},
"devDependencies": {
"@babel/core": "^7.16.5",
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@react-spring/rafz": "^9.7.3",
"@react-types/dialog": "^3.5.5",
"@juggle/resize-observer": "^3.3.1",
"@livekit/components-core": "^0.11.0",
"@livekit/components-react": "^2.0.0",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^1.25.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@opentelemetry/sdk-trace-web": "^1.9.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^9.4.4",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^2.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5",
"@types/dom-screen-wake-lock": "^1.0.1",
"@types/dompurify": "^3.0.2",
"@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7",
"@types/lodash": "^4.14.199",
"@types/node": "^20.0.0",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
"@types/request": "^2.48.8",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "10",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.0.0",
"@vector-im/compound-web": "^6.0.0",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^2.0.5",
"babel-loader": "^9.0.0",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
@ -120,12 +73,38 @@
"eslint-plugin-react-hooks": "^4.5.0",
"eslint-plugin-unicorn": "^55.0.0",
"global-jsdom": "^24.0.0",
"history": "^4.0.0",
"i18next": "^23.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.0.0",
"i18next-parser": "^9.0.0",
"jsdom": "^25.0.0",
"knip": "^5.27.2",
"livekit-client": "^2.0.2",
"lodash": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "^v34.4.0",
"matrix-widget-api": "^1.8.2",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "^1.29.0",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "18",
"react-dom": "18",
"react-i18next": "^15.0.0",
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
"typescript": "^5.1.6",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"vaul": "^0.9.0",
"vite": "^5.0.0",
"vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^4.0.0",

View File

@ -4,8 +4,8 @@
},
"action": {
"close": "Close",
"copy": "Copy",
"copy_link": "Copy link",
"edit": "Edit",
"go": "Go",
"invite": "Invite",
"no": "No",
@ -13,7 +13,8 @@
"remove": "Remove",
"sign_in": "Sign in",
"sign_out": "Sign out",
"submit": "Submit"
"submit": "Submit",
"upload_file": "Upload file"
},
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.",
"app_selection_modal": {
@ -43,7 +44,6 @@
"avatar": "Avatar",
"back": "Back",
"camera": "Camera",
"copied": "Copied!",
"display_name": "Display name",
"encrypted": "Encrypted",
"error": "Error",
@ -131,7 +131,6 @@
"room_auth_view_eula_caption": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"room_auth_view_join_button": "Join call now",
"screenshare_button_label": "Share screen",
"select_input_unset_button": "Select an option",
"settings": {
"developer_settings_label": "Developer Settings",
"developer_settings_label_description": "Expose developer settings in the settings window.",

View File

@ -44,20 +44,5 @@
"prHeader": "Please review modals on mobile for visual regressions."
}
],
"semanticCommits": "disabled",
"ignoreDeps": [
"@react-aria/button",
"@react-aria/focus",
"@react-aria/menu",
"@react-aria/overlays",
"@react-aria/select",
"@react-aria/tabs",
"@react-aria/tooltip",
"@react-aria/utils",
"@react-stately/collections",
"@react-stately/select",
"@react-stately/tooltip",
"@react-stately/tree",
"@react-types/dialog"
]
"semanticCommits": "disabled"
}

View File

@ -22,7 +22,6 @@ import {
useLocation,
} from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { History } from "history";
import { TooltipProvider } from "@vector-im/compound-web";
@ -92,23 +91,21 @@ export const App: FC<AppProps> = ({ history }) => {
<ClientProvider>
<MediaDevicesProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<DisconnectedBanner />
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="*">
<RoomPage />
</SentryRoute>
</Switch>
</OverlayProvider>
<DisconnectedBanner />
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="*">
<RoomPage />
</SentryRoute>
</Switch>
</Sentry.ErrorBoundary>
</MediaDevicesProvider>
</ClientProvider>

View File

@ -1,22 +0,0 @@
/*
Copyright 2023 New Vector Ltd
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.
*/
.banner {
flex: 1;
border-radius: 8px;
padding: 16px;
background-color: var(--cpd-color-bg-subtle-primary);
}

View File

@ -1,27 +0,0 @@
/*
Copyright 2023 New Vector Ltd
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 { FC, ReactNode } from "react";
import styles from "./Banner.module.css";
interface Props {
children: ReactNode;
}
export const Banner: FC<Props> = ({ children }) => {
return <div className={styles.banner}>{children}</div>;
};

View File

@ -33,13 +33,10 @@ import {
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 { ErrorView } from "./FullScreenView";
import {
CryptoStoreIntegrityError,
fallbackICEServerAllowed,
initClient,
} from "./utils/matrix";
import { fallbackICEServerAllowed, initClient } from "./utils/matrix";
import { widget } from "./widget";
import {
PosthogAnalytics,
@ -380,16 +377,15 @@ async function loadClient(): Promise<InitResult | null> {
passwordlessUser,
};
} catch (err) {
if (err instanceof CryptoStoreIntegrityError) {
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
// We can't use this session anymore, so let's log it out
try {
const client = await initClient(initClientParams, false); // Don't need the crypto store just to log out)
await client.logout(true);
} catch (err) {
logger.warn(
"The previous session was lost, and we couldn't log it out, " +
err +
"either",
"The previous session was unable to login, and we couldn't log it out: " +
err,
);
}
}

View File

@ -20,9 +20,10 @@ import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/src/logger";
import { Button } from "@vector-im/compound-web";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import { LinkButton, Button } from "./button";
import { LinkButton } from "./button";
import styles from "./FullScreenView.module.css";
import { TranslatedError } from "./TranslatedError";
import { Config } from "./config/Config";
@ -81,21 +82,11 @@ export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
<RageshakeButton description={`***Error View***: ${error.message}`} />
{!confineToRoom &&
(location.pathname === "/" ? (
<Button
size="lg"
variant="default"
className={styles.homeLink}
onPress={onReload}
>
<Button className={styles.homeLink} onClick={onReload}>
{t("return_home_button")}
</Button>
) : (
<LinkButton
size="lg"
variant="default"
className={styles.homeLink}
to="/"
>
<LinkButton className={styles.homeLink} to="/">
{t("return_home_button")}
</LinkButton>
))}
@ -122,12 +113,7 @@ export const CrashView: FC = () => {
)}
<RageshakeButton description="***Soft Crash***" />
<Button
size="lg"
variant="default"
className={styles.wideButton}
onPress={onReload}
>
<Button className={styles.wideButton} onClick={onReload}>
{t("return_home_button")}
</Button>
</FullScreenView>

View File

@ -1,49 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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.
*/
.listBox {
margin: 0;
padding: 0;
max-height: 150px;
overflow-y: auto;
list-style: none;
background-color: transparent;
border: 1px solid var(--cpd-color-border-interactive-secondary);
background-color: var(--cpd-color-bg-canvas-default);
border-radius: 8px;
}
.option {
display: flex;
align-items: center;
justify-content: space-between;
background-color: transparent;
color: var(--cpd-color-text-primary);
padding: 8px 16px;
outline: none;
cursor: pointer;
font-size: var(--font-size-body);
min-height: 32px;
}
.option.focused {
background-color: rgba(111, 120, 130, 0.2);
}
.option.disabled {
color: var(--cpd-color-text-disabled);
background-color: var(--stopgap-bgColor3);
}

View File

@ -1,116 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 {
MutableRefObject,
PointerEvent,
ReactNode,
useCallback,
useRef,
} from "react";
import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox";
import { ListState } from "@react-stately/list";
import { Node } from "@react-types/shared";
import classNames from "classnames";
import styles from "./ListBox.module.css";
interface ListBoxProps<T> extends AriaListBoxOptions<T> {
optionClassName: string;
state: ListState<T>;
className?: string;
listBoxRef?: MutableRefObject<HTMLUListElement>;
}
export function ListBox<T>({
state,
optionClassName,
className,
listBoxRef,
...rest
}: ListBoxProps<T>): ReactNode {
const ref = useRef<HTMLUListElement>(null);
const listRef = listBoxRef ?? ref;
const { listBoxProps } = useListBox(rest, state, listRef);
return (
<ul
{...listBoxProps}
ref={listRef}
className={classNames(styles.listBox, className)}
>
{[...state.collection].map((item) => (
<Option
key={item.key}
item={item}
state={state}
className={optionClassName}
/>
))}
</ul>
);
}
interface OptionProps<T> {
className: string;
state: ListState<T>;
item: Node<T>;
}
function Option<T>({ item, state, className }: OptionProps<T>): ReactNode {
const ref = useRef(null);
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
{ key: item.key },
state,
ref,
);
// Hack: remove the onPointerUp event handler and re-wire it to
// onClick. Chrome Android triggers a click event after the onpointerup
// event which leaks through to elements underneath the z-indexed select
// popover. preventDefault / stopPropagation don't have any effect, even
// adding just a dummy onClick handler still doesn't work, but it's fine
// if we handle just onClick.
// https://github.com/vector-im/element-call/issues/762
const origPointerUp = optionProps.onPointerUp;
delete optionProps.onPointerUp;
optionProps.onClick = useCallback(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
origPointerUp(e as unknown as PointerEvent<HTMLElement>);
},
[origPointerUp],
);
return (
<li
{...optionProps}
ref={ref}
className={classNames(styles.option, className, {
[styles.selected]: isSelected,
[styles.focused]: isFocused,
[styles.disables]: isDisabled,
})}
>
{item.rendered}
</li>
);
}

View File

@ -1,73 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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.
*/
.menu {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
}
.menuItem {
cursor: pointer;
height: 48px;
display: flex;
align-items: center;
padding: 0 12px;
color: var(--cpd-color-text-primary);
font-size: var(--font-size-body);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.menuItem > * {
margin: 0 10px 0 0;
}
.menuItem > :last-child {
margin-right: 0;
}
.menuItem.focused,
.menuItem:hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.menuItem:active {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}
.menuItem.focused:first-child,
.menuItem:hover:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.menuItem.focused:last-child,
.menuItem:hover:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.checkIcon {
position: absolute;
right: 16px;
}
.checkIcon * {
stroke: var(--cpd-color-text-primary);
}

View File

@ -1,102 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 { Key, ReactNode, useRef, useState } from "react";
import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu";
import { TreeState, useTreeState } from "@react-stately/tree";
import { mergeProps } from "@react-aria/utils";
import { useFocus } from "@react-aria/interactions";
import classNames from "classnames";
import { Node } from "@react-types/shared";
import styles from "./Menu.module.css";
interface MenuProps<T> extends AriaMenuOptions<T> {
className?: string;
onClose: () => void;
onAction: (value: Key) => void;
label?: string;
}
export function Menu<T extends object>({
className,
onAction,
onClose,
label,
...rest
}: MenuProps<T>): ReactNode {
const state = useTreeState<T>({ ...rest, selectionMode: "none" });
const menuRef = useRef(null);
const { menuProps } = useMenu<T>(rest, state, menuRef);
return (
<ul
{...mergeProps(menuProps, rest)}
ref={menuRef}
className={classNames(styles.menu, className)}
>
{[...state.collection].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
onAction={onAction}
onClose={onClose}
/>
))}
</ul>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
onAction: (value: Key) => void;
onClose: () => void;
}
function MenuItem<T>({
item,
state,
onAction,
onClose,
}: MenuItemProps<T>): ReactNode {
const ref = useRef(null);
const { menuItemProps } = useMenuItem(
{
key: item.key,
onAction,
onClose,
},
state,
ref,
);
const [isFocused, setFocused] = useState(false);
const { focusProps } = useFocus({ onFocusChange: setFocused });
return (
<li
{...mergeProps(menuItemProps, focusProps)}
ref={ref}
className={classNames(styles.menuItem, {
[styles.focused]: isFocused,
})}
>
{item.rendered}
</li>
);
}

View File

@ -134,6 +134,10 @@ body[data-platform="ios"] .drawer {
padding-block: var(--cpd-space-9x) var(--cpd-space-10x);
}
.modal.tabbed .body {
padding-block-start: 0;
}
.handle {
content: "";
position: absolute;

View File

@ -15,7 +15,6 @@ limitations under the License.
*/
import { FC, ReactNode, useCallback } from "react";
import { AriaDialogProps } from "@react-types/dialog";
import { useTranslation } from "react-i18next";
import {
Root as DialogRoot,
@ -35,8 +34,7 @@ import styles from "./Modal.module.css";
import overlayStyles from "./Overlay.module.css";
import { useMediaQuery } from "./useMediaQuery";
// TODO: Support tabs
export interface Props extends AriaDialogProps {
export interface Props {
title: string;
children: ReactNode;
className?: string;
@ -52,6 +50,11 @@ export interface Props extends AriaDialogProps {
* will be non-dismissable.
*/
onDismiss?: () => void;
/**
* Whether the modal content has tabs.
*/
// TODO: Better tabs support
tabbed?: boolean;
}
/**
@ -64,6 +67,7 @@ export const Modal: FC<Props> = ({
className,
open,
onDismiss,
tabbed,
...rest
}) => {
const { t } = useTranslation();
@ -92,6 +96,7 @@ export const Modal: FC<Props> = ({
overlayStyles.overlay,
styles.modal,
styles.drawer,
{ [styles.tabbed]: tabbed },
)}
{...rest}
>
@ -123,6 +128,7 @@ export const Modal: FC<Props> = ({
overlayStyles.animate,
styles.modal,
styles.dialog,
{ [styles.tabbed]: tabbed },
)}
>
<div className={styles.content}>

View File

@ -16,6 +16,7 @@ limitations under the License.
import { describe, expect, test, vi } from "vitest";
import { render, configure } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toast } from "../src/Toast";
import { withFakeTimers } from "./utils/test";
@ -24,6 +25,9 @@ configure({
defaultHidden: true,
});
// Test Explanation:
// This test the toast. We need to use { document: window.document } because the toast listens
// for user input on `window`.
describe("Toast", () => {
test("renders", () => {
const { queryByRole } = render(
@ -40,6 +44,33 @@ describe("Toast", () => {
expect(getByRole("dialog")).toMatchSnapshot();
});
test("dismisses when Esc is pressed", async () => {
const user = userEvent.setup({ document: window.document });
const onDismiss = vi.fn();
const { debug } = render(
<Toast open={true} onDismiss={onDismiss}>
Hello world!
</Toast>,
);
debug();
await user.keyboard("[Escape]");
expect(onDismiss).toHaveBeenCalled();
});
test("dismisses when background is clicked", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
const { getByRole, unmount } = render(
<Toast open={true} onDismiss={onDismiss}>
Hello world!
</Toast>,
);
const background = getByRole("dialog").previousSibling! as Element;
await user.click(background);
expect(onDismiss).toHaveBeenCalled();
unmount();
});
test("dismisses itself after the specified timeout", () => {
withFakeTimers(() => {
const onDismiss = vi.fn();

View File

@ -1,30 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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.
*/
.tooltip {
background-color: var(--cpd-color-bg-subtle-secondary);
flex-direction: row;
justify-content: center;
align-items: center;
padding: 10px;
color: var(--cpd-color-text-primary);
border-radius: 8px;
max-width: 135px;
width: max-content;
font-size: var(--font-size-caption);
font-weight: 500;
text-align: center;
}

View File

@ -1,118 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 {
ForwardedRef,
forwardRef,
ReactElement,
ReactNode,
useRef,
} from "react";
import {
TooltipTriggerState,
useTooltipTriggerState,
} from "@react-stately/tooltip";
import { FocusableProvider } from "@react-aria/focus";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import classNames from "classnames";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import { Placement } from "@react-types/overlays";
import styles from "./Tooltip.module.css";
interface TooltipProps {
className?: string;
state: TooltipTriggerState;
children: ReactNode;
}
const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(
{ state, className, children, ...rest }: TooltipProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { tooltipProps } = useTooltip(rest, state);
return (
<div
className={classNames(styles.tooltip, className)}
{...mergeProps(rest, tooltipProps)}
ref={ref}
>
{children}
</div>
);
},
);
Tooltip.displayName = "Tooltip";
interface TooltipTriggerProps {
children: ReactElement;
placement?: Placement;
delay?: number;
tooltip: () => string;
}
export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
(
{ children, placement, tooltip, ...rest }: TooltipTriggerProps,
ref: ForwardedRef<HTMLElement>,
) => {
const tooltipTriggerProps = { delay: 250, ...rest };
const tooltipState = useTooltipTriggerState(tooltipTriggerProps);
const triggerRef = useObjectRef<HTMLElement>(ref);
const overlayRef = useRef<HTMLDivElement>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
tooltipTriggerProps,
tooltipState,
triggerRef,
);
const { overlayProps } = useOverlayPosition({
placement: placement || "top",
targetRef: triggerRef,
overlayRef,
isOpen: tooltipState.isOpen,
offset: 12,
});
return (
<FocusableProvider ref={triggerRef} {...triggerProps}>
<children.type
{...mergeProps<typeof children.props | typeof rest>(
children.props,
rest,
)}
/>
{tooltipState.isOpen && (
<OverlayContainer>
<Tooltip
state={tooltipState}
ref={overlayRef}
{...mergeProps(tooltipProps, overlayProps)}
>
{tooltip()}
</Tooltip>
</OverlayContainer>
)}
</FocusableProvider>
);
},
);
TooltipTrigger.displayName = "TooltipTrigger";

View File

@ -21,6 +21,14 @@ limitations under the License.
flex-shrink: 0;
}
.userButton {
appearance: none;
background: none;
border: none;
margin: 0;
cursor: pointer;
}
.userButton svg * {
fill: var(--cpd-color-icon-primary);
}

View File

@ -14,21 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC, ReactNode, useCallback, useMemo } from "react";
import { Item } from "@react-stately/collections";
import { FC, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Menu, MenuItem } from "@vector-im/compound-web";
import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
import { Menu } from "./Menu";
import { TooltipTrigger } from "./Tooltip";
import { LinkButton } from "./button";
import { Avatar, Size } from "./Avatar";
import UserIcon from "./icons/User.svg?react";
import SettingsIcon from "./icons/Settings.svg?react";
import LoginIcon from "./icons/Login.svg?react";
import LogoutIcon from "./icons/Logout.svg?react";
import { Body } from "./typography/Typography";
import styles from "./UserMenu.module.css";
interface Props {
@ -91,7 +87,7 @@ export const UserMenu: FC<Props> = ({
return arr;
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation, t]);
const tooltip = useCallback(() => t("common.profile"), [t]);
const [open, setOpen] = useState(false);
if (!isAuthenticated) {
return (
@ -102,10 +98,15 @@ export const UserMenu: FC<Props> = ({
}
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger tooltip={tooltip} placement="bottom left">
<Button
variant="icon"
<Menu
title={t("a11y.user_menu")}
showTitle={false}
align="end"
open={open}
onOpenChange={setOpen}
trigger={
<button
aria-label={t("common.profile")}
className={styles.userButton}
data-testid="usermenu_open"
>
@ -119,26 +120,18 @@ export const UserMenu: FC<Props> = ({
) : (
<UserIcon />
)}
</Button>
</TooltipTrigger>
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: any): ReactNode => (
<Menu {...props} label={t("a11y.user_menu")} onAction={onAction}>
{items.map(({ key, icon: Icon, label, dataTestid }) => (
<Item key={key} textValue={label}>
<Icon
width={24}
height={24}
className={styles.menuIcon}
data-testid={dataTestid}
/>
<Body overflowEllipsis>{label}</Body>
</Item>
))}
</Menu>
)
</button>
}
</PopoverMenuTrigger>
>
{items.map(({ key, icon: Icon, label, dataTestid }) => (
<MenuItem
key={key}
Icon={Icon}
label={label}
data-test-id={dataTestid}
onSelect={() => onAction(key)}
/>
))}
</Menu>
);
};

View File

@ -16,7 +16,7 @@ limitations under the License.
import posthog, { CaptureOptions, PostHog, Properties } from "posthog-js";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Buffer } from "buffer";
import { widget } from "../widget";

View File

@ -17,11 +17,11 @@ limitations under the License.
import { FC, FormEvent, useCallback, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web";
import Logo from "../icons/LogoLarge.svg?react";
import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";

View File

@ -28,9 +28,9 @@ import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { Button } from "@vector-im/compound-web";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { useClientLegacy } from "../ClientContext";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";

View File

@ -13,7 +13,7 @@ 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 "@types/grecaptcha";
import { useEffect, useCallback, useRef, useState } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
@ -23,7 +23,6 @@ import { translatedError } from "../TranslatedError";
declare global {
interface Window {
mxOnRecaptchaLoaded: () => void;
// grecaptcha: any;
}
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,240 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.button,
.toolbarButton,
.toolbarButtonSecondary,
.iconButton,
.iconCopyButton,
.secondary,
.secondaryHangup,
.copyButton,
.dropdownButton {
position: relative;
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
padding: 0;
border: none;
cursor: pointer;
text-decoration: none;
box-sizing: border-box;
}
.secondary,
.secondaryHangup,
.button,
.copyButton {
padding: 8px 20px;
border-radius: 8px;
font-size: var(--font-size-body);
font-weight: 600;
}
.button {
color: var(--stopgap-color-on-solid-accent);
background-color: var(--cpd-color-text-action-accent);
}
.button:focus-visible,
.toolbarButton:focus-visible,
.toolbarButtonSecondary:focus-visible,
.iconButton:focus-visible,
.iconCopyButton:focus-visible,
.secondary:focus-visible,
.secondaryHangup:focus-visible,
.copyButton:focus-visible {
outline: auto;
}
.toolbarButton:disabled {
background-color: var(--cpd-color-bg-action-primary-disabled);
box-shadow: none;
}
.toolbarButton,
.toolbarButtonSecondary {
width: 50px;
height: 50px;
border-radius: 50px;
background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-icon-primary);
border: 1px solid var(--cpd-color-gray-400);
box-shadow: var(--subtle-drop-shadow);
}
.toolbarButton.on,
.toolbarButton.off {
background-color: var(--cpd-color-bg-action-primary-rest);
color: var(--cpd-color-icon-on-solid-primary);
}
.toolbarButtonSecondary.on {
background-color: var(--cpd-color-text-success-primary);
}
.toolbarButton:active,
.toolbarButtonSecondary:active {
background-color: var(--cpd-color-bg-subtle-primary);
border: none;
box-shadow: none;
}
.toolbarButton.on:active,
.toolbarButton.off:active {
background-color: var(--cpd-color-bg-action-primary-pressed);
}
.iconButton:not(.stroke) svg * {
fill: var(--cpd-color-bg-action-primary-rest);
}
.iconButton:not(.stroke):tertiary svg * {
fill: var(--cpd-color-icon-accent-tertiary);
}
.iconButton.on:not(.stroke) svg * {
fill: var(--cpd-color-icon-accent-tertiary);
}
.iconButton.on.stroke svg * {
stroke: var(--cpd-color-icon-accent-tertiary);
}
.hangupButton {
background-color: var(--cpd-color-bg-critical-primary);
border-color: var(--cpd-color-border-critical-subtle);
.endCall > svg {
color: var(--stopgap-color-on-solid-accent);
}
.hangupButton:active {
background-color: var(--cpd-color-bg-critical-pressed);
}
.secondary,
.copyButton {
color: var(--cpd-color-text-action-accent);
border: 2px solid var(--cpd-color-text-action-accent);
background-color: transparent;
}
.secondaryHangup {
color: var(--cpd-color-text-critical-primary);
border: 2px solid var(--cpd-color-border-critical-primary);
background-color: transparent;
}
.copyButton.secondaryCopy {
color: var(--cpd-color-text-primary);
border-color: var(--cpd-color-border-interactive-primary);
}
.copyButton {
width: 100%;
height: 40px;
transition:
border-color 250ms,
background-color 250ms;
}
.copyButton span {
font-weight: 600;
font-size: var(--font-size-body);
margin-right: 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.copyButton svg {
flex-shrink: 0;
}
.copyButton:not(.on) svg * {
fill: var(--cpd-color-icon-accent-tertiary);
}
.copyButton.on {
border-color: transparent;
background-color: var(--cpd-color-text-action-accent);
color: white;
}
.copyButton.on svg * {
stroke: white;
}
.copyButton.secondaryCopy:not(.on) svg * {
fill: var(--cpd-color-bg-action-primary-rest);
}
.iconCopyButton svg * {
fill: var(--cpd-color-icon-secondary);
}
.iconCopyButton.on svg *,
.iconCopyButton.on:hover svg * {
fill: transparent;
stroke: var(--cpd-color-text-action-accent);
}
.dropdownButton {
color: var(--cpd-color-text-primary);
padding: 2px 8px;
border-radius: 8px;
}
.dropdownButton:active,
.dropdownButton.on {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}
.dropdownButton svg {
margin-left: 8px;
}
.dropdownButton svg * {
fill: var(--cpd-color-icon-primary);
}
.lg {
height: 40px;
}
.linkButton {
background-color: transparent;
border: none;
color: var(--cpd-color-text-action-accent);
cursor: pointer;
}
@media (hover: hover) {
.toolbarButton:hover,
.toolbarButtonSecondary:hover {
background-color: var(--cpd-color-bg-subtle-primary);
border: none;
box-shadow: none;
}
.toolbarButton.on:hover,
.toolbarButton.off:hover {
background-color: var(--cpd-color-bg-action-primary-hovered);
}
.iconButton:not(.stroke):hover svg * {
fill: var(--cpd-color-icon-accent-tertiary);
}
.hangupButton:hover {
background-color: var(--cpd-color-bg-critical-hovered);
}
.iconCopyButton:hover svg * {
fill: var(--cpd-color-icon-accent-tertiary);
}
.dropdownButton:hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 - 2023 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -13,13 +13,10 @@ 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 { FC, forwardRef } from "react";
import { PressEvent } from "@react-types/shared";
import { ComponentPropsWithoutRef, FC } from "react";
import classNames from "classnames";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@vector-im/compound-web";
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
import {
MicOnSolidIcon,
MicOffSolidIcon,
@ -28,120 +25,15 @@ import {
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
ChevronDownIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
export type ButtonVariant =
| "default"
| "toolbar"
| "toolbarSecondary"
| "icon"
| "secondary"
| "copy"
| "secondaryCopy"
| "iconCopy"
| "secondaryHangup"
| "dropdown"
| "link";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
toolbarSecondary: [styles.toolbarButtonSecondary],
icon: [styles.iconButton],
secondary: [styles.secondary],
copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
link: [styles.linkButton],
};
export type ButtonSize = "lg";
export const sizeToClassName: { lg: string[] } = {
lg: [styles.lg],
};
interface Props {
variant: ButtonVariant;
size: ButtonSize;
on: () => void;
off: () => void;
iconStyle: string;
className: string;
children: Element[];
onPress: (e: PressEvent) => void;
onPressStart: (e: PressEvent) => void;
disabled: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean;
}
export const Button = forwardRef<HTMLButtonElement, Props>(
(
{
variant = "default",
size,
on,
off,
iconStyle,
className,
children,
onPress,
onPressStart,
...rest
},
ref,
) => {
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef,
);
// TODO: react-aria's useButton hook prevents form submission via keyboard
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
let filteredButtonProps = buttonProps;
if (rest.type === "submit" && !rest.onPress) {
const { ...filtered } = buttonProps;
filteredButtonProps = filtered;
}
return (
<button
className={classNames(
variantToClassName[variant],
sizeToClassName[size],
styles[iconStyle],
className,
{
[styles.on]: on,
[styles.off]: off,
},
)}
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
<>
{children}
{variant === "dropdown" && <ChevronDownIcon />}
</>
</button>
);
},
);
Button.displayName = "Button";
export const MicButton: FC<{
muted: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}> = ({ muted, ...rest }) => {
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
const { t } = useTranslation();
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const label = muted
@ -150,18 +42,21 @@ export const MicButton: FC<{
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={muted}>
<Icon aria-hidden width={24} height={24} />
</Button>
<CpdButton
iconOnly
Icon={Icon}
kind={muted ? "primary" : "secondary"}
{...props}
/>
</Tooltip>
);
};
export const VideoButton: FC<{
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}> = ({ muted, ...rest }) => {
}
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
const { t } = useTranslation();
const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon;
const label = muted
@ -170,19 +65,24 @@ export const VideoButton: FC<{
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={muted}>
<Icon aria-hidden width={24} height={24} />
</Button>
<CpdButton
iconOnly
Icon={Icon}
kind={muted ? "primary" : "secondary"}
{...props}
/>
</Tooltip>
);
};
export const ScreenshareButton: FC<{
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}> = ({ enabled, className, ...rest }) => {
}
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
enabled,
...props
}) => {
const { t } = useTranslation();
const label = enabled
? t("stop_screenshare_button_label")
@ -190,45 +90,48 @@ export const ScreenshareButton: FC<{
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={enabled}>
<ShareScreenSolidIcon aria-hidden width={24} height={24} />
</Button>
<CpdButton
iconOnly
Icon={ShareScreenSolidIcon}
kind={enabled ? "primary" : "secondary"}
{...props}
/>
</Tooltip>
);
};
export const HangupButton: FC<{
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}> = ({ className, ...rest }) => {
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
className,
...props
}) => {
const { t } = useTranslation();
return (
<Tooltip label={t("hangup_button_label")}>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<EndCallIcon aria-hidden width={24} height={24} />
</Button>
<CpdButton
className={classNames(className, styles.endCall)}
iconOnly
Icon={EndCallIcon}
destructive
{...props}
/>
</Tooltip>
);
};
export const SettingsButton: FC<{
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}> = ({ className, ...rest }) => {
export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = (
props,
) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.settings")}>
<Button variant="toolbar" {...rest}>
<SettingsSolidIcon aria-hidden width={24} height={24} />
</Button>
<CpdButton
iconOnly
Icon={SettingsSolidIcon}
kind="secondary"
{...props}
/>
</Tooltip>
);
};

View File

@ -1,69 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 { useTranslation } from "react-i18next";
import useClipboard from "react-use-clipboard";
import { FC } from "react";
import CheckIcon from "../icons/Check.svg?react";
import CopyIcon from "../icons/Copy.svg?react";
import { Button, ButtonVariant } from "./Button";
interface Props {
value: string;
children?: JSX.Element | string;
className?: string;
variant?: ButtonVariant;
copiedMessage?: string;
}
export const CopyButton: FC<Props> = ({
value,
children,
className,
variant,
copiedMessage,
...rest
}) => {
const { t } = useTranslation();
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return (
<Button
{...rest}
variant={variant === "icon" ? "iconCopy" : variant || "copy"}
on={isCopied}
className={className}
onPress={setCopied}
iconStyle={isCopied ? "stroke" : "fill"}
aria-label={t("action.copy")}
>
{isCopied ? (
<>
{variant !== "icon" && (
<span>{copiedMessage || t("common.copied")}</span>
)}
<CheckIcon />
</>
) : (
<>
{variant !== "icon" && <span>{children || value}</span>}
<CopyIcon />
</>
)}
</Button>
);
};

61
src/button/Link.tsx Normal file
View File

@ -0,0 +1,61 @@
/*
Copyright 2024 New Vector Ltd
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 {
ComponentPropsWithoutRef,
forwardRef,
MouseEvent,
useCallback,
useMemo,
} from "react";
import { Link as CpdLink } from "@vector-im/compound-web";
import { useHistory } from "react-router-dom";
import { createPath, LocationDescriptor, Path } from "history";
export function useLink(
to: LocationDescriptor,
): [Path, (e: MouseEvent) => void] {
const history = useHistory();
const path = useMemo(
() => (typeof to === "string" ? to : createPath(to)),
[to],
);
const onClick = useCallback(
(e: MouseEvent) => {
e.preventDefault();
history.push(to);
},
[history, to],
);
return [path, onClick];
}
type Props = Omit<
ComponentPropsWithoutRef<typeof CpdLink>,
"href" | "onClick"
> & { to: LocationDescriptor };
/**
* A version of Compound's link component that integrates with our router setup.
*/
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link(
{ to, ...props },
ref,
) {
const [path, onClick] = useLink(to);
return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />;
});

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,45 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC, HTMLAttributes } from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";
import * as H from "history";
import { ComponentPropsWithoutRef, forwardRef } from "react";
import { Button } from "@vector-im/compound-web";
import { LocationDescriptor } from "history";
import {
variantToClassName,
sizeToClassName,
ButtonVariant,
ButtonSize,
} from "./Button";
import { useLink } from "./Link";
interface Props extends HTMLAttributes<HTMLAnchorElement> {
children: JSX.Element | string;
to: H.LocationDescriptor | ((location: H.Location) => H.LocationDescriptor);
size?: ButtonSize;
variant?: ButtonVariant;
className?: string;
}
type Props = Omit<
ComponentPropsWithoutRef<typeof Button<"a">>,
"as" | "href"
> & { to: LocationDescriptor };
export const LinkButton: FC<Props> = ({
children,
to,
size,
variant,
className,
...rest
}) => {
return (
<Link
className={classNames(
variantToClassName[variant || "secondary"],
size ? sizeToClassName[size] : [],
className,
)}
to={to}
{...rest}
>
{children}
</Link>
);
};
/**
* A version of Compound's button component that acts as a link and integrates
* with our router setup.
*/
export const LinkButton = forwardRef<HTMLAnchorElement, Props>(
function LinkButton({ to, ...props }, ref) {
const [path, onClick] = useLink(to);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
},
);

View File

@ -15,5 +15,4 @@ limitations under the License.
*/
export * from "./Button";
export * from "./CopyButton";
export * from "./LinkButton";

View File

@ -42,8 +42,7 @@ limitations under the License.
align-items: center;
}
.avatar,
.copyButtonSpacer {
.avatar {
flex-shrink: 0;
}
@ -64,18 +63,6 @@ limitations under the License.
margin-top: 8px;
}
.copyButtonSpacer,
.copyButton {
width: 16px;
height: 16px;
}
.copyButton {
position: absolute;
top: 12px;
right: 12px;
}
.callList {
display: flex;
flex-wrap: wrap;

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import { render, RenderResult } from "@testing-library/react";
import { MatrixClient } from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,10 +20,9 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Room } from "matrix-js-sdk/src/models/room";
import { FC } from "react";
import { CopyButton } from "../button";
import { Avatar, Size } from "../Avatar";
import styles from "./CallList.module.css";
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../utils/matrix";
import { getRelativeRoomUrl } from "../utils/matrix";
import { Body } from "../typography/Typography";
import { GroupCallRoom } from "./useGroupCallRooms";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
@ -81,12 +80,6 @@ const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
</div>
<div className={styles.copyButtonSpacer} />
</Link>
<CopyButton
className={styles.copyButton}
variant="icon"
// Todo add the viaServers to the created link
value={getAbsoluteRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
/>
</div>
);
};

View File

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { PressEvent } from "@react-types/shared";
import { useTranslation } from "react-i18next";
import { FC } from "react";
import { FC, MouseEvent } from "react";
import { Button } from "@vector-im/compound-web";
import { Modal } from "../Modal";
import { Button } from "../button";
import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css";
interface Props {
open: boolean;
onDismiss: () => void;
onJoin: (e: PressEvent) => void;
onJoin: (e: MouseEvent) => void;
}
export const JoinExistingCallModal: FC<Props> = ({
@ -44,8 +43,8 @@ export const JoinExistingCallModal: FC<Props> = ({
>
<p>{t("join_existing_call_modal.text")}</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={onDismiss}>{t("action.no")}</Button>
<Button onPress={onJoin} data-testid="home_joinExistingRoom">
<Button onClick={onDismiss}>{t("action.no")}</Button>
<Button onClick={onJoin} data-testid="home_joinExistingRoom">
{t("join_existing_call_modal.join_button")}
</Button>
</FieldRow>

View File

@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { Heading } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { Button } from "@vector-im/compound-web";
import {
createRoom,
@ -32,7 +33,6 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css";
import styles from "./RegisteredView.module.css";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
@ -40,10 +40,7 @@ import { Caption } from "../typography/Typography";
import { Form } from "../form/Form";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2eeType } from "../e2ee/e2eeType";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
import { useOptInAnalytics } from "../settings/settings";
interface Props {
client: MatrixClient;
@ -52,7 +49,7 @@ interface Props {
export const RegisteredView: FC<Props> = ({ client }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
const [optInAnalytics] = useOptInAnalytics();
const history = useHistory();
const { t } = useTranslation();
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =

View File

@ -18,14 +18,13 @@ import { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { Heading } from "@vector-im/compound-web";
import { Button, Heading } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import {
createRoom,
getRelativeRoomUrl,
@ -43,16 +42,13 @@ import { generateRandomName } from "../auth/generateRandomName";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
import { useOptInAnalytics } from "../settings/settings";
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
const [optInAnalytics] = useOptInAnalytics();
const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);

View File

@ -18,7 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useState, useEffect } from "react";
import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk";
import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk/src/matrix";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
import { KnownMembership } from "matrix-js-sdk/src/types";

View File

@ -1,10 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_965_9448)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.70711 5.36321C2.09763 4.97268 2.73182 4.97166 3.1236 5.36091L8.08934 10.2946L13.0391 5.34488C13.4296 4.95435 14.0638 4.95333 14.4556 5.34258C14.8474 5.73184 14.8484 6.36398 14.4579 6.75451L8.80101 12.4114C8.41049 12.8019 7.7763 12.8029 7.38452 12.4137L1.70939 6.77513C1.3176 6.38587 1.31658 5.75373 1.70711 5.36321Z" fill="#8E99A4"/>
</g>
<defs>
<clipPath id="clip0_965_9448">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 633 B

View File

@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9699 2.22605L6 7.20093L1.5 7.20093C0.671573 7.20093 0 7.8725 0 8.70093V15.3009C0 16.1294 0.671571 16.8009 1.5 16.8009L6 16.8009L11.9699 21.7758C12.4584 22.1829 13.2 21.8355 13.2 21.1996V2.80221C13.2 2.16634 12.4584 1.81897 11.9699 2.22605Z" fill="white"/>
<path d="M21.1888 4.1866C20.8497 3.75065 20.2214 3.67212 19.7855 4.01119C19.35 4.34988 19.2712 4.97715 19.6089 5.41304L19.6097 5.41402L19.6107 5.41531L19.6243 5.43347C19.6376 5.45126 19.6589 5.48033 19.6872 5.52017C19.7438 5.59988 19.828 5.72244 19.9308 5.88385C20.1365 6.20718 20.4145 6.68332 20.6932 7.28057C21.2535 8.48111 21.7994 10.134 21.7994 12.0005C21.7994 13.8671 21.2535 15.52 20.6932 16.7205C20.4145 17.3178 20.1365 17.7939 19.9308 18.1172C19.828 18.2786 19.7438 18.4012 19.6872 18.4809C19.6589 18.5208 19.6376 18.5498 19.6243 18.5676L19.6107 18.5858L19.6097 18.5871L19.6088 18.5882C19.2712 19.0241 19.3501 19.6512 19.7855 19.9899C20.2214 20.329 20.8497 20.2504 21.1888 19.8145L20.4435 19.2348C21.1888 19.8145 21.1888 19.8145 21.1888 19.8145L21.1908 19.8119L21.1936 19.8082L21.2019 19.7974L21.2284 19.7621C21.2503 19.7327 21.2805 19.6915 21.3179 19.6389C21.3925 19.5338 21.4958 19.3832 21.6181 19.191C21.8623 18.8072 22.1843 18.2547 22.5056 17.5663C23.1453 16.1954 23.7994 14.2482 23.7994 12.0005C23.7994 9.75284 23.1453 7.80569 22.5056 6.4348C22.1843 5.74634 21.8623 5.1939 21.6181 4.81009C21.4958 4.61793 21.3925 4.46727 21.3179 4.36217C21.2805 4.30959 21.2503 4.26835 21.2284 4.23893L21.2019 4.20373L21.1936 4.19288L21.1908 4.18917L21.1897 4.18774C21.1897 4.18774 21.1888 4.1866 20.3994 4.80054L21.1888 4.1866Z" fill="white"/>
<path d="M17.5896 7.78682C17.2506 7.35087 16.6223 7.27234 16.1864 7.61141C15.7515 7.94959 15.6723 8.57548 16.0083 9.01128L16.0117 9.01586C16.0162 9.02185 16.0246 9.03334 16.0365 9.05007C16.0603 9.08359 16.0977 9.13784 16.1441 9.21085C16.2374 9.3574 16.3654 9.57639 16.4941 9.85222C16.7544 10.4099 17.0003 11.1627 17.0003 12.0008C17.0003 12.8388 16.7544 13.5916 16.4941 14.1493C16.3654 14.4251 16.2374 14.6441 16.1441 14.7907C16.0977 14.8637 16.0603 14.9179 16.0365 14.9514C16.0246 14.9682 16.0162 14.9797 16.0117 14.9857L16.0083 14.9903C15.6723 15.4261 15.7515 16.0519 16.1864 16.3901C16.6223 16.7292 17.2506 16.6506 17.5896 16.2147L16.8003 15.6008C17.5896 16.2147 17.5896 16.2147 17.5896 16.2147L17.5914 16.2124L17.5936 16.2095L17.5994 16.2021L17.6158 16.1802C17.6289 16.1626 17.6463 16.1389 17.6672 16.1094C17.709 16.0505 17.7654 15.9682 17.8315 15.8644C17.9632 15.6574 18.1352 15.3621 18.3065 14.9951C18.6462 14.267 19.0003 13.2199 19.0003 12.0008C19.0003 10.7816 18.6462 9.73448 18.3065 9.00645C18.1352 8.63942 17.9632 8.34412 17.8315 8.1371C17.7654 8.03333 17.709 7.95097 17.6672 7.89207C17.6463 7.8626 17.6289 7.83893 17.6158 7.82132L17.5994 7.79946L17.5936 7.79198L17.5914 7.78911L17.5905 7.78789C17.5905 7.78789 17.5896 7.78682 16.8003 8.40076L17.5896 7.78682Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2C2.89543 2 2 2.89543 2 4V8.66667C2 9.77124 2.89543 10.6667 4 10.6667H5.33333V12C5.33333 13.1046 6.22877 14 7.33333 14H12C13.1046 14 14 13.1046 14 12V7.33333C14 6.22876 13.1046 5.33333 12 5.33333H10.6667V4C10.6667 2.89543 9.77123 2 8.66667 2H4ZM9.33333 5.33333V4C9.33333 3.63181 9.03486 3.33333 8.66667 3.33333H4C3.63181 3.33333 3.33333 3.63181 3.33333 4V8.66667C3.33333 9.03486 3.63181 9.33333 4 9.33333H5.33333V7.33333C5.33333 6.22877 6.22876 5.33333 7.33333 5.33333H9.33333ZM6.66667 7.33333C6.66667 6.96514 6.96514 6.66667 7.33333 6.66667H12C12.3682 6.66667 12.6667 6.96514 12.6667 7.33333V12C12.6667 12.3682 12.3682 12.6667 12 12.6667H7.33333C6.96514 12.6667 6.66667 12.3682 6.66667 12V7.33333Z" fill="#8E99A4"/>
</svg>

Before

Width:  |  Height:  |  Size: 872 B

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 3C2.23858 3 0 5.23858 0 8C0 10.7614 2.23858 13 5 13H11C13.7614 13 16 10.7614 16 8C16 5.23858 13.7614 3 11 3H5ZM8 8C8 9.65685 6.65685 11 5 11C3.34315 11 2 9.65685 2 8C2 6.34315 3.34315 5 5 5C6.65685 5 8 6.34315 8 8Z" fill="#A9B2BC"/>
</svg>

Before

Width:  |  Height:  |  Size: 388 B

View File

@ -1,4 +0,0 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.64856 7.35501C2.65473 7.31601 2.67231 7.27972 2.69908 7.25069L8.40377 1.06442C8.47865 0.983217 8.60518 0.978093 8.68638 1.05297L9.8626 2.13763C9.9438 2.21251 9.94893 2.33904 9.87405 2.42024L4.16936 8.60651C4.1426 8.63554 4.10783 8.656 4.06946 8.6653L2.66781 9.00511C2.52911 9.03873 2.40084 8.92044 2.42315 8.77948L2.64856 7.35501Z" fill="white"/>
<path d="M1.75 9.44346C1.33579 9.44346 1 9.77925 1 10.1935C1 10.6077 1.33579 10.9435 1.75 10.9435L10.75 10.9435C11.1642 10.9435 11.5 10.6077 11.5 10.1935C11.5 9.77925 11.1642 9.44346 10.75 9.44346L1.75 9.44346Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 689 B

View File

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.283 21.4401C17.6495 21.4401 21.9999 17.0881 21.9999 11.7196C21.9999 6.3511 17.6495 1.99908 12.283 1.99908C6.91643 1.99908 2.566 6.3511 2.566 11.7196C2.566 13.2234 2.90739 14.6476 3.51687 15.9186L2.04468 20.7049C1.80806 21.4742 2.5308 22.1936 3.29898 21.9535L8.04564 20.4696C9.32625 21.0914 10.7639 21.4401 12.283 21.4401Z" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -1,5 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.33398 15C8.33398 16.3807 7.2147 17.5 5.83398 17.5C4.45327 17.5 3.33398 16.3807 3.33398 15C3.33398 13.6193 4.45327 12.5 5.83398 12.5C7.2147 12.5 8.33398 13.6193 8.33398 15Z" fill="white"/>
<path d="M17.5002 15C17.5002 16.3807 16.381 17.5 15.0002 17.5C13.6195 17.5 12.5002 16.3807 12.5002 15C12.5002 13.6193 13.6195 12.5 15.0002 12.5C16.381 12.5 17.5002 13.6193 17.5002 15Z" fill="white"/>
<path d="M24.1665 17.5C25.5472 17.5 26.6665 16.3807 26.6665 15C26.6665 13.6193 25.5472 12.5 24.1665 12.5C22.7858 12.5 21.6665 13.6193 21.6665 15C21.6665 16.3807 22.7858 17.5 24.1665 17.5Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 707 B

View File

@ -1,4 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.50001 3.33334C1.11929 3.33334 0 4.45264 0 5.83336V14.1667C0 15.5474 1.11929 16.6667 2.50001 16.6667H11.6667C13.0474 16.6667 14.1667 15.5474 14.1667 14.1667V5.83336C14.1667 4.45264 13.0474 3.33334 11.6667 3.33334H2.50001Z" fill="white"/>
<path d="M18.6462 5.24983L15.8334 7.50004V12.5001L18.6462 14.7503C19.1918 15.1868 20.0001 14.7983 20.0001 14.0996V5.90056C20.0001 5.2018 19.1918 4.81332 18.6462 5.24983Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 538 B

View File

@ -20,9 +20,12 @@ limitations under the License.
Therefore we define a unicode-range to load which excludes the glyphs
(to avoid having to maintain a fork of Inter). */
@import "normalize.css/normalize.css";
@import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
@import "@vector-im/compound-web/dist/style.css";
@layer normalize, compound-legacy, compound;
@import url("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
:root {
--font-scale: 1;
@ -195,87 +198,89 @@ body[data-platform="desktop"] {
--cpd-font-family-sans: "Inter", sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
a {
margin-top: 0;
}
@layer compound-legacy {
h1,
h2,
h3,
h4,
h5,
h6,
p,
a {
margin-top: 0;
}
/* Headline Semi Bold */
h1 {
font-weight: 600;
font-size: var(--font-size-headline);
}
/* Headline Semi Bold */
h1 {
font-weight: 600;
font-size: var(--font-size-headline);
}
/* Title */
h2 {
font-weight: 600;
font-size: var(--font-size-title);
}
/* Title */
h2 {
font-weight: 600;
font-size: var(--font-size-title);
}
/* Subtitle */
h3 {
font-weight: 600;
font-size: var(--font-size-subtitle);
}
/* Subtitle */
h3 {
font-weight: 600;
font-size: var(--font-size-subtitle);
}
/* Body Semi Bold */
h4 {
font-weight: 600;
font-size: var(--font-size-body);
}
/* Body Semi Bold */
h4 {
font-weight: 600;
font-size: var(--font-size-body);
}
h1,
h2,
h3 {
line-height: 1.2;
}
h1,
h2,
h3 {
line-height: 1.2;
}
/* Body */
p {
font-size: var(--font-size-body);
line-height: var(--font-size-title);
}
/* Body */
p {
font-size: var(--font-size-body);
line-height: var(--font-size-title);
}
a {
color: var(--cpd-color-text-action-accent);
text-decoration: none;
}
a {
color: var(--cpd-color-text-action-accent);
text-decoration: none;
}
a:hover,
a:active {
opacity: 0.8;
}
a:hover,
a:active {
opacity: 0.8;
}
hr {
width: calc(100% - 24px);
border: none;
border-top: 1px solid var(--cpd-color-border-interactive-secondary);
color: var(--cpd-color-border-interactive-secondary);
overflow: visible;
text-align: center;
height: 5px;
font-weight: 600;
font-size: var(--font-size-body);
line-height: 24px;
margin: 0 12px;
}
hr {
width: calc(100% - 24px);
border: none;
border-top: 1px solid var(--cpd-color-border-interactive-secondary);
color: var(--cpd-color-border-interactive-secondary);
overflow: visible;
text-align: center;
height: 5px;
font-weight: 600;
font-size: var(--font-size-body);
line-height: 24px;
margin: 0 12px;
}
summary {
font-size: var(--font-size-body);
}
summary {
font-size: var(--font-size-body);
}
details > :not(summary) {
margin-left: var(--font-size-body);
}
details > :not(summary) {
margin-left: var(--font-size-body);
}
details[open] > summary {
margin-bottom: var(--font-size-body);
details[open] > summary {
margin-bottom: var(--font-size-body);
}
}
#root > [data-overlay-container] {

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,45 +15,30 @@ limitations under the License.
*/
.avatarInputField {
display: flex;
flex-direction: column;
justify-content: center;
}
.avatarContainer {
position: relative;
margin-bottom: 8px;
}
.avatar {
display: block;
}
.fileInput {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
display: none;
}
.edit {
border-radius: var(--cpd-radius-pill-effect);
padding: 2px;
background: var(--cpd-color-bg-canvas-default);
position: absolute;
z-index: -1;
inset-block-end: -2px;
inset-inline-end: -2px;
}
.fileInput:focus + .fileInputButton {
outline: auto;
}
.fileInputButton {
position: absolute;
bottom: 11px;
right: -4px;
background-color: var(--cpd-color-subtle-primary);
width: 20px;
height: 20px;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.removeButton {
color: var(--cpd-color-text-action-accent);
font-size: var(--font-size-caption);
padding: 6px 0;
.edit button {
min-block-size: 0;
block-size: var(--cpd-space-7x);
inline-size: var(--cpd-space-7x);
padding: var(--cpd-space-1x);
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,21 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useObjectRef } from "@react-aria/utils";
import {
AllHTMLAttributes,
useEffect,
useCallback,
useState,
forwardRef,
ChangeEvent,
useRef,
FC,
} from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Button, Menu, MenuItem } from "@vector-im/compound-web";
import {
DeleteIcon,
EditIcon,
ShareIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Avatar, Size } from "../Avatar";
import { Button } from "../button";
import EditIcon from "../icons/Edit.svg?react";
import styles from "./AvatarInputField.module.css";
interface Props extends AllHTMLAttributes<HTMLInputElement> {
@ -40,89 +44,115 @@ interface Props extends AllHTMLAttributes<HTMLInputElement> {
onRemoveAvatar: () => void;
}
export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
(
{
id,
label,
className,
avatarUrl,
userId,
displayName,
onRemoveAvatar,
...rest
},
ref,
) => {
const { t } = useTranslation();
export const AvatarInputField: FC<Props> = ({
id,
label,
className,
avatarUrl,
userId,
displayName,
onRemoveAvatar,
...rest
}) => {
const { t } = useTranslation();
const [removed, setRemoved] = useState(false);
const [objUrl, setObjUrl] = useState<string | undefined>(undefined);
const [removed, setRemoved] = useState(false);
const [objUrl, setObjUrl] = useState<string | undefined>(undefined);
const [menuOpen, setMenuOpen] = useState(false);
const fileInputRef = useObjectRef(ref);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const currentInput = fileInputRef.current;
useEffect(() => {
const currentInput = fileInputRef.current!;
const onChange = (e: Event): void => {
const inputEvent = e as unknown as ChangeEvent<HTMLInputElement>;
if (inputEvent.target.files && inputEvent.target.files.length > 0) {
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
setRemoved(false);
} else {
setObjUrl(undefined);
}
};
const onChange = (e: Event): void => {
const inputEvent = e as unknown as ChangeEvent<HTMLInputElement>;
if (inputEvent.target.files && inputEvent.target.files.length > 0) {
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
setRemoved(false);
} else {
setObjUrl(undefined);
}
};
currentInput.addEventListener("change", onChange);
currentInput.addEventListener("change", onChange);
return (): void => {
currentInput?.removeEventListener("change", onChange);
};
});
return (): void => {
currentInput?.removeEventListener("change", onChange);
};
});
const onPressRemoveAvatar = useCallback(() => {
setRemoved(true);
onRemoveAvatar();
}, [onRemoveAvatar]);
const onSelectUpload = useCallback(() => {
fileInputRef.current!.click();
}, [fileInputRef]);
return (
<div className={classNames(styles.avatarInputField, className)}>
<div className={styles.avatarContainer}>
<Avatar
id={userId}
name={displayName}
size={Size.XL}
src={removed ? undefined : objUrl || avatarUrl}
/>
<input
id={id}
accept="image/png, image/jpeg"
ref={fileInputRef}
type="file"
className={styles.fileInput}
role="button"
aria-label={label}
{...rest}
/>
{/* https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/966 */}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor={id} className={styles.fileInputButton}>
<EditIcon />
</label>
</div>
{(avatarUrl || objUrl) && !removed && (
<Button
className={styles.removeButton}
variant="icon"
onPress={onPressRemoveAvatar}
const onSelectRemove = useCallback(() => {
setRemoved(true);
onRemoveAvatar();
}, [onRemoveAvatar]);
return (
<div className={classNames(styles.avatarInputField, className)}>
<Avatar
id={userId}
className={styles.avatar}
name={displayName}
size={Size.XL}
src={removed ? undefined : objUrl || avatarUrl}
/>
<input
id={id}
accept="image/*"
ref={fileInputRef}
type="file"
className={styles.fileInput}
role="button"
aria-label={label}
{...rest}
/>
<div className={styles.edit}>
{(avatarUrl || objUrl) && !removed ? (
<Menu
title={t("action.edit")}
showTitle={false}
open={menuOpen}
onOpenChange={setMenuOpen}
trigger={
<Button
iconOnly
Icon={EditIcon}
kind="tertiary"
size="sm"
aria-label={t("action.edit")}
/>
}
>
{t("action.remove")}
</Button>
<MenuItem
Icon={ShareIcon}
label={t("action.upload_file")}
onSelect={onSelectUpload}
/>
<MenuItem
Icon={DeleteIcon}
label={t("action.remove")}
kind="critical"
onSelect={onSelectRemove}
/>
</Menu>
) : (
<Button
type="button"
iconOnly
Icon={EditIcon}
kind="tertiary"
size="sm"
aria-label={t("action.edit")}
onClick={onSelectUpload}
/>
)}
</div>
);
},
);
</div>
);
};
AvatarInputField.displayName = "AvatarInputField";

View File

@ -1,60 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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.
*/
.selectInput {
position: relative;
display: inline-block;
margin-bottom: 28px;
max-width: 444px;
}
.label {
margin-top: 0;
margin-bottom: 12px;
}
.selectTrigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background-color: var(--cpd-color-bg-canvas-default);
border-radius: 8px;
border: 1px solid var(--cpd-color-border-interactive-primary);
font-size: var(--font-size-body);
color: var(--cpd-color-text-primary);
height: 40px;
max-width: 100%;
width: 100%;
}
.selectTrigger:focus {
outline: auto;
}
.selectedItem {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 20px;
}
.popover {
position: absolute;
margin-top: 5px;
width: 100%;
z-index: 1;
}

View File

@ -1,80 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 { useRef } from "react";
import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button";
import { useSelectState } from "@react-stately/select";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Popover } from "../popover/Popover";
import { ListBox } from "../ListBox";
import styles from "./SelectInput.module.css";
import ArrowDownIcon from "../icons/ArrowDown.svg?react";
interface Props extends AriaSelectOptions<object> {
className?: string;
}
export function SelectInput(props: Props): JSX.Element {
const { t } = useTranslation();
const state = useSelectState(props);
const ref = useRef(null);
const { labelProps, triggerProps, valueProps, menuProps } = useSelect(
props,
state,
ref,
);
const { buttonProps } = useButton(triggerProps, ref);
return (
<div className={classNames(styles.selectInput, props.className)}>
<h4 {...labelProps} className={styles.label}>
{props.label}
</h4>
<HiddenSelect
state={state}
triggerRef={ref}
label={props.label}
name={props.name}
/>
<button {...buttonProps} ref={ref} className={styles.selectTrigger}>
<span {...valueProps} className={styles.selectedItem}>
{state.selectedItem
? state.selectedItem.rendered
: t("select_input_unset_button")}
</span>
<ArrowDownIcon />
</button>
{state.isOpen && (
<Popover
isOpen={state.isOpen}
onClose={state.close}
className={styles.popover}
>
<ListBox
{...menuProps}
state={state}
optionClassName={styles.option}
/>
</Popover>
)}
</div>
);
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IOpenIDToken, MatrixClient } from "matrix-js-sdk";
import { IOpenIDToken, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useEffect, useState } from "react";

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import { Span } from "@opentelemetry/api";
import { MatrixCall } from "matrix-js-sdk";
import { MatrixCall } from "matrix-js-sdk/src/matrix";
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import {
TransceiverStats,

View File

@ -20,7 +20,7 @@ import {
MatrixClient,
MatrixEvent,
RoomMember,
} from "matrix-js-sdk";
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import {
CallError,

View File

@ -1,24 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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.
*/
.popover {
display: flex;
flex-direction: column;
width: 194px;
background: var(--cpd-color-bg-subtle-secondary);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 8px;
}

View File

@ -1,62 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 { forwardRef, HTMLAttributes } from "react";
import { DismissButton, useOverlay } from "@react-aria/overlays";
import { FocusScope } from "@react-aria/focus";
import classNames from "classnames";
import { useObjectRef } from "@react-aria/utils";
import styles from "./Popover.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
isOpen: boolean;
onClose: () => void;
className?: string;
children?: JSX.Element;
}
export const Popover = forwardRef<HTMLDivElement, Props>(
({ isOpen = true, onClose, className, children, ...rest }, ref) => {
const popoverRef = useObjectRef(ref);
const { overlayProps } = useOverlay(
{
isOpen,
onClose,
shouldCloseOnBlur: true,
isDismissable: true,
},
popoverRef,
);
return (
<FocusScope restoreFocus>
<div
{...overlayProps}
{...rest}
className={classNames(styles.popover, className)}
ref={popoverRef}
>
{children}
<DismissButton onDismiss={onClose} />
</div>
</FocusScope>
);
},
);
Popover.displayName = "Popover";

View File

@ -1,20 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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.
*/
.popoverMenuTrigger {
position: relative;
display: inline-block;
}

View File

@ -1,98 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 { forwardRef, useRef } from "react";
import { useMenuTriggerState } from "@react-stately/menu";
import { useMenuTrigger } from "@react-aria/menu";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import classNames from "classnames";
import { MenuTriggerProps } from "@react-types/menu";
import { Placement } from "@react-types/overlays";
import styles from "./PopoverMenu.module.css";
import { Popover } from "./Popover";
interface PopoverMenuTriggerProps extends MenuTriggerProps {
children: JSX.Element;
placement: Placement;
className: string;
disableOnState: boolean;
[index: string]: unknown;
}
export const PopoverMenuTrigger = forwardRef<
HTMLDivElement,
PopoverMenuTriggerProps
>(({ children, placement, className, disableOnState, ...rest }, ref) => {
const popoverMenuState = useMenuTriggerState(rest);
const buttonRef = useObjectRef(ref);
const { menuTriggerProps, menuProps } = useMenuTrigger(
{},
popoverMenuState,
buttonRef,
);
const popoverRef = useRef(null);
const { overlayProps } = useOverlayPosition({
targetRef: buttonRef,
overlayRef: popoverRef,
placement: placement || "top",
offset: 5,
isOpen: popoverMenuState.isOpen,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"PopoverMenu must have two props. The first being a button and the second being a render prop.",
);
}
const [popoverTrigger, popoverMenu] = children;
return (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...mergeProps(popoverTrigger.props, menuTriggerProps)}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
});
PopoverMenuTrigger.displayName = "PopoverMenuTrigger";

View File

@ -42,9 +42,8 @@ limitations under the License.
}
.callEndedButton {
margin: auto;
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.submitButton {

View File

@ -18,17 +18,19 @@ import { FC, FormEventHandler, ReactNode, useCallback, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { Button } from "@vector-im/compound-web";
import styles from "./CallEndedView.module.css";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { Button, LinkButton } from "../button";
import { useProfile } from "../profile/useProfile";
import { Body, Link, Headline } from "../typography/Typography";
import { Body, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
import { RageshakeButton } from "../settings/RageshakeButton";
import { Link } from "../button/Link";
import { LinkButton } from "../button";
interface Props {
client: MatrixClient;
@ -95,12 +97,7 @@ export const CallEndedView: FC<Props> = ({
calls
</p>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
<LinkButton className={styles.callEndedButton} to="/register">
{t("call_ended_view.create_account_button")}
</LinkButton>
</div>
@ -136,8 +133,6 @@ export const CallEndedView: FC<Props> = ({
<Button
type="submit"
className={styles.submitButton}
size="lg"
variant="default"
data-testid="home_go"
>
{submitting ? t("submitting") : t("action.submit")}
@ -159,7 +154,7 @@ export const CallEndedView: FC<Props> = ({
</Trans>
</Headline>
<div className={styles.disconnectedButtons}>
<Button size="lg" variant="default" onClick={reconnect}>
<Button onClick={reconnect}>
{t("call_ended_view.reconnect_button")}
</Button>
<div className={styles.rageshakeButton}>
@ -169,9 +164,7 @@ export const CallEndedView: FC<Props> = ({
</main>
{!confineToRoom && (
<Body className={styles.footer}>
<Link color="primary" to="/">
{t("return_home_button")}
</Link>
<Link to="/"> {t("return_home_button")} </Link>
</Body>
)}
</>
@ -198,9 +191,7 @@ export const CallEndedView: FC<Props> = ({
</main>
{!confineToRoom && (
<Body className={styles.footer}>
<Link color="primary" to="/">
{t("call_ended_view.not_now_button")}
</Link>
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
</Body>
)}
</>

View File

@ -17,7 +17,7 @@ limitations under the License.
import { useCallback } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { MatrixError } from "matrix-js-sdk";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { useHistory } from "react-router-dom";
import { Heading, Link, Text } from "@vector-im/compound-web";

View File

@ -19,7 +19,6 @@ import {
RoomContext,
useLocalParticipant,
} from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays";
import { ConnectionState, Room } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
@ -44,10 +43,10 @@ import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
HangupButton,
EndCallButton,
MicButton,
VideoButton,
ScreenshareButton,
ShareScreenButton,
SettingsButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
@ -175,7 +174,6 @@ export const InCallView: FC<InCallViewProps> = ({
connState,
onShareClick,
}) => {
usePreventScroll();
useWakeLock();
useEffect(() => {
@ -492,14 +490,14 @@ export const InCallView: FC<InCallViewProps> = ({
<MicButton
key="1"
muted={!muteStates.audio.enabled}
onPress={toggleMicrophone}
onClick={toggleMicrophone}
disabled={muteStates.audio.setEnabled === null}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
muted={!muteStates.video.enabled}
onPress={toggleCamera}
onClick={toggleCamera}
disabled={muteStates.video.setEnabled === null}
data-testid="incall_videomute"
/>,
@ -507,21 +505,21 @@ export const InCallView: FC<InCallViewProps> = ({
if (!reducedControls) {
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ScreenshareButton
<ShareScreenButton
key="3"
enabled={isScreenShareEnabled}
onPress={toggleScreensharing}
onClick={toggleScreensharing}
data-testid="incall_screenshare"
/>,
);
}
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
buttons.push(<SettingsButton key="4" onClick={openSettings} />);
}
buttons.push(
<HangupButton
<EndCallButton
key="6"
onPress={function (): void {
onClick={function (): void {
onLeave();
}}
data-testid="incall_leave"

View File

@ -16,7 +16,7 @@ limitations under the License.
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Room } from "matrix-js-sdk";
import { Room } from "matrix-js-sdk/src/matrix";
import { Button, Text } from "@vector-im/compound-web";
import {
LinkIcon,

View File

@ -19,7 +19,6 @@ limitations under the License.
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-bg-canvas-default);
box-shadow: 0px 0px 40px 0px rgba(0, 0, 0, 0.5);
display: flex;
position: relative;
}
@ -32,9 +31,9 @@ limitations under the License.
inline-size: var(--cpd-space-11x);
cursor: pointer;
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-primary);
background: var(--cpd-color-bg-action-secondary-rest);
box-shadow: var(--small-drop-shadow);
transition: background-color 0.1s;
}
.toggle svg {
@ -43,6 +42,7 @@ limitations under the License.
padding: calc(2.5 * var(--cpd-space-1x));
pointer-events: none;
color: var(--cpd-color-icon-primary);
transition: color 0.1s;
}
.toggle svg:nth-child(2) {
@ -61,7 +61,7 @@ limitations under the License.
}
.toggle input:active {
background: var(--cpd-color-bg-action-secondary-hovered);
background: var(--cpd-color-bg-action-secondary-pressed);
box-shadow: none;
}
@ -80,7 +80,7 @@ limitations under the License.
}
.toggle input:checked:active {
background: var(--cpd-color-bg-action-primary-hovered);
background: var(--cpd-color-bg-action-primary-pressed);
}
.toggle input:first-child {

View File

@ -17,7 +17,7 @@ limitations under the License.
import { FC, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Button, Link } from "@vector-im/compound-web";
import { Button } from "@vector-im/compound-web";
import classNames from "classnames";
import { useHistory } from "react-router-dom";
@ -29,7 +29,7 @@ import { MatrixInfo, VideoPreview } from "./VideoPreview";
import { MuteStates } from "./MuteStates";
import { InviteButton } from "../button/InviteButton";
import {
HangupButton,
EndCallButton,
MicButton,
SettingsButton,
VideoButton,
@ -37,6 +37,7 @@ import {
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
interface Props {
client: MatrixClient;
@ -92,7 +93,7 @@ export const LobbyView: FC<Props> = ({
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
const recentsButton = !confineToRoom && (
<Link className={styles.recents} href="#" onClick={onLeaveClick}>
<Link className={styles.recents} to="/">
{t("lobby.leave_button")}
</Link>
);
@ -140,16 +141,16 @@ export const LobbyView: FC<Props> = ({
<div className={inCallStyles.buttons}>
<MicButton
muted={!muteStates.audio.enabled}
onPress={onAudioPress}
onClick={onAudioPress}
disabled={muteStates.audio.setEnabled === null}
/>
<VideoButton
muted={!muteStates.video.enabled}
onPress={onVideoPress}
onClick={onVideoPress}
disabled={muteStates.video.setEnabled === null}
/>
<SettingsButton onPress={openSettings} />
{!confineToRoom && <HangupButton onPress={onLeaveClick} />}
<SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
</div>
</div>
</div>

View File

@ -16,9 +16,9 @@ limitations under the License.
import { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web";
import { Modal, Props as ModalProps } from "../Modal";
import { Button } from "../button";
import { FieldRow, ErrorMessage } from "../input/Input";
import { useSubmitRageshake } from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
@ -52,7 +52,7 @@ export const RageshakeRequestModal: FC<Props> = ({
<Body>{t("rageshake_request_modal.body")}</Body>
<FieldRow>
<Button
onPress={(): void =>
onClick={(): void =>
void submitRageshake({
sendLogs: true,
rageshakeRequestId,

View File

@ -18,9 +18,9 @@ import { FC, useCallback, useState } from "react";
import { useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { Button } from "@vector-im/compound-web";
import styles from "./RoomAuthView.module.css";
import { Button } from "../button";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";

View File

@ -35,10 +35,7 @@ import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
import { useOptInAnalytics } from "../settings/settings";
export const RoomPage: FC = () => {
const {
@ -83,7 +80,7 @@ export const RoomPage: FC = () => {
registerPasswordlessUser,
]);
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);

View File

@ -26,7 +26,7 @@ import { SyncState } from "matrix-js-sdk/src/sync";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { RoomEvent, Room } from "matrix-js-sdk/src/models/room";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { JoinRule } from "matrix-js-sdk";
import { JoinRule } from "matrix-js-sdk/src/matrix";
import { useTranslation } from "react-i18next";
import { widget } from "../widget";

View File

@ -1,64 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 { useEffect } from "react";
import { platform } from "../Platform";
export function usePageUnload(callback: () => void): void {
useEffect(() => {
let pageVisibilityTimeout: ReturnType<typeof setTimeout>;
function onBeforeUnload(event: PageTransitionEvent): void {
if (event.type === "visibilitychange") {
if (document.visibilityState === "visible") {
clearTimeout(pageVisibilityTimeout);
} else {
// Wait 5 seconds before closing the page to avoid accidentally leaving
// TODO: Make this configurable?
pageVisibilityTimeout = setTimeout(() => {
callback();
}, 5000);
}
} else {
callback();
}
}
// iOS doesn't fire beforeunload event, so leave the call when you hide the page.
if (platform === "ios") {
window.addEventListener("pagehide", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
document.addEventListener("visibilitychange", onBeforeUnload);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.addEventListener("beforeunload", onBeforeUnload);
return (): void => {
window.removeEventListener("pagehide", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
document.removeEventListener("visibilitychange", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.removeEventListener("beforeunload", onBeforeUnload);
clearTimeout(pageVisibilityTimeout);
};
}, [callback]);
}

View File

@ -17,12 +17,11 @@ limitations under the License.
import { FC, useCallback } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
import { Body } from "../typography/Typography";
import styles from "../input/SelectInput.module.css";
import feedbackStyles from "../input/FeedbackInput.module.css";
interface Props {
@ -62,7 +61,7 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
return (
<div>
<h4 className={styles.label}>{t("settings.feedback_tab_h4")}</h4>
<h4>{t("settings.feedback_tab_h4")}</h4>
<Body>{t("settings.feedback_tab_body")}</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>

View File

@ -16,8 +16,8 @@ limitations under the License.
import { useTranslation } from "react-i18next";
import { FC, useCallback } from "react";
import { Button } from "@vector-im/compound-web";
import { Button } from "../button";
import { Config } from "../config/Config";
import styles from "./RageshakeButton.module.css";
import { useSubmitRageshake } from "./submit-rageshake";
@ -52,9 +52,7 @@ export const RageshakeButton: FC<Props> = ({ description }) => {
logsComponent = (
<Button
size="lg"
variant="default"
onPress={sendDebugLogs}
onClick={sendDebugLogs}
className={styles.wideButton}
disabled={sending}
>

View File

@ -14,21 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ChangeEvent, FC, Key, ReactNode, useCallback } from "react";
import { Item } from "@react-stately/collections";
import { ChangeEvent, FC, ReactNode, useCallback } from "react";
import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Dropdown } from "@vector-im/compound-web";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "../tabs/Tabs";
import AudioIcon from "../icons/Audio.svg?react";
import VideoIcon from "../icons/Video.svg?react";
import DeveloperIcon from "../icons/Developer.svg?react";
import OverflowIcon from "../icons/Overflow.svg?react";
import UserIcon from "../icons/User.svg?react";
import FeedbackIcon from "../icons/Feedback.svg?react";
import { SelectInput } from "../input/SelectInput";
import { Tab, TabContainer } from "../tabs/Tabs";
import { FieldRow, InputField } from "../input/Input";
import { Caption } from "../typography/Typography";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
@ -42,9 +35,9 @@ import {
import { widget } from "../widget";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
developerSettingsTab as developerSettingsTabSetting,
duplicateTiles as duplicateTilesSetting,
useOptInAnalytics,
} from "./settings";
import { isFirefox } from "../Platform";
@ -77,7 +70,7 @@ export const SettingsModal: FC<Props> = ({
}) => {
const { t } = useTranslation();
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [developerSettingsTab, setDeveloperSettingsTab] = useSetting(
developerSettingsTabSetting,
);
@ -90,24 +83,30 @@ export const SettingsModal: FC<Props> = ({
): ReactNode => {
if (devices.available.length == 0) return null;
const values = devices.available.map(
({ deviceId, label }, index) =>
[
deviceId,
!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`,
] as [string, string],
);
return (
<SelectInput
<Dropdown
label={caption}
selectedKey={
defaultValue={
devices.selectedId === "" || !devices.selectedId
? "default"
: devices.selectedId
}
onSelectionChange={(id): void => devices.select(id.toString())}
>
{devices.available.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`}
</Item>
))}
</SelectInput>
onValueChange={(id): void => devices.select(id)}
values={values}
// XXX This is unused because we set a defaultValue. The component
// shouldn't require this prop.
placeholder=""
/>
);
};
@ -125,158 +124,123 @@ export const SettingsModal: FC<Props> = ({
const devices = useMediaDevices();
useMediaDeviceNames(devices, open);
const audioTab = (
<TabItem
key="audio"
title={
<>
<AudioIcon width={16} height={16} />
<span className={styles.tabLabel}>{t("common.audio")}</span>
</>
}
>
{generateDeviceSelection(devices.audioInput, t("common.microphone"))}
{!isFirefox() &&
generateDeviceSelection(
devices.audioOutput,
t("settings.speaker_device_selection_label"),
)}
</TabItem>
);
const videoTab = (
<TabItem
key="video"
title={
<>
<VideoIcon width={16} height={16} />
<span>{t("common.video")}</span>
</>
}
>
{generateDeviceSelection(devices.videoInput, t("common.camera"))}
</TabItem>
);
const profileTab = (
<TabItem
key="profile"
title={
<>
<UserIcon width={15} height={15} />
<span>{t("common.profile")}</span>
</>
}
>
<ProfileSettingsTab client={client} />
</TabItem>
);
const feedbackTab = (
<TabItem
key="feedback"
title={
<>
<FeedbackIcon width={16} height={16} />
<span>{t("settings.feedback_tab_title")}</span>
</>
}
>
<FeedbackSettingsTab roomId={roomId} />
</TabItem>
);
const moreTab = (
<TabItem
key="more"
title={
<>
<OverflowIcon width={16} height={16} />
<span>{t("settings.more_tab_title")}</span>
</>
}
>
<h4>{t("settings.developer_tab_title")}</h4>
<p>
{t("version", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</p>
<FieldRow>
<InputField
id="developerSettingsTab"
type="checkbox"
checked={developerSettingsTab}
label={t("settings.developer_settings_label")}
description={t("settings.developer_settings_label_description")}
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
setDeveloperSettingsTab(event.target.checked)
}
/>
</FieldRow>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
</TabItem>
);
const developerTab = (
<TabItem
key="developer"
title={
<>
<DeveloperIcon width={16} height={16} />
<span>{t("settings.developer_tab_title")}</span>
</>
}
>
<p>
{t("version", {
productName: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</p>
<p>
{t("crypto_version", {
version: client.getCrypto()?.getVersion() || "unknown",
})}
</p>
<p>
{t("matrix_id", {
id: client.getUserId() || "unknown",
})}
</p>
<p>
{t("device_id", {
id: client.getDeviceId() || "unknown",
})}
</p>
<FieldRow>
<InputField
id="duplicateTiles"
type="number"
label={t("settings.duplicate_tiles_label")}
value={duplicateTiles.toString()}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const value = event.target.valueAsNumber;
setDuplicateTiles(Number.isNaN(value) ? 0 : value);
},
[setDuplicateTiles],
const audioTab: Tab<SettingsTab> = {
key: "audio",
name: t("common.audio"),
content: (
<>
{generateDeviceSelection(devices.audioInput, t("common.microphone"))}
{!isFirefox() &&
generateDeviceSelection(
devices.audioOutput,
t("settings.speaker_device_selection_label"),
)}
/>
</FieldRow>
</TabItem>
);
</>
),
};
const videoTab: Tab<SettingsTab> = {
key: "video",
name: t("common.video"),
content: generateDeviceSelection(devices.videoInput, t("common.camera")),
};
const profileTab: Tab<SettingsTab> = {
key: "profile",
name: t("common.profile"),
content: <ProfileSettingsTab client={client} />,
};
const feedbackTab: Tab<SettingsTab> = {
key: "feedback",
name: t("settings.feedback_tab_title"),
content: <FeedbackSettingsTab roomId={roomId} />,
};
const moreTab: Tab<SettingsTab> = {
key: "more",
name: t("settings.more_tab_title"),
content: (
<>
<h4>{t("settings.developer_tab_title")}</h4>
<p>
{t("version", {
productName: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</p>
<FieldRow>
<InputField
id="developerSettingsTab"
type="checkbox"
checked={developerSettingsTab}
label={t("settings.developer_settings_label")}
description={t("settings.developer_settings_label_description")}
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
setDeveloperSettingsTab(event.target.checked)
}
/>
</FieldRow>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
</>
),
};
const developerTab: Tab<SettingsTab> = {
key: "developer",
name: t("settings.developer_tab_title"),
content: (
<>
<p>
{t("version", {
productName: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</p>
<p>
{t("crypto_version", {
version: client.getCrypto()?.getVersion() || "unknown",
})}
</p>
<p>
{t("matrix_id", {
id: client.getUserId() || "unknown",
})}
</p>
<p>
{t("device_id", {
id: client.getDeviceId() || "unknown",
})}
</p>
<FieldRow>
<InputField
id="duplicateTiles"
type="number"
label={t("settings.duplicate_tiles_label")}
value={duplicateTiles.toString()}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const value = event.target.valueAsNumber;
setDuplicateTiles(Number.isNaN(value) ? 0 : value);
},
[setDuplicateTiles],
)}
/>
</FieldRow>
</>
),
};
const tabs = [audioTab, videoTab];
if (widget === null) tabs.push(profileTab);
@ -289,14 +253,14 @@ export const SettingsModal: FC<Props> = ({
className={styles.settingsModal}
open={open}
onDismiss={onDismiss}
tabbed
>
<TabContainer
onSelectionChange={onTabChange as (tab: Key) => void}
selectedKey={tab}
className={styles.tabContainer}
>
{tabs}
</TabContainer>
label={t("common.settings")}
tab={tab}
onTabChange={onTabChange}
tabs={tabs}
/>
</Modal>
);
};

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,60 +21,16 @@ limitations under the License.
}
.tabList {
display: flex;
list-style: none;
padding: 0;
margin: 0 auto 24px auto;
gap: 16px;
overflow-y: auto;
max-width: 100%;
/*no scrollbars*/
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.tab {
height: 32px;
box-sizing: border-box;
border-radius: 8px;
background-color: transparent;
display: flex;
align-items: center;
padding: 0 8px;
border: none;
cursor: pointer;
font-size: var(--font-size-body);
}
.tab > * {
color: var(--cpd-color-text-secondary);
margin: 0 8px 0 0;
}
.tab svg * {
fill: var(--cpd-color-text-secondary);
}
.tab > :last-child {
margin-right: 0;
}
.tab.selected {
background-color: var(--cpd-color-text-action-accent);
}
.tab.selected * {
color: var(--stopgap-color-on-solid-accent);
}
.tab.selected svg * {
fill: var(--stopgap-color-on-solid-accent);
}
.tab.disabled {
}
.tabPanel {
display: flex;
flex-direction: column;
flex: 1;
padding: 0;
overflow-y: auto;
.tabList::-webkit-scrollbar {
/*no scrollbars*/
background: transparent; /* Chrome/Safari/Webkit */
width: 0px;
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,74 +14,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useRef } from "react";
import { useTabList, useTab, useTabPanel } from "@react-aria/tabs";
import { Item } from "@react-stately/collections";
import { useTabListState, TabListState } from "@react-stately/tabs";
import classNames from "classnames";
import { AriaTabPanelProps, TabListProps } from "@react-types/tabs";
import { Node } from "@react-types/shared";
import { Key, ReactNode, useId } from "react";
import { NavBar, NavItem } from "@vector-im/compound-web";
import styles from "./Tabs.module.css";
interface TabContainerProps<T> extends TabListProps<T> {
className?: string;
export interface Tab<K extends Key> {
key: K;
name: string;
content: ReactNode;
}
export function TabContainer<T extends object>(
props: TabContainerProps<T>,
): JSX.Element {
const state = useTabListState<T>(props);
const ref = useRef<HTMLUListElement>(null);
const { tabListProps } = useTabList(props, state, ref);
interface Props<K extends Key> {
label: string;
tab: K;
onTabChange: (key: K) => void;
tabs: Tab<K>[];
}
export function TabContainer<K extends Key>({
label,
tab,
onTabChange,
tabs,
}: Props<K>): ReactNode {
const idPrefix = useId();
return (
<div className={classNames(styles.tabContainer, props.className)}>
<ul {...tabListProps} ref={ref} className={styles.tabList}>
{[...state.collection].map((item) => (
<Tab item={item} state={state} key={item.key} />
<div className={styles.tabContainer}>
<NavBar role="tablist" aria-label={label} className={styles.tabList}>
{tabs.map(({ key, name }) => (
<NavItem
aria-controls={`${idPrefix}[${key}]`}
onClick={() => onTabChange(key)}
active={key === tab}
>
{name}
</NavItem>
))}
</ul>
<TabPanel key={state.selectedItem?.key} state={state} />
</NavBar>
{tabs.map(({ key, content }) => (
<div
id={`${idPrefix}[${key}]`}
style={{ display: key === tab ? undefined : "none" }}
>
{content}
</div>
))}
</div>
);
}
interface TabProps<T> {
item: Node<T>;
state: TabListState<T>;
}
function Tab<T>({ item, state }: TabProps<T>): JSX.Element {
const { key, rendered } = item;
const ref = useRef<HTMLLIElement>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<li
{...tabProps}
ref={ref}
className={classNames(styles.tab, {
[styles.selected]: state.selectedKey === key,
[styles.disabled]: state.disabledKeys.has(key),
})}
>
{rendered}
</li>
);
}
interface TabPanelProps<T> extends AriaTabPanelProps {
state: TabListState<T>;
}
function TabPanel<T>({ state, ...props }: TabPanelProps<T>): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className={styles.tabPanel}>
{state.selectedItem?.props.children}
</div>
);
}
export const TabItem = Item;

View File

@ -142,7 +142,9 @@ unconditionally select the container so we can use cqmin units */
box-shadow: var(--small-drop-shadow);
cursor: pointer;
opacity: 0;
transition: opacity ease 0.15s;
transition:
opacity 0.15s,
background-color 0.1s;
}
.fg:has(:focus-visible) > button,

View File

@ -99,8 +99,9 @@ limitations under the License.
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-alpha-gray-1400);
box-shadow: var(--small-drop-shadow);
transition-duration: 0.1s;
transition-property: opacity, background-color;
transition:
opacity 0.15s,
background-color 0.1s;
position: absolute;
z-index: 1;
--inset: 6px;

View File

@ -35,7 +35,7 @@ import { useObservableEagerState } from "observable-hooks";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { RoomMember } from "matrix-js-sdk";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css";

View File

@ -22,6 +22,12 @@ import userEvent from "@testing-library/user-event";
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
// Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`.
// The TestComponent just wraps a button around that hook.
// - We need to set `userEvent` to the `{document = window.document}` since we are testing the
// `useCallViewKeyboardShortcuts` hook here. Which is listening to window.
interface TestComponentProps {
setMicrophoneMuted: (muted: boolean) => void;
onButtonClick?: () => void;
@ -40,24 +46,33 @@ const TestComponent: FC<TestComponentProps> = ({
);
return (
<div ref={ref}>
<Button onClick={onButtonClick}>I'm a button</Button>
<Button onClick={onButtonClick}>TEST</Button>
</div>
);
};
test("spacebar unmutes", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ document: window.document });
let muted = true;
render(<TestComponent setMicrophoneMuted={(m) => (muted = m)} />);
render(
<TestComponent
onButtonClick={() => (muted = false)}
setMicrophoneMuted={(m) => {
muted = m;
}}
/>,
);
await user.keyboard("[Space>]");
expect(muted).toBe(false);
await user.keyboard("[/Space]");
expect(muted).toBe(true);
});
test("spacebar prioritizes pressing a button", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ document: window.document });
const setMuted = vi.fn();
const onClick = vi.fn();
render(
@ -71,7 +86,7 @@ test("spacebar prioritizes pressing a button", async () => {
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ document: window.document });
const defaultPrevented = vi.fn();
// In the real application, we mostly just want the spacebar shortcut to avoid
// scrolling the page. But to test that here in JSDOM, we need some kind of

View File

@ -15,7 +15,6 @@ limitations under the License.
*/
import { useEffect } from "react";
import EventEmitter, { EventMap } from "typed-emitter";
import type {
Listener,
@ -60,20 +59,3 @@ export function useTypedEventEmitter<
};
}, [emitter, eventType, listener]);
}
// Shortcut for registering a listener on an eventemitter3 EventEmitter (ie. what the LiveKit SDK uses)
export function useEventEmitterThree<
EventType extends keyof T,
T extends EventMap,
>(
emitter: EventEmitter<T>,
eventType: EventType,
listener: T[EventType],
): void {
useEffect(() => {
emitter.on(eventType, listener);
return (): void => {
emitter.off(eventType, listener);
};
}, [emitter, eventType, listener]);
}

View File

@ -50,9 +50,6 @@ export const useLocalStorage = (
];
};
export const getLocalStorageItem = (key: string): LocalStorageItem =>
localStorage.getItem(key);
export const setLocalStorageItem = (key: string, value: string): void => {
localStorage.setItem(key, value);
localStorageBus.emit(key, value);

View File

@ -1,27 +0,0 @@
/*
Copyright 2023 New Vector Ltd
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 { useRef } from "react";
/**
* React hook that returns the value given on the previous render.
*/
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
const previous = ref.current;
ref.current = value;
return previous;
}

View File

@ -1,44 +0,0 @@
/*
Copyright 2023 New Vector Ltd
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.
*/
/**
* Gets the index of the last element in the array to satsify the given
* predicate.
*/
// TODO: remove this once TypeScript recognizes the existence of
// Array.prototype.findLastIndex
export function findLastIndex<T>(
array: T[],
predicate: (item: T, index: number) => boolean,
): number | null {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i], i)) return i;
}
return null;
}
/**
* Counts the number of elements in an array that satsify the given predicate.
*/
export const count = <T>(
array: T[],
predicate: (item: T, index: number) => boolean,
): number =>
array.reduce(
(acc, item, index) => (predicate(item, index) ? acc + 1 : acc),
0,
);

View File

@ -16,9 +16,6 @@ limitations under the License.
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
import {
createClient,
ICreateClientOpts,
@ -41,18 +38,7 @@ import { EncryptionSystem, saveKeyForRoom } from "../e2ee/sharedKeyManagement";
export const fallbackICEServerAllowed =
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
export class CryptoStoreIntegrityError extends Error {
public constructor() {
super("Crypto store data was expected, but none was found");
}
}
const SYNC_STORE_NAME = "element-call-sync";
// Note that the crypto store name has changed from previous versions
// deliberately in order to force a logout for all users due to
// https://github.com/vector-im/element-call/issues/464
// (It's a good opportunity to make the database names consistent.)
const CRYPTO_STORE_NAME = "element-call-crypto";
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
@ -85,9 +71,8 @@ async function waitForSync(client: MatrixClient): Promise<void> {
/**
* Initialises and returns a new standalone Matrix Client.
* If true is passed for the 'restore' parameter, a check will be made
* to ensure that corresponding crypto data is stored and recovered.
* If the check fails, CryptoStoreIntegrityError will be thrown.
* If false is passed for the 'restore' parameter, corresponding crypto
* data is cleared before the client initialization.
* @param clientOptions Object of options passed through to the client
* @param restore Whether the session is being restored from storage
* @returns The MatrixClient instance
@ -124,45 +109,6 @@ export async function initClient(
baseOpts.store = new MemoryStore({ localStorage });
}
// Check whether we have crypto data store. If we are restoring a session
// from storage then we will have started the crypto store and therefore
// have generated keys for that device, so if we can't recover those keys,
// we must not continue or we'll generate new keys and anyone who saw our
// previous keys will not accept our new key.
// It's worth mentioning here that if support for indexeddb or localstorage
// appears or disappears between sessions (it happens) then the failure mode
// here will be that we'll try a different store, not find crypto data and
// fail to restore the session. An alternative would be to continue using
// whatever we were using before, but that could be confusing since you could
// enable indexeddb and but the app would still not be using it.
if (restore) {
if (indexedDB) {
const cryptoStoreExists = await IndexedDBCryptoStore.exists(
indexedDB,
CRYPTO_STORE_NAME,
);
if (!cryptoStoreExists) throw new CryptoStoreIntegrityError();
} else if (localStorage) {
if (!LocalStorageCryptoStore.exists(localStorage))
throw new CryptoStoreIntegrityError();
} else {
// if we get here then we're using the memory store, which cannot
// possibly have remembered a session, so it's an error.
throw new CryptoStoreIntegrityError();
}
}
if (indexedDB) {
baseOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
CRYPTO_STORE_NAME,
);
} else if (localStorage) {
baseOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
} else {
baseOpts.cryptoStore = new MemoryCryptoStore();
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
@ -184,18 +130,34 @@ export async function initClient(
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of registering a new matrix account caused by broken store state. This is particularly needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - Need to make sure any possible leftover crypto store gets cleared.
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
// This would result in conflicting crypto store userId vs matrixClient userId. Caused by EC 0.6.1
if (!restore) {
client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
// start the client store (totally independent to the crypto store)
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client store. Falling back to memory store.",
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
await client.initRustCrypto();
client.setGlobalErrorOnUnknownDevices(false);
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
@ -228,7 +190,7 @@ function fullAliasFromRoomName(roomName: string, client: MatrixClient): string {
* @param client A matrix client object
*/
export function sanitiseRoomNameInput(input: string): string {
// check to see if the user has enetered a fully qualified room
// check to see if the user has entered a fully qualified room
// alias. If so, turn it into just the localpart because that's what
// we use
const parts = input.split(":", 2);
@ -246,38 +208,6 @@ export function sanitiseRoomNameInput(input: string): string {
return input;
}
/**
* XXX: What is this trying to do? It looks like it's getting the localpart from
* a room alias, but why is it splitting on hyphens and then putting spaces in??
* @param roomId
* @returns
*/
export function roomNameFromRoomId(roomId: string): string {
return roomId
.match(/([^:]+):.*$/)![1]
.substring(1)
.split("-")
.map((part) =>
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part,
)
.join(" ")
.toLowerCase();
}
export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
if (!roomId) {
return false;
}
const parts = roomId.match(/[^:]+:(.*)$/)!;
if (parts.length < 2) {
return false;
}
return parts[1] === client.getDomain();
}
interface CreateRoomResult {
roomId: string;
alias?: string;

View File

@ -13,13 +13,12 @@ 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 "global-jsdom/register";
import globalJsdom from "global-jsdom";
import i18n from "i18next";
import posthog from "posthog-js";
import { initReactI18next } from "react-i18next";
import { afterEach } from "vitest";
import { afterEach, beforeEach } from "vitest";
import { cleanup } from "@testing-library/react";
import { Config } from "./config/Config";
@ -36,4 +35,12 @@ i18n.use(initReactI18next).init({
Config.initDefault();
posthog.opt_out_capturing();
afterEach(cleanup);
// We need to cleanup the global jsDom
// Otherwise we will run into issues with async input test overlapping and throwing.
let cleanupJsDom: { (): void };
beforeEach(() => (cleanupJsDom = globalJsdom()));
afterEach(() => {
cleanupJsDom();
cleanup();
});

View File

@ -31,21 +31,6 @@ export enum ElementWidgetActions {
HangupCall = "im.vector.hangup",
TileLayout = "io.element.tile_layout",
SpotlightLayout = "io.element.spotlight_layout",
// Element Call -> host requesting to start a screenshare
// (ie. expects a ScreenshareStart once the user has picked a source)
// Element Call -> host requesting to start a screenshare
// (ie. expects a ScreenshareStart once the user has picked a source)
// replies with { pending } where pending is true if the host has asked
// the user to choose a window and false if not (ie. if the host isn't
// running within Electron)
ScreenshareRequest = "io.element.screenshare_request",
// host -> Element Call telling EC to start screen sharing with
// the given source
ScreenshareStart = "io.element.screenshare_start",
// host -> Element Call telling EC to stop screen sharing, or that
// the user cancelled when selecting a source after a ScreenshareRequest
ScreenshareStop = "io.element.screenshare_stop",
// This can be sent as from or to widget
// fromWidget: updates the client about the current device mute state
// toWidget: the client requests a specific device mute configuration
@ -68,10 +53,6 @@ export interface JoinCallData {
videoInput: string | null;
}
export interface ScreenshareStartData {
desktopCapturerSourceId: string;
}
export interface WidgetHelpers {
api: WidgetApi;
lazyActions: LazyEventEmitter;
@ -101,8 +82,6 @@ export const widget = ((): WidgetHelpers | null => {
ElementWidgetActions.HangupCall,
ElementWidgetActions.TileLayout,
ElementWidgetActions.SpotlightLayout,
ElementWidgetActions.ScreenshareStart,
ElementWidgetActions.ScreenshareStop,
ElementWidgetActions.DeviceMute,
].forEach((action) => {
api.on(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {

View File

@ -1,5 +1,5 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
import viteConfig from "./vite.config.js";
export default defineConfig((configEnv) =>
mergeConfig(

1202
yarn.lock

File diff suppressed because it is too large Load Diff