Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New call layouts #2485

Merged
merged 45 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
20602c1
Implement the new unified grid layout
robintown May 2, 2024
41083c0
Refactor settings to use observables
robintown May 8, 2024
14fc148
Address some review feedback
robintown Jul 12, 2024
599d6fd
Address review feedback
robintown Jul 12, 2024
f847692
Merge pull request #2325 from robintown/unified-grid
robintown Jul 12, 2024
a534356
Merge pull request #2368 from robintown/settings-refactor
robintown Jul 12, 2024
fdc6d4a
Add a developer option to duplicate tiles
robintown May 8, 2024
e33fbd7
Split local and remote user media into different classes
robintown May 16, 2024
8a41401
Add always show flag to view model
robintown May 16, 2024
5647619
Add always show toggle to the UI
robintown Jun 20, 2024
0d485ef
Use always show flag in importance ordering
robintown May 16, 2024
af0bd79
Replace react-rxjs with observable-hooks
robintown May 16, 2024
34c45cb
Get the right grid offset even when offsetParent is a layout element
robintown May 21, 2024
ffbbc74
Implement the new spotlight layout
robintown May 17, 2024
54c22f4
Clean up spotlight tile code
robintown May 28, 2024
ec1b020
Add indicators to spotlight tile and make spotlight layout responsive
robintown May 30, 2024
7f40ce8
Fix advance buttons showing up for the spotlight speaker
robintown May 31, 2024
dfda753
Only switch to spotlight for remote screen shares
robintown Jun 4, 2024
12b719d
Make layout reactivity a little more fine-grained
robintown Jun 4, 2024
183d2d9
Show speaker in the spotlight in large grids
robintown Jun 7, 2024
e0b10d8
Add model for one-on-one layout
robintown Jun 7, 2024
7979493
Implement the new one-on-one layout
robintown Jun 7, 2024
45c89a2
Delete the legacy grid system
robintown Jun 7, 2024
a16f235
Fix crash in spotlight mode while connecting
robintown Jun 12, 2024
2440037
Implement most of the remaining layout changes
robintown Jul 3, 2024
8c21e8f
Use a more descriptive string
robintown Jul 17, 2024
a59875d
Explain what each sorting bin means
robintown Jul 17, 2024
2bc56db
Use fewer ML-style variable names
robintown Jul 17, 2024
e05c6f1
Merge pull request #2369 from robintown/duplicate-tiles
robintown Jul 17, 2024
d4a2617
Merge pull request #2380 from robintown/pin-always-show
robintown Jul 17, 2024
0a8c6c1
Merge branch 'new-call-layouts' into observable-hooks
robintown Jul 17, 2024
caea4b2
Merge pull request #2381 from robintown/observable-hooks
robintown Jul 17, 2024
1efa594
Use Array.some where it's appropriate
robintown Jul 17, 2024
7fcd712
Merge branch 'new-call-layouts' into spotlight-layout
robintown Jul 18, 2024
24870de
Merge pull request #2382 from robintown/spotlight-layout
robintown Jul 18, 2024
e04affe
Justify the use of a participant count threshold
robintown Jul 18, 2024
d561a41
Merge pull request #2416 from robintown/grid-spotlight-speaker
robintown Jul 18, 2024
b4e0df7
Merge branch 'new-call-layouts' into one-on-one-layout
robintown Jul 18, 2024
7526826
Improve aspect ratios on mobile
robintown Jul 18, 2024
bcc06d8
Merge pull request #2417 from robintown/one-on-one-layout
robintown Jul 18, 2024
0664f97
Merge branch 'new-call-layouts' into rest-of-the-layouts
robintown Jul 18, 2024
4955535
Use more consistent names for layout types
robintown Jul 18, 2024
377b7ff
Explain each layout
robintown Jul 18, 2024
6812c35
Merge pull request #2463 from robintown/rest-of-the-layouts
robintown Jul 18, 2024
507b1fc
Merge branch 'livekit' into new-call-layouts
robintown Jul 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,6 @@ module.exports = {
"jsx-a11y/media-has-caption": "off",
// We should use the js-sdk logger, never console directly.
"no-console": ["error"],
"no-restricted-imports": [
"error",
{
name: "@react-rxjs/core",
importNames: ["Subscribe", "RemoveSubscribe"],
message:
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
},
],
"react/display-name": "error",
},
settings: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0",
"@react-rxjs/core": "^0.10.7",
"@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4",
"@react-stately/select": "^3.1.3",
Expand All @@ -66,6 +65,7 @@
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
"matrix-widget-api": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
"postcss-preset-env": "^9.0.0",
"posthog-js": "^1.29.0",
Expand Down
8 changes: 5 additions & 3 deletions public/locales/en-GB/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"analytics": "Analytics",
"audio": "Audio",
"avatar": "Avatar",
"back": "Back",
"camera": "Camera",
"copied": "Copied!",
"display_name": "Display name",
Expand All @@ -49,6 +50,7 @@
"home": "Home",
"loading": "Loading…",
"microphone": "Microphone",
"next": "Next",
"options": "Options",
"password": "Password",
"profile": "Profile",
Expand Down Expand Up @@ -130,6 +132,7 @@
"developer_settings_label": "Developer Settings",
"developer_settings_label_description": "Expose developer settings in the settings window.",
"developer_tab_title": "Developer",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"feedback_tab_description_label": "Your feedback",
"feedback_tab_h4": "Submit feedback",
Expand All @@ -138,7 +141,6 @@
"feedback_tab_title": "Feedback",
"more_tab_title": "More",
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
"show_connection_stats_label": "Show connection stats",
"speaker_device_selection_label": "Speaker"
},
"star_rating_input_label_one": "{{count}} stars",
Expand All @@ -154,12 +156,12 @@
"unmute_microphone_button_label": "Unmute microphone",
"version": "Version: {{version}}",
"video_tile": {
"always_show": "Always show",
"change_fit_contain": "Fit to frame",
"exit_full_screen": "Exit full screen",
"full_screen": "Full screen",
"mute_for_me": "Mute for me",
"sfu_participant_local": "You",
"volume": "Volume"
},
"waiting_for_participants": "Waiting for other participants…"
}
}
2 changes: 1 addition & 1 deletion src/Header.module.css
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
26 changes: 17 additions & 9 deletions src/Header.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,7 +15,7 @@ limitations under the License.
*/

import classNames from "classnames";
import { FC, HTMLAttributes, ReactNode } from "react";
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
Expand All @@ -32,13 +32,21 @@ interface HeaderProps extends HTMLAttributes<HTMLElement> {
className?: string;
}

export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
return (
<header className={classNames(styles.header, className)} {...rest}>
{children}
</header>
);
};
export const Header = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
},
);

Header.displayName = "Header";

interface LeftNavProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
Expand Down
5 changes: 5 additions & 0 deletions src/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ if (/android/i.test(navigator.userAgent)) {
} else {
platform = "desktop";
}

export const isFirefox = (): boolean => {
const { userAgent } = navigator;
return userAgent.includes("Firefox");
};
31 changes: 10 additions & 21 deletions src/analytics/PosthogAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { MatrixClient } from "matrix-js-sdk";
import { Buffer } from "buffer";

import { widget } from "../widget";
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
import {
CallEndedTracker,
CallStartedTracker,
Expand All @@ -35,7 +34,7 @@ import {
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
import { localStorageBus } from "../useLocalStorage";
import { optInAnalytics } from "../settings/settings";

/* Posthog analytics tracking.
*
Expand Down Expand Up @@ -131,7 +130,7 @@ export class PosthogAnalytics {
const { analyticsID } = getUrlParams();
// if the embedding platform (element web) already got approval to communicating with posthog
// element call can also send events to posthog
setSetting("opt-in-analytics", Boolean(analyticsID));
optInAnalytics.setValue(Boolean(analyticsID));
}

this.posthog.init(posthogConfig.project_api_key, {
Expand All @@ -151,9 +150,7 @@ export class PosthogAnalytics {
);
this.enabled = false;
}
this.startListeningToSettingsChanges();
const optInAnalytics = getSetting("opt-in-analytics", false);
this.updateAnonymityAndIdentifyUser(optInAnalytics);
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
}

private sanitizeProperties = (
Expand Down Expand Up @@ -336,8 +333,7 @@ export class PosthogAnalytics {
}

public onLoginStatusChanged(): void {
const optInAnalytics = getSetting("opt-in-analytics", false);
this.updateAnonymityAndIdentifyUser(optInAnalytics);
this.maybeIdentifyUser();
}

private updateSuperProperties(): void {
Expand All @@ -360,20 +356,12 @@ export class PosthogAnalytics {
return this.eventSignup.getSignupEndTime() > new Date(0);
}

private async updateAnonymityAndIdentifyUser(
pseudonymousOptIn: boolean,
): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings
const anonymity = pseudonymousOptIn
? Anonymity.Pseudonymous
: Anonymity.Disabled;
this.setAnonymity(anonymity);

private async maybeIdentifyUser(): Promise<void> {
// We may not yet have a Matrix client at this point, if not, bail. This should get
// triggered again by onLoginStatusChanged once we do have a client.
if (!window.matrixclient) return;

if (anonymity === Anonymity.Pseudonymous) {
if (this.anonymity === Anonymity.Pseudonymous) {
this.setRegistrationType(
window.matrixclient.isGuest() || window.passwordlessUser
? RegistrationType.Guest
Expand All @@ -389,7 +377,7 @@ export class PosthogAnalytics {
}
}

if (anonymity !== Anonymity.Disabled) {
if (this.anonymity !== Anonymity.Disabled) {
this.updateSuperProperties();
}
}
Expand Down Expand Up @@ -419,8 +407,9 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
this.updateAnonymityAndIdentifyUser(optInAnalytics);
optInAnalytics.value.subscribe((optIn) => {
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser();
});
}

Expand Down
159 changes: 159 additions & 0 deletions src/grid/CallLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
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 { BehaviorSubject, Observable } from "rxjs";
import { ComponentType } from "react";

import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
import { LayoutProps } from "./Grid";

export interface Bounds {
width: number;
height: number;
}

export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}

export const defaultSpotlightAlignment: Alignment = {
inline: "end",
block: "end",
};
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };

export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds: Observable<Bounds>;
/**
* The alignment of the floating spotlight tile, if present.
*/
spotlightAlignment: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
*/
pipAlignment: BehaviorSubject<Alignment>;
}

export interface GridTileModel {
type: "grid";
vm: UserMediaViewModel;
}

export interface SpotlightTileModel {
type: "spotlight";
vms: MediaViewModel[];
maximised: boolean;
}

export type TileModel = GridTileModel | SpotlightTileModel;

export interface CallLayoutOutputs<Model> {
/**
* Whether the scrolling layer of the layout should appear on top.
*/
scrollingOnTop: boolean;
/**
* The visually fixed (non-scrolling) layer of the layout.
*/
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
/**
* The layer of the layout that can overflow and be scrolled.
*/
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
}

/**
* A layout system for media tiles.
*/
export type CallLayout<Model> = (
inputs: CallLayoutInputs,
) => CallLayoutOutputs<Model>;

export interface GridArrangement {
tileWidth: number;
tileHeight: number;
gap: number;
columns: number;
}

const tileMinHeight = 130;
const tileMaxAspectRatio = 17 / 9;
const tileMinAspectRatio = 4 / 3;
const tileMobileMinAspectRatio = 2 / 3;

/**
* Determine the ideal arrangement of tiles into a grid of a particular size.
*/
export function arrangeTiles(
width: number,
minHeight: number,
tileCount: number,
): GridArrangement {
// The goal here is to determine the grid size and padding that maximizes
// use of screen space for n tiles without making those tiles too small or
// too cropped (having an extreme aspect ratio)
const gap = width < 800 ? 16 : 20;
const tileMinWidth = width < 500 ? 150 : 180;

let columns = Math.min(
// Don't create more columns than we have items for
tileCount,
// The ideal number of columns is given by a packing of equally-sized
// squares into a grid.
// width / column = height / row.
// columns * rows = number of squares.
// ∴ columns = sqrt(width / height * number of squares).
// Except we actually want 16:9-ish tiles rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio.
Math.ceil(Math.sqrt((width / minHeight / tileMaxAspectRatio) * tileCount)),
);
let rows = Math.ceil(tileCount / columns);

let tileWidth = (width - (columns + 1) * gap) / columns;
let tileHeight = (minHeight - (rows - 1) * gap) / rows;

// Impose a minimum width and height on the tiles
if (tileWidth < tileMinWidth) {
// In this case we want the tile width to determine the number of columns,
// not the other way around. If we take the above equation for the tile
// width (w = (W - (c - 1) * g) / c) and solve for c, we get
// c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (tileMinWidth + gap));
rows = Math.ceil(tileCount / columns);
tileWidth = (width - (columns + 1) * gap) / columns;
tileHeight = (minHeight - (rows - 1) * gap) / rows;
}
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;

// Impose a minimum and maximum aspect ratio on the tiles
const tileAspectRatio = tileWidth / tileHeight;
// We enforce a different min aspect ratio in 1:1s on mobile
const minAspectRatio =
tileCount === 1 && width < 600
? tileMobileMinAspectRatio
: tileMinAspectRatio;
if (tileAspectRatio > tileMaxAspectRatio)
tileWidth = tileHeight * tileMaxAspectRatio;
else if (tileAspectRatio < minAspectRatio)
tileHeight = tileWidth / minAspectRatio;
// TODO: We might now be hitting the minimum height or width limit again

return { tileWidth, tileHeight, gap, columns };
}
File renamed without changes.
Loading
Loading