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

Add usePersistentUserChoices hook to save device settings and username to local storage #683

Merged
merged 19 commits into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/lemon-chairs-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@livekit/components-core': minor
'@livekit/components-react': minor
---

Add `usePersistentUserChoices` hook to save user choices saving functionality.
17 changes: 17 additions & 0 deletions packages/core/etc/components-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export function isTrackReferencePlaceholder(trackReference?: TrackReferenceOrPla
// @internal (undocumented)
export function isWeb(): boolean;

// @alpha
export function loadUserChoices(defaults?: Partial<UserChoices>,
preventLoad?: boolean): UserChoices;

// @public (undocumented)
export const log: loglevel.Logger;

Expand Down Expand Up @@ -334,6 +338,10 @@ export function roomInfoObserver(room: Room): Observable<{
// @public (undocumented)
export function roomObserver(room: Room): Observable<Room>;

// @alpha
export function saveUserChoices(deviceSettings: UserChoices,
preventSave?: boolean): void;

// @public (undocumented)
export function screenShareObserver(room: Room): Observable<ScreenShareTrackMap>;

Expand Down Expand Up @@ -556,6 +564,15 @@ export type TrackSourceWithOptions = {
// @public
export function updatePages<T extends UpdatableItem>(currentList: T[], nextList: T[], maxItemsOnPage: number): T[];

// @public
export type UserChoices = {
videoInputEnabled: boolean;
audioInputEnabled: boolean;
videoInputDeviceId: string;
audioInputDeviceId: string;
username: string;
};

// @public (undocumented)
export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export * from './observables/track';
export * from './observables/dataChannel';
export * from './observables/dom-event';

export * from './persistent-storage';

export { log, setLogLevel } from './logger';
1 change: 1 addition & 0 deletions packages/core/src/persistent-storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { saveUserChoices, loadUserChoices, type UserChoices } from './user-choices';
45 changes: 45 additions & 0 deletions packages/core/src/persistent-storage/local-storage-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { log } from '../logger';

/**
* Set an object to local storage by key
* @param key - the key to set the object to local storage
* @param value - the object to set to local storage
* @internal
*/
export function setLocalStorageObject<T extends object>(key: string, value: T): void {
if (typeof localStorage === 'undefined') {
log.error('Local storage is not available.');
return;
}

try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
log.error(`Error setting item to local storage: ${error}`);
}
}

/**
* Get an object from local storage by key
* @param key - the key to retrieve the object from local storage
* @returns the object retrieved from local storage, or null if the key does not exist
* @internal
*/
export function getLocalStorageObject<T extends object>(key: string): T | undefined {
if (typeof localStorage === 'undefined') {
log.error('Local storage is not available.');
return undefined;
}

try {
const item = localStorage.getItem(key);
if (!item) {
log.warn(`Item with key ${key} does not exist in local storage.`);
return undefined;
}
return JSON.parse(item);
} catch (error) {
log.error(`Error getting item from local storage: ${error}`);
return undefined;
}
}
92 changes: 92 additions & 0 deletions packages/core/src/persistent-storage/user-choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { cssPrefix } from '../constants';
import { getLocalStorageObject, setLocalStorageObject } from './local-storage-helpers';

const USER_CHOICES_KEY = `${cssPrefix}-device-settings` as const;

/**
* Represents the user's choices for video and audio input devices,
* as well as their username.
*/
export type UserChoices = {
/**
* Whether video input is enabled.
* @defaultValue `true`
*/
videoInputEnabled: boolean;
/**
* Whether audio input is enabled.
* @defaultValue `true`
*/
audioInputEnabled: boolean;
/**
* The device ID of the video input device to use.
* @defaultValue `''`
*/
videoInputDeviceId: string;
/**
* The device ID of the audio input device to use.
* @defaultValue `''`
*/
audioInputDeviceId: string;
/**
* The username to use.
* @defaultValue `''`
*/
username: string;
};

const defaultUserChoices: UserChoices = {
videoInputEnabled: true,
audioInputEnabled: true,
videoInputDeviceId: '',
audioInputDeviceId: '',
username: '',
} as const;

/**
* Saves user choices to local storage.
* @param deviceSettings - The device settings to be stored.
* @alpha
*/
export function saveUserChoices(
deviceSettings: UserChoices,
/**
* Whether to prevent saving user choices to local storage.
*/
preventSave: boolean = false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we instead of the boolean parameter simply not call the function if the user doesn't intend to save?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted the default defaultUserChoices (if no defaults are passed by the user) to be defined and handled within the core. This API evolved from that decision. We could move the same logic one layer up into components-react, but that would increase the API surface a bit, and (if we support more frameworks in the future) we would have to reimplement it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, that makes sense to me!

): void {
if (preventSave === true) {
return;
}
setLocalStorageObject(USER_CHOICES_KEY, deviceSettings);
}

/**
* Reads the user choices from local storage, or returns the default settings if none are found.
* @param defaults - The default device settings to use if none are found in local storage.
* @defaultValue `defaultUserChoices`
*
* @alpha
*/
export function loadUserChoices(
defaults?: Partial<UserChoices>,
/**
* Whether to prevent loading from local storage and return default values instead.
* @defaultValue false
*/
preventLoad: boolean = false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question as above

): UserChoices {
const fallback: UserChoices = {
videoInputEnabled: defaults?.videoInputEnabled ?? defaultUserChoices.videoInputEnabled,
audioInputEnabled: defaults?.audioInputEnabled ?? defaultUserChoices.audioInputEnabled,
videoInputDeviceId: defaults?.videoInputDeviceId ?? defaultUserChoices.videoInputDeviceId,
audioInputDeviceId: defaults?.audioInputDeviceId ?? defaultUserChoices.audioInputDeviceId,
username: defaults?.username ?? defaultUserChoices.username,
};

if (preventLoad) {
return fallback;
} else {
return getLocalStorageObject(USER_CHOICES_KEY) ?? fallback;
}
}
29 changes: 25 additions & 4 deletions packages/react/etc/components-react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { TrackPublication } from 'livekit-client';
import type { TrackReference } from '@livekit/components-core';
import { TrackReferenceOrPlaceholder } from '@livekit/components-core';
import type { TrackSourceWithOptions } from '@livekit/components-core';
import type { UserChoices } from '@livekit/components-core';
import type { VideoCaptureOptions } from 'livekit-client';
import type { VideoSource } from '@livekit/components-core';
import type { WidgetState } from '@livekit/components-core';
Expand Down Expand Up @@ -182,7 +183,7 @@ export interface ConnectionStatusProps extends React_2.HTMLAttributes<HTMLDivEle
}

// @public
export function ControlBar({ variation, controls, ...props }: ControlBarProps): React_2.JSX.Element;
export function ControlBar({ variation, controls, saveUserChoices, ...props }: ControlBarProps): React_2.JSX.Element;

// @public (undocumented)
export type ControlBarControls = {
Expand All @@ -197,6 +198,8 @@ export type ControlBarControls = {
export interface ControlBarProps extends React_2.HTMLAttributes<HTMLDivElement> {
// (undocumented)
controls?: ControlBarControls;
// @alpha
saveUserChoices?: boolean;
// (undocumented)
variation?: 'minimal' | 'verbose' | 'textOnly';
}
Expand Down Expand Up @@ -323,7 +326,7 @@ export interface LiveKitRoomProps extends Omit<React_2.HTMLAttributes<HTMLDivEle
// @internal (undocumented)
export const LKFeatureContext: React_2.Context<FeatureFlags | undefined>;

// @public (undocumented)
// @public @deprecated (undocumented)
export type LocalUserChoices = {
username: string;
videoEnabled: boolean;
Expand Down Expand Up @@ -424,9 +427,9 @@ export interface ParticipantTileProps extends React_2.HTMLAttributes<HTMLDivElem
}

// @public
export function PreJoin({ defaults, onValidate, onSubmit, onError, debug, joinLabel, micLabel, camLabel, userLabel, showE2EEOptions, ...htmlProps }: PreJoinProps): React_2.JSX.Element;
export function PreJoin({ defaults, onValidate, onSubmit, onError, debug, joinLabel, micLabel, camLabel, userLabel, showE2EEOptions, persistUserChoices, ...htmlProps }: PreJoinProps): React_2.JSX.Element;

// @public (undocumented)
// @public
export interface PreJoinProps extends Omit<React_2.HTMLAttributes<HTMLDivElement>, 'onSubmit' | 'onError'> {
// (undocumented)
camLabel?: string;
Expand All @@ -440,6 +443,8 @@ export interface PreJoinProps extends Omit<React_2.HTMLAttributes<HTMLDivElement
onError?: (error: Error) => void;
onSubmit?: (values: LocalUserChoices) => void;
onValidate?: (values: LocalUserChoices) => boolean;
// @alpha
persistUserChoices?: boolean;
// (undocumented)
showE2EEOptions?: boolean;
// (undocumented)
Expand Down Expand Up @@ -825,6 +830,22 @@ export interface UseParticipantTileProps<T extends HTMLElement> extends React_2.
trackRef?: TrackReferenceOrPlaceholder;
}

// @alpha
export function usePersistentUserChoices(options?: UsePersistentUserChoicesOptions): {
userChoices: UserChoices;
saveAudioInputEnabled: (isEnabled: boolean) => void;
saveVideoInputEnabled: (isEnabled: boolean) => void;
saveAudioInputDeviceId: (deviceId: string) => void;
saveVideoInputDeviceId: (deviceId: string) => void;
};

// @alpha
export interface UsePersistentUserChoicesOptions {
defaults?: Partial<UserChoices>;
preventLoad?: boolean;
preventSave?: boolean;
}

// @public
export function usePinnedTracks(layoutContext?: LayoutContextType): TrackReferenceOrPlaceholder[];

Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ export { UseVisualStableUpdateOptions, useVisualStableUpdate } from './useVisual
export { UseTrackOptions, useTrack } from './useTrack';
export { useTrackByName } from './useTrackByName';
export { useChat } from './useChat';
export {
usePersistentUserChoices,
type UsePersistentUserChoicesOptions,
} from './usePersistentUserChoices';
60 changes: 60 additions & 0 deletions packages/react/src/hooks/usePersistentUserChoices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { UserChoices } from '@livekit/components-core';
import { loadUserChoices, saveUserChoices } from '@livekit/components-core';
import * as React from 'react';

/**
* Options for the `usePersistentDeviceSettings` hook.
* @alpha
*/
export interface UsePersistentUserChoicesOptions {
/**
* The default value to use if reading from local storage returns no results or fails.
*/
defaults?: Partial<UserChoices>;
/**
* Whether to prevent saving to persistent storage.
* @defaultValue false
*/
preventSave?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if save and load would make more sense than negating the option with prevent*

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the feeling that preventSave=true tells you a bit more than just save=true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about skipSave: boolean?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really just wanted to hear your opinion on it, I think your argument is fair. Let's stick with prevent* then!

/**
* Whether to prevent loading user choices from persistent storage and use `defaults` instead.
* @defaultValue false
*/
preventLoad?: boolean;
}

/**
* A hook that provides access to user choices stored in local storage, such as
* selected media devices and their current state (on or off), as well as the user name.
* @alpha
*/
export function usePersistentUserChoices(options: UsePersistentUserChoicesOptions = {}) {
const [userChoices, setSettings] = React.useState<UserChoices>(
loadUserChoices(options.defaults, options.preventLoad ?? false),
);

const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, audioInputEnabled: isEnabled }));
}, []);
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, videoInputEnabled: isEnabled }));
}, []);
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, audioInputDeviceId: deviceId }));
}, []);
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, videoInputDeviceId: deviceId }));
}, []);

React.useEffect(() => {
saveUserChoices(userChoices, options.preventSave ?? false);
}, [userChoices, options.preventSave]);

return {
userChoices,
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
};
}
Loading
Loading