From 68d71a8329dbf8714048fab3b89c6ab2ba8b7c0b Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 8 Nov 2024 10:23:19 -0500 Subject: [PATCH] Improve interactions to hide/show the footer This fixes a few different usability issues with the footer: - When tapping one of the footer buttons, the footer would be dismissed rather than activating the button. - When the footer was hidden, you could still tap the buttons. - Interacting with controls in the footer would not reset the timer that hides it, leading to a feeling that the footer can disappear out from under you. --- src/room/InCallView.module.css | 1 - src/room/InCallView.tsx | 38 +++++++++++++++++++------ src/state/CallViewModel.ts | 51 ++++++++++++++++++++++++---------- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index fd8f3b304..6a87b383e 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -66,7 +66,6 @@ Please see LICENSE in the repository root for full details. .footer.overlay.hidden { display: grid; opacity: 0; - pointer-events: none; } .footer.overlay:has(:focus-visible) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 06173c8f2..f88921da2 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -261,12 +261,22 @@ export const InCallView: FC = ({ }, [vm]); const onTouchCancel = useCallback(() => (touchStart.current = null), []); - // We also need to tell the layout toggle to prevent touch events from - // bubbling up, or else the controls will be dismissed before a change event - // can be registered on the toggle - const onLayoutToggleTouchEnd = useCallback( - (e: TouchEvent) => e.stopPropagation(), - [], + // We also need to tell the footer controls to prevent touch events from + // bubbling up, or else the footer will be dismissed before a click/change + // event can be registered on the control + const onControlsTouchEnd = useCallback( + (e: TouchEvent) => { + // Somehow applying pointer-events: none to the controls when the footer + // is hidden is not enough to stop clicks from happening as the footer + // becomes visible, so we check manually whether the footer is shown + if (showFooter) { + e.stopPropagation(); + vm.tapControls(); + } else { + e.preventDefault(); + } + }, + [vm, showFooter], ); const onPointerMove = useCallback( @@ -528,6 +538,7 @@ export const InCallView: FC = ({ key="audio" muted={!muteStates.audio.enabled} onClick={toggleMicrophone} + onTouchEnd={onControlsTouchEnd} disabled={muteStates.audio.setEnabled === null} data-testid="incall_mute" />, @@ -535,6 +546,7 @@ export const InCallView: FC = ({ key="video" muted={!muteStates.video.enabled} onClick={toggleCamera} + onTouchEnd={onControlsTouchEnd} disabled={muteStates.video.setEnabled === null} data-testid="incall_videomute" />, @@ -545,6 +557,7 @@ export const InCallView: FC = ({ key="switch_camera" className={styles.switchCamera} onClick={switchCamera} + onTouchEnd={onControlsTouchEnd} />, ); if (canScreenshare && !hideScreensharing) { @@ -554,6 +567,7 @@ export const InCallView: FC = ({ className={styles.shareScreen} enabled={isScreenShareEnabled} onClick={toggleScreensharing} + onTouchEnd={onControlsTouchEnd} data-testid="incall_screenshare" />, ); @@ -565,11 +579,18 @@ export const InCallView: FC = ({ className={styles.raiseHand} client={client} rtcSession={rtcSession} + onTouchEnd={onControlsTouchEnd} />, ); } if (layout.type !== "pip") - buttons.push(); + buttons.push( + , + ); buttons.push( = ({ onClick={function (): void { onLeave(); }} + onTouchEnd={onControlsTouchEnd} data-testid="incall_leave" />, ); @@ -604,7 +626,7 @@ export const InCallView: FC = ({ className={styles.layout} layout={gridMode} setLayout={setGridMode} - onTouchEnd={onLayoutToggleTouchEnd} + onTouchEnd={onControlsTouchEnd} /> )} diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 27dd7aa75..3804d4a01 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -85,6 +85,10 @@ const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; // on mobile. No spotlight tile should be shown below this threshold. const smallMobileCallThreshold = 3; +// How long the footer should be shown for when hovering over or interacting +// with the interface +const showFooterMs = 4000; + export interface GridLayoutMedia { type: "grid"; spotlight?: MediaViewModel[]; @@ -902,6 +906,7 @@ export class CallViewModel extends ViewModel { ); private readonly screenTap = new Subject(); + private readonly controlsTap = new Subject(); private readonly screenHover = new Subject(); private readonly screenUnhover = new Subject(); @@ -912,6 +917,13 @@ export class CallViewModel extends ViewModel { this.screenTap.next(); } + /** + * Callback for when the user taps the call's controls. + */ + public tapControls(): void { + this.controlsTap.next(); + } + /** * Callback for when the user hovers over the call view. */ @@ -946,27 +958,38 @@ export class CallViewModel extends ViewModel { if (isFirefox()) return of(true); // Show/hide the footer in response to interactions return merge( - this.screenTap.pipe(map(() => "tap" as const)), + this.screenTap.pipe(map(() => "tap screen" as const)), + this.controlsTap.pipe(map(() => "tap controls" as const)), this.screenHover.pipe(map(() => "hover" as const)), ).pipe( - switchScan( - (state, interaction) => - interaction === "tap" - ? state + switchScan((state, interaction) => { + switch (interaction) { + case "tap screen": + return state ? // Toggle visibility on tap of(false) : // Hide after a timeout - timer(6000).pipe( + timer(showFooterMs).pipe( map(() => false), startWith(true), - ) - : // Show on hover and hide after a timeout - race(timer(3000), this.screenUnhover.pipe(take(1))).pipe( - map(() => false), - startWith(true), - ), - false, - ), + ); + case "tap controls": + // The user is interacting with things, so reset the timeout + return timer(showFooterMs).pipe( + map(() => false), + startWith(true), + ); + case "hover": + // Show on hover and hide after a timeout + return race( + timer(showFooterMs), + this.screenUnhover.pipe(take(1)), + ).pipe( + map(() => false), + startWith(true), + ); + } + }, false), startWith(false), ); }