mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
Merge branch 'livekit' into toger5/media-device-switcher
This commit is contained in:
1
.github/workflows/build.yaml
vendored
1
.github/workflows/build.yaml
vendored
@@ -88,6 +88,7 @@ jobs:
|
|||||||
|
|
||||||
build_storybook:
|
build_storybook:
|
||||||
name: Build Storybook
|
name: Build Storybook
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
7
.github/workflows/deploy-to-netlify.yaml
vendored
7
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -18,11 +18,6 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
|
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
|
||||||
environment_name:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: NetlifyDefault
|
|
||||||
description: The GitHub deployment environment label shown in the PR (e.g. 'Netlify', 'Storybook')
|
|
||||||
artifact_run_id:
|
artifact_run_id:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
@@ -48,7 +43,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
step: start
|
step: start
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
env: TestName
|
env: ${{ inputs.package}}
|
||||||
ref: ${{ inputs.deployment_ref }}
|
ref: ${{ inputs.deployment_ref }}
|
||||||
desc: |
|
desc: |
|
||||||
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
||||||
|
|||||||
18
.github/workflows/pr-deploy.yaml
vendored
18
.github/workflows/pr-deploy.yaml
vendored
@@ -84,6 +84,24 @@ jobs:
|
|||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
|
netlify-storybook:
|
||||||
|
needs: prdetails
|
||||||
|
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'storybook build') }}
|
||||||
|
permissions:
|
||||||
|
deployments: write
|
||||||
|
uses: ./.github/workflows/deploy-to-netlify.yaml
|
||||||
|
with:
|
||||||
|
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
|
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||||
|
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
|
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||||
|
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||||
|
package: storybook
|
||||||
|
secrets:
|
||||||
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
||||||
needs: prdetails
|
needs: prdetails
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Synapse reverse proxy including .well-known/matrix/client
|
# Synapse reverse proxy including .well-known/matrix/client
|
||||||
@@ -91,8 +89,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# MatrixRTC reverse proxy
|
# MatrixRTC reverse proxy
|
||||||
@@ -144,8 +140,6 @@ server {
|
|||||||
proxy_pass http://livekit-sfu:7880/;
|
proxy_pass http://livekit-sfu:7880/;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# MatrixRTC reverse proxy
|
# MatrixRTC reverse proxy
|
||||||
@@ -192,8 +186,6 @@ server {
|
|||||||
proxy_pass http://livekit-sfu-1:17880/;
|
proxy_pass http://livekit-sfu-1:17880/;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convenience reverse proxy for the call.m.localhost domain to element call
|
# Convenience reverse proxy for the call.m.localhost domain to element call
|
||||||
@@ -243,7 +235,6 @@ server {
|
|||||||
proxy_pass http://host.docker.internal:8080;
|
proxy_pass http://host.docker.internal:8080;
|
||||||
|
|
||||||
}
|
}
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +267,6 @@ server {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convenience reverse proxy app.othersite.m.localhost for element web
|
# Convenience reverse proxy app.othersite.m.localhost for element web
|
||||||
@@ -309,6 +298,4 @@ server {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
||||||
/>
|
/>
|
||||||
<title><%- brand %></title>
|
<title><%- brand %></title>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
"glob": "^10.5.0",
|
"glob": "^10.5.0",
|
||||||
"qs": "^6.14.1",
|
"qs": "^6.14.1",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"esbuild": "^0.27.7"
|
"esbuild": "^0.28.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.0"
|
"packageManager": "pnpm@10.33.0"
|
||||||
|
|||||||
1417
pnpm-lock.yaml
generated
1417
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
|||||||
.bar {
|
.bar {
|
||||||
block-size: 64px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar > header {
|
.bar > header {
|
||||||
position: absolute;
|
position: sticky;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
block-size: 64px;
|
block-size: 64px;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ Please see LICENSE in the repository root for full details.
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-inline: var(--inline-content-inset);
|
padding-left: var(--content-inset-left);
|
||||||
|
padding-right: var(--content-inset-right);
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Drawer.Root
|
<Drawer.Root
|
||||||
open={open}
|
open={open}
|
||||||
|
// This autofocus is a custom vault property and not the
|
||||||
|
// standard HTML autofocus attribute.
|
||||||
|
// It makes the Drawer.Root behave like the `DialogRoot`
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
|
autoFocus
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
dismissible={onDismiss !== undefined}
|
dismissible={onDismiss !== undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
grid-template-areas: ". buttons layout";
|
grid-template-areas: ". buttons layout";
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--cpd-space-3x);
|
gap: var(--cpd-space-3x);
|
||||||
padding: var(--cpd-space-10x) var(--cpd-space-6x);
|
/* Ensure that footer lies within the safe area */
|
||||||
|
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
|
||||||
|
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
|
||||||
|
padding-block: var(--cpd-space-10x)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(0, 0, 0, 0) 0%,
|
rgba(0, 0, 0, 0) 0%,
|
||||||
@@ -118,13 +122,15 @@ Once we exceed 500 we hide everything except the buttons.
|
|||||||
|
|
||||||
@media (max-height: 800px) {
|
@media (max-height: 800px) {
|
||||||
.footer {
|
.footer {
|
||||||
padding-block: var(--cpd-space-8x);
|
padding-block: var(--cpd-space-8x)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 400px) {
|
@media (max-height: 400px) {
|
||||||
.footer {
|
.footer {
|
||||||
padding-block: var(--cpd-space-4x);
|
padding-block: var(--cpd-space-4x)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +146,9 @@ Once we exceed 500 we hide everything except the buttons.
|
|||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
padding-block-start: var(--cpd-space-3x);
|
padding-block-start: var(--cpd-space-3x);
|
||||||
padding-block-end: var(--cpd-space-2x);
|
padding-block-end: calc(
|
||||||
|
env(safe-area-inset-bottom) + var(--cpd-space-2x)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ export const UnavailableMediaDevices: Story = {
|
|||||||
...Default,
|
...Default,
|
||||||
args: {
|
args: {
|
||||||
...Default.args,
|
...Default.args,
|
||||||
|
audioEnabled: false,
|
||||||
|
videoEnabled: false,
|
||||||
toggleAudio: undefined,
|
toggleAudio: undefined,
|
||||||
toggleVideo: undefined,
|
toggleVideo: undefined,
|
||||||
audioOutputSwitcher: undefined,
|
audioOutputSwitcher: undefined,
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ export interface ConfigOptions {
|
|||||||
enable_video?: boolean;
|
enable_video?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
|
||||||
|
* This allows brief sync interruptions without triggering a reconnection message.
|
||||||
|
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
|
||||||
|
*/
|
||||||
|
sync_disconnect_grace_period_ms?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These are low level options that are used to configure the MatrixRTC session.
|
* These are low level options that are used to configure the MatrixRTC session.
|
||||||
* Take care when changing these options.
|
* Take care when changing these options.
|
||||||
@@ -155,6 +162,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
|||||||
server_name: string;
|
server_name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
sync_disconnect_grace_period_ms: number;
|
||||||
ssla: string;
|
ssla: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,5 +176,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
|||||||
features: {
|
features: {
|
||||||
feature_use_device_session_member_events: true,
|
feature_use_device_session_member_events: true,
|
||||||
},
|
},
|
||||||
|
sync_disconnect_grace_period_ms: 10000,
|
||||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -266,6 +266,20 @@ export function Grid<
|
|||||||
}, []),
|
}, []),
|
||||||
useCallback(() => window.innerHeight, []),
|
useCallback(() => window.innerHeight, []),
|
||||||
);
|
);
|
||||||
|
const orientation = useSyncExternalStore(
|
||||||
|
useCallback((onChange) => {
|
||||||
|
// Support for the change event is experimental
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event#browser_compatibility
|
||||||
|
(screen as unknown as EventTarget).addEventListener?.("change", onChange);
|
||||||
|
return (): void =>
|
||||||
|
(screen as unknown as EventTarget).removeEventListener?.(
|
||||||
|
"change",
|
||||||
|
onChange,
|
||||||
|
);
|
||||||
|
}, []),
|
||||||
|
useCallback(() => window.innerHeight, []),
|
||||||
|
);
|
||||||
|
|
||||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||||
const [generation, setGeneration] = useState<number | null>(null);
|
const [generation, setGeneration] = useState<number | null>(null);
|
||||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||||
@@ -336,10 +350,10 @@ export function Grid<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
// The rects may change due to the grid resizing or updating to a new
|
// The rects may change due to the grid resizing, changing orientation, or
|
||||||
// generation, but eslint can't statically verify this
|
// updating to a new generation, but eslint can't statically verify this
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
|
}, [gridRoot, layoutRoot, tiles, gridBounds, orientation, generation]);
|
||||||
|
|
||||||
// The height of the portion of the grid visible at any given time
|
// The height of the portion of the grid visible at any given time
|
||||||
const visibleHeight = useMemo(
|
const visibleHeight = useMemo(
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 404px;
|
inline-size: 404px;
|
||||||
block-size: 233px;
|
block-size: 233px;
|
||||||
inset-block: 0;
|
/* Ensure that spotlight tile lies within the safe area */
|
||||||
inset-inline: var(--cpd-space-3x);
|
inset: 0 calc(env(safe-area-inset-right) + var(--cpd-space-3x)) 0
|
||||||
|
calc(env(safe-area-inset-left) + var(--cpd-space-3x));
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed > .slot[data-block-alignment="start"] {
|
.fixed > .slot[data-block-alignment="start"] {
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 135px;
|
inline-size: 135px;
|
||||||
block-size: 160px;
|
block-size: 160px;
|
||||||
inset: var(--cpd-space-4x);
|
/* Ensure that PiP lies within the safe area */
|
||||||
|
inset: calc(env(safe-area-inset-top) + var(--cpd-space-4x))
|
||||||
|
var(--content-inset-right)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x))
|
||||||
|
var(--content-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
|
|||||||
@@ -37,10 +37,20 @@ layer(compound);
|
|||||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||||
/* The distance to inset non-full-width content from the edge of the window
|
/* The distance to inset non-full-width content from the edge of the window
|
||||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||||
96px for typical desktop windows. */
|
96px for typical desktop windows, and accounts for the safe area. */
|
||||||
--inline-content-inset: min(
|
--content-inset-left: calc(
|
||||||
var(--cpd-space-24x),
|
env(safe-area-inset-left) +
|
||||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
min(
|
||||||
|
var(--cpd-space-24x),
|
||||||
|
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
--content-inset-right: calc(
|
||||||
|
env(safe-area-inset-right) +
|
||||||
|
min(
|
||||||
|
var(--cpd-space-24x),
|
||||||
|
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-inline: var(--inline-content-inset);
|
padding-left: var(--content-inset-left);
|
||||||
|
padding-right: var(--content-inset-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|||||||
@@ -227,10 +227,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||||
|
|
||||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
|
||||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
|
||||||
useCallViewKeyboardShortcuts(
|
useCallViewKeyboardShortcuts(
|
||||||
containerRef1,
|
|
||||||
toggleAudio,
|
toggleAudio,
|
||||||
toggleVideo,
|
toggleVideo,
|
||||||
setAudioEnabled,
|
setAudioEnabled,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { usePageTitle } from "../usePageTitle";
|
|||||||
import { getValue } from "../utils/observable";
|
import { getValue } from "../utils/observable";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
import { CallFooter } from "../components/CallFooter";
|
import { CallFooter } from "../components/CallFooter";
|
||||||
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -91,6 +92,11 @@ export const LobbyView: FC<Props> = ({
|
|||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||||
|
|
||||||
|
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||||
|
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||||
|
// Next to the keyboard shortcuts, this is also responsible for catching escape key presses and forwarding the to mobile -> pip.
|
||||||
|
useCallViewKeyboardShortcuts(toggleAudio, toggleVideo, null, null, null);
|
||||||
|
|
||||||
const openSettings = useCallback(
|
const openSettings = useCallback(
|
||||||
() => setSettingsModalOpen(true),
|
() => setSettingsModalOpen(true),
|
||||||
[setSettingsModalOpen],
|
[setSettingsModalOpen],
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
margin-inline: var(--inline-content-inset);
|
margin-left: var(--content-inset-left);
|
||||||
|
margin-right: var(--content-inset-right);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
block-size: 50vh;
|
block-size: 50vh;
|
||||||
border-radius: var(--cpd-space-4x);
|
border-radius: var(--cpd-space-4x);
|
||||||
@@ -80,6 +81,7 @@ video.mirror {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.buttonBar {
|
.buttonBar {
|
||||||
padding-inline: var(--inline-content-inset);
|
padding-left: var(--content-inset-left);
|
||||||
|
padding-right: var(--content-inset-right);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
|
|||||||
|
|
||||||
import { ObservableScope } from "../../ObservableScope";
|
import { ObservableScope } from "../../ObservableScope";
|
||||||
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
||||||
|
import { testScope, withTestScheduler } from "../../../utils/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal stub of a Matrix client sufficient for our tests:
|
* Minimal stub of a Matrix client sufficient for our tests:
|
||||||
@@ -96,19 +97,20 @@ describe("createHomeserverConnected$", () => {
|
|||||||
|
|
||||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||||
// easy enough to read them so I think they can stay.
|
// easy enough to read them so I think they can stay.
|
||||||
|
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||||
it("is false when sync state is not Syncing", () => {
|
it("is false when sync state is not Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
expect(hsConnected.combined$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// Make sync loop OK
|
// Make sync loop OK
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
// Indicate probable leave before connection
|
// Indicate probable leave before connection
|
||||||
@@ -118,7 +120,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("becomes true only when all three conditions are satisfied", () => {
|
it("becomes true only when all three conditions are satisfied", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// 1. Sync loop connected
|
// 1. Sync loop connected
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
||||||
@@ -128,7 +130,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when sync loop leaves Syncing", () => {
|
it("drops back to false when sync loop leaves Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// Reach connected state
|
// Reach connected state
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
@@ -140,7 +142,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when membership status becomes disconnected", () => {
|
it("drops back to false when membership status becomes disconnected", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
@@ -150,7 +152,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
@@ -160,7 +162,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
@@ -174,7 +176,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("composite sequence reflects each individual failure reason", () => {
|
it("composite sequence reflects each individual failure reason", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
|
||||||
// Initially false (sync error + disconnected + not probably left)
|
// Initially false (sync error + disconnected + not probably left)
|
||||||
expect(hsConnected.combined$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
@@ -200,3 +202,62 @@ describe("createHomeserverConnected$", () => {
|
|||||||
expect(hsConnected.combined$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createHomeserverConnected$ - Grace Period", () => {
|
||||||
|
const GRACE_PERIOD = 5;
|
||||||
|
|
||||||
|
function marbleTest(
|
||||||
|
syncStateMarbles: string,
|
||||||
|
expectedConnectedMarbles: string,
|
||||||
|
): void {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
const syncState$ = behavior(syncStateMarbles, {
|
||||||
|
s: SyncState.Syncing,
|
||||||
|
e: SyncState.Error,
|
||||||
|
});
|
||||||
|
const client = new MockMatrixClient(syncState$.value);
|
||||||
|
schedule(syncStateMarbles, {
|
||||||
|
s: () => client.setSyncState(SyncState.Syncing),
|
||||||
|
e: () => client.setSyncState(SyncState.Error),
|
||||||
|
});
|
||||||
|
const session = new MockMatrixRTCSession({
|
||||||
|
membershipStatus: Status.Connected,
|
||||||
|
probablyLeft: false,
|
||||||
|
});
|
||||||
|
const hsConnected = createHomeserverConnected$(
|
||||||
|
testScope(),
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
GRACE_PERIOD,
|
||||||
|
);
|
||||||
|
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
|
||||||
|
y: true,
|
||||||
|
n: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
|
||||||
|
// - Initial state: Everything is connected
|
||||||
|
// - Sync error occurs -> should remain connected due to grace period
|
||||||
|
// - After grace period, not connected
|
||||||
|
marbleTest("se", "y-----n");
|
||||||
|
// If the sync error takes longer to occur, it should take equally long for
|
||||||
|
// the connection state to change
|
||||||
|
marbleTest("s--e", "y-------n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers immediately if sync returns during grace period", () => {
|
||||||
|
// - Initial state: Connected
|
||||||
|
// - Sync error occurs
|
||||||
|
// - Sync recovers BEFORE the grace period expires
|
||||||
|
// - Connection state remains constant
|
||||||
|
marbleTest("se--s", "y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
|
||||||
|
// - Initial error: connection eventually flips to false
|
||||||
|
// - Back to Syncing -> Must be connected immediately (synchronously)
|
||||||
|
marbleTest("e-----s", "y----ny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ import {
|
|||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||||
import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
|
import {
|
||||||
|
fromEvent,
|
||||||
|
startWith,
|
||||||
|
map,
|
||||||
|
tap,
|
||||||
|
type Observable,
|
||||||
|
distinctUntilChanged,
|
||||||
|
switchMap,
|
||||||
|
of,
|
||||||
|
delay,
|
||||||
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { Config } from "../../../config/Config";
|
||||||
import { type ObservableScope } from "../../ObservableScope";
|
import { type ObservableScope } from "../../ObservableScope";
|
||||||
import { type Behavior } from "../../Behavior";
|
import { type Behavior } from "../../Behavior";
|
||||||
import { and$ } from "../../../utils/observable";
|
import { and$ } from "../../../utils/observable";
|
||||||
@@ -35,28 +46,46 @@ export interface HomeserverConnected {
|
|||||||
* for the purposes of a MatrixRTC session.
|
* for the purposes of a MatrixRTC session.
|
||||||
*
|
*
|
||||||
* Becomes FALSE if ANY sub-condition is fulfilled:
|
* Becomes FALSE if ANY sub-condition is fulfilled:
|
||||||
* 1. Sync loop is not in SyncState.Syncing
|
* 1. Sync loop is not in SyncState.Syncing (after grace period)
|
||||||
* 2. membershipStatus !== Status.Connected
|
* 2. membershipStatus !== Status.Connected
|
||||||
* 3. probablyLeft === true
|
* 3. probablyLeft === true
|
||||||
|
*
|
||||||
|
* @param scope - The observable scope for lifecycle management.
|
||||||
|
* @param client - The Matrix client to monitor sync state.
|
||||||
|
* @param matrixRTCSession - The RTC session to monitor membership.
|
||||||
|
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
|
||||||
|
* If not provided, uses the config value (default 10000ms).
|
||||||
*/
|
*/
|
||||||
export function createHomeserverConnected$(
|
export function createHomeserverConnected$(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
matrixRTCSession: NodeStyleEventEmitter &
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
|
gracePeriodMs?: number,
|
||||||
): HomeserverConnected {
|
): HomeserverConnected {
|
||||||
|
// Get grace period from parameter or config (default 10000ms)
|
||||||
|
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||||
|
|
||||||
const syncing$ = (
|
const syncing$ = (
|
||||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||||
).pipe(
|
).pipe(
|
||||||
startWith([client.getSyncState()]),
|
startWith([client.getSyncState()]),
|
||||||
map(([state]) => state === SyncState.Syncing),
|
map(([state]) => state === SyncState.Syncing),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((isSyncing) => {
|
||||||
|
if (isSyncing || graceMs <= 0) {
|
||||||
|
return of(isSyncing);
|
||||||
|
}
|
||||||
|
return of(false).pipe(delay(graceMs), startWith(true));
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rtsSession$ = scope.behavior<Status>(
|
const rtsSession$ = scope.behavior<Status>(
|
||||||
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||||
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||||
),
|
),
|
||||||
Status.Unknown,
|
matrixRTCSession.membershipStatus ?? Status.Unknown,
|
||||||
);
|
);
|
||||||
|
|
||||||
const membershipConnected$ = rtsSession$.pipe(
|
const membershipConnected$ = rtsSession$.pipe(
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
--media-view-fg-inset: 10px;
|
--media-view-fg-inset: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.maximised .item {
|
||||||
|
/* Ensure that foreground elements lie within the safe area */
|
||||||
|
--media-view-fg-inset: 10px calc(env(safe-area-inset-right) + 10px) 10px
|
||||||
|
calc(env(safe-area-inset-left) + 10px);
|
||||||
|
}
|
||||||
|
|
||||||
.item.snap {
|
.item.snap {
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { type FC, useRef, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { Button } from "@vector-im/compound-web";
|
import { Button } from "@vector-im/compound-web";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
@@ -39,9 +39,7 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
initialModalOpen = false,
|
initialModalOpen = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [modalOpen, setModalOpen] = useState(initialModalOpen);
|
const [modalOpen, setModalOpen] = useState(initialModalOpen);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useCallViewKeyboardShortcuts(
|
useCallViewKeyboardShortcuts(
|
||||||
ref,
|
|
||||||
() => {},
|
() => {},
|
||||||
() => {},
|
() => {},
|
||||||
setAudioEnabled,
|
setAudioEnabled,
|
||||||
@@ -49,8 +47,11 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
toggleHandRaised,
|
toggleHandRaised,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<>
|
||||||
<Button onClick={onButtonClick}>TEST</Button>
|
<div id={initialModalOpen ? "root" : undefined}>
|
||||||
|
<Button onClick={onButtonClick}>TEST</Button>
|
||||||
|
</div>
|
||||||
|
{/*// modal lives outside of the root*/}
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<dialog
|
<dialog
|
||||||
open
|
open
|
||||||
@@ -64,7 +65,7 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
<button>InModalButton</button>
|
<button>InModalButton</button>
|
||||||
</dialog>
|
</dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,12 +165,13 @@ test("unmuting happens in place of the default action", async () => {
|
|||||||
// container element that can be interactive and receive focus / keydown
|
// container element that can be interactive and receive focus / keydown
|
||||||
// events. <video> is kind of a weird choice, but it'll do the job.
|
// events. <video> is kind of a weird choice, but it'll do the job.
|
||||||
render(
|
render(
|
||||||
<video
|
<div id="root">
|
||||||
tabIndex={0}
|
<video
|
||||||
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
|
tabIndex={0}
|
||||||
>
|
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
|
||||||
<TestComponent setAudioEnabled={() => {}} />
|
/>
|
||||||
</video>,
|
<TestComponent setAudioEnabled={() => {}} />,
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.tab(); // Focus the <video>
|
await user.tab(); // Focus the <video>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type RefObject, useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { useEventTarget } from "./useEvents";
|
import { useEventTarget } from "./useEvents";
|
||||||
import {
|
import {
|
||||||
@@ -18,22 +19,61 @@ import {
|
|||||||
* Determines whether focus is in the same part of the tree as the given
|
* Determines whether focus is in the same part of the tree as the given
|
||||||
* element (specifically, if the element or an ancestor of it is focused).
|
* element (specifically, if the element or an ancestor of it is focused).
|
||||||
*/
|
*/
|
||||||
const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
|
const mayReceiveKeyEvents = (): boolean => {
|
||||||
const focusedElement = document.activeElement;
|
const root = document.getElementById("root");
|
||||||
return focusedElement !== null && focusedElement.contains(e);
|
if (root === null) {
|
||||||
|
logger.warn(
|
||||||
|
"[mayReceiveKeyEvents] Root element not found, always allow keyboard shortcuts (m,v,esc...)",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const focusElement = document.activeElement;
|
||||||
|
const nothingInFocus = focusElement === null;
|
||||||
|
const focusOnBody = focusElement === document.body;
|
||||||
|
const noPrimaryFocus =
|
||||||
|
nothingInFocus || root.contains(focusElement) || focusOnBody;
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[mayReceiveKeyEvents] nothingInFocus ${nothingInFocus}, focusOnBody ${focusOnBody}, noPrimaryFocus ${noPrimaryFocus}`,
|
||||||
|
);
|
||||||
|
// Only if we do not have a primary focus we allow keyboard shortcut events.
|
||||||
|
return noPrimaryFocus;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only do push to talk behavior if the active element is not a button or button like.
|
||||||
|
*/
|
||||||
|
const mayReceiveSpaceKeyEvents = (): boolean => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (activeElement === null) return true;
|
||||||
|
return activeElement.tagName.toLowerCase() !== "button";
|
||||||
};
|
};
|
||||||
|
|
||||||
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
|
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
|
||||||
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
|
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook sets up gloabl keyboard shortcuts. It will filter for keyboard presses that should be ignored due to user
|
||||||
|
* currently focussing on a modal.
|
||||||
|
* This is achieved by using the fact, that all modal inputs are outside the #root element and use react portals to get rendered.
|
||||||
|
* The following shortcuts are auspported (optional):
|
||||||
|
* @param toggleAudio - triggered on (m)
|
||||||
|
* @param toggleVideo - triggered on (v)
|
||||||
|
* @param setAudioEnabled - push to talk behavior controlled via (space)
|
||||||
|
* @param sendReaction - triggered on (1,2,3,...)
|
||||||
|
* @param toggleHandRaised - triggered on (h)
|
||||||
|
* Additionally this method listens to the (escape) key to trigger the onBackButtonPressed callback, which is used to navigate to pip in the native app.
|
||||||
|
*
|
||||||
|
* Note: This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||||
|
*/
|
||||||
|
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||||
export function useCallViewKeyboardShortcuts(
|
export function useCallViewKeyboardShortcuts(
|
||||||
focusElement: RefObject<HTMLElement | null>,
|
|
||||||
toggleAudio: (() => void) | null,
|
toggleAudio: (() => void) | null,
|
||||||
toggleVideo: (() => void) | null,
|
toggleVideo: (() => void) | null,
|
||||||
setAudioEnabled: ((enabled: boolean) => void) | null,
|
setAudioEnabled: ((enabled: boolean) => void) | null,
|
||||||
sendReaction: (reaction: ReactionOption) => void,
|
sendReaction: ((reaction: ReactionOption) => void) | null,
|
||||||
toggleHandRaised: () => void,
|
toggleHandRaised: (() => void) | null,
|
||||||
): void {
|
): void {
|
||||||
const spacebarHeld = useRef(false);
|
const spacebarHeld = useRef(false);
|
||||||
|
|
||||||
@@ -45,8 +85,8 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
"keydown",
|
"keydown",
|
||||||
useCallback(
|
useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (focusElement.current === null) return;
|
logger.info("Keydown event", event);
|
||||||
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
if (!mayReceiveKeyEvents()) return;
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -56,7 +96,7 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
} else if (event.key === "v") {
|
} else if (event.key === "v") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleVideo?.();
|
toggleVideo?.();
|
||||||
} else if (event.key === " ") {
|
} else if (event.key === " " && mayReceiveSpaceKeyEvents()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!spacebarHeld.current) {
|
if (!spacebarHeld.current) {
|
||||||
spacebarHeld.current = true;
|
spacebarHeld.current = true;
|
||||||
@@ -64,16 +104,16 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
}
|
}
|
||||||
} else if (event.key === "h") {
|
} else if (event.key === "h") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleHandRaised();
|
toggleHandRaised?.();
|
||||||
} else if (KeyToReactionMap[event.key]) {
|
} else if (KeyToReactionMap[event.key]) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
sendReaction(KeyToReactionMap[event.key]);
|
sendReaction?.(KeyToReactionMap[event.key]);
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
|
logger.info("Escape key pressed, triggering onBackButtonPressed");
|
||||||
window.controls.onBackButtonPressed?.();
|
window.controls.onBackButtonPressed?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
focusElement,
|
|
||||||
toggleVideo,
|
toggleVideo,
|
||||||
toggleAudio,
|
toggleAudio,
|
||||||
setAudioEnabled,
|
setAudioEnabled,
|
||||||
@@ -92,15 +132,13 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
"keyup",
|
"keyup",
|
||||||
useCallback(
|
useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (focusElement.current === null) return;
|
if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
|
||||||
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
|
||||||
|
|
||||||
if (event.key === " ") {
|
if (event.key === " ") {
|
||||||
spacebarHeld.current = false;
|
spacebarHeld.current = false;
|
||||||
setAudioEnabled?.(false);
|
setAudioEnabled?.(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusElement, setAudioEnabled],
|
[setAudioEnabled],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user