feat: [closes #76] Custom usernames (#93)

* feat: add Username component
* feat: set custom username state
* feat: update custom username on input blur
* feat: inform peers of username updates
* feat: display username for peers
* feat: show static name in parentheses
* feat: use display name in message notification
* feat: remove username display from Shell Drawer
* feat: persist customUsername
This commit is contained in:
Jeremy Kahn 2023-03-04 12:55:37 -06:00 committed by GitHub
parent 870a13eac1
commit dfe510e642
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 284 additions and 66 deletions

View File

@ -54,6 +54,7 @@ test('persists user settings if none were already persisted', async () => {
expect(mockSetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS, {
colorMode: 'dark',
userId: 'abc123',
customUsername: '',
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
})

View File

@ -39,6 +39,7 @@ function Bootstrap({
const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
const [userSettings, setUserSettings] = useState<UserSettings>({
userId: getUuid(),
customUsername: '',
colorMode: 'dark',
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
@ -100,8 +101,8 @@ function Bootstrap({
<Router>
<StorageContext.Provider value={storageContextValue}>
<SettingsContext.Provider value={settingsContextValue}>
<Shell appNeedsUpdate={appNeedsUpdate} userPeerId={userId}>
{hasLoadedSettings ? (
{hasLoadedSettings ? (
<Shell appNeedsUpdate={appNeedsUpdate} userPeerId={userId}>
<Routes>
{[routes.ROOT, routes.INDEX_HTML].map(path => (
<Route
@ -129,10 +130,10 @@ function Bootstrap({
element={<Navigate to={routes.ROOT} replace />}
/>
</Routes>
) : (
<></>
)}
</Shell>
</Shell>
) : (
<></>
)}
</SettingsContext.Provider>
</StorageContext.Provider>
</Router>

View File

@ -1,9 +1,11 @@
import { render, screen } from '@testing-library/react'
import { SettingsContext } from 'contexts/SettingsContext'
import { funAnimalName } from 'fun-animal-names'
import { ReceivedMessage, UnsentMessage } from 'models/chat'
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
import { Message } from './Message'
import { Message, MessageProps } from './Message'
const mockUserId = 'user-123'
@ -22,10 +24,20 @@ const mockReceivedMessage: ReceivedMessage = {
timeReceived: 2,
}
const userSettingsStub = userSettingsContextStubFactory({
userId: mockUserId,
})
const MockMessage = (props: MessageProps) => (
<SettingsContext.Provider value={userSettingsStub}>
<Message {...props} />
</SettingsContext.Provider>
)
describe('Message', () => {
test('renders unsent message text', () => {
render(
<Message
<MockMessage
message={mockUnsentMessage}
userId={mockUserId}
showAuthor={false}
@ -37,7 +49,7 @@ describe('Message', () => {
test('renders received message text', () => {
render(
<Message
<MockMessage
message={mockReceivedMessage}
userId={mockUserId}
showAuthor={false}
@ -49,7 +61,7 @@ describe('Message', () => {
test('renders message author', () => {
render(
<Message
<MockMessage
message={mockReceivedMessage}
userId={mockUserId}
showAuthor={true}

View File

@ -1,5 +1,6 @@
import Typography, { TypographyProps } from '@mui/material/Typography'
import { usePeerNameDisplay } from './usePeerNameDisplay'
import { getPeerName } from './getPeerName'
interface PeerNameDisplayProps extends TypographyProps {
@ -7,12 +8,26 @@ interface PeerNameDisplayProps extends TypographyProps {
}
export const PeerNameDisplay = ({
children,
children: userId,
...rest
}: PeerNameDisplayProps) => {
return (
<Typography component="span" {...rest}>
{getPeerName(children)}
</Typography>
)
const { getCustomUsername, getFriendlyName } = usePeerNameDisplay()
const friendlyName = getFriendlyName(userId)
const customUsername = getCustomUsername(userId)
if (customUsername === friendlyName) {
return (
<Typography component="span" {...rest}>
{friendlyName}
<Typography variant="caption"> ({getPeerName(userId)})</Typography>
</Typography>
)
} else {
return (
<Typography component="span" {...rest}>
{getPeerName(userId)}
</Typography>
)
}
}

View File

@ -1,2 +1,3 @@
export * from './PeerNameDisplay'
export * from './usePeerNameDisplay'
export * from './getPeerName'

View File

@ -0,0 +1,52 @@
import { useContext } from 'react'
import { SettingsContext } from 'contexts/SettingsContext'
import { ShellContext } from 'contexts/ShellContext'
import { getPeerName } from './getPeerName'
export const usePeerNameDisplay = () => {
const { getUserSettings } = useContext(SettingsContext)
const { peerList, customUsername: selfCustomUsername } =
useContext(ShellContext)
const { userId: selfUserId } = getUserSettings()
const isPeerSelf = (userId: string) => selfUserId === userId
const getPeer = (userId: string) =>
peerList.find(peer => peer.userId === userId)
const getCustomUsername = (userId: string) =>
isPeerSelf(userId)
? selfCustomUsername
: getPeer(userId)?.customUsername ?? ''
const getFriendlyName = (userId: string) => {
const customUsername = getCustomUsername(userId)
const friendlyName = customUsername || getPeerName(userId)
return friendlyName
}
const getDisplayUsername = (userId: string) => {
const friendlyName = getFriendlyName(userId)
const customUsername = getCustomUsername(userId)
let displayUsername: string
if (customUsername === friendlyName) {
displayUsername = `${friendlyName} (${getPeerName(userId)})`
} else {
displayUsername = getPeerName(userId)
}
return displayUsername
}
return {
getCustomUsername,
isPeerSelf,
getFriendlyName,
getDisplayUsername,
}
}

View File

@ -2,13 +2,12 @@ import { useEffect, useRef } from 'react'
import Paper from '@mui/material/Paper'
import Tooltip from '@mui/material/Tooltip'
import { getPeerName } from 'components/PeerNameDisplay'
import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { VideoStreamType } from 'models/chat'
import { SelectedPeerStream } from './RoomVideoDisplay'
interface PeerVideoProps {
isSelectedVideo?: boolean
isSelfVideo?: boolean
numberOfVideos: number
onVideoClick?: (
@ -30,7 +29,6 @@ const nextPerfectSquare = (base: number) => {
}
export const PeerVideo = ({
isSelectedVideo,
isSelfVideo,
numberOfVideos,
onVideoClick,
@ -81,7 +79,7 @@ export const PeerVideo = ({
elevation={10}
>
<Tooltip
title={getPeerName(userId)}
title={<PeerNameDisplay>{userId}</PeerNameDisplay>}
placement="top"
componentsProps={{
tooltip: { sx: { position: 'absolute', top: '25px' } },

View File

@ -3,11 +3,19 @@ import { waitFor, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
import { SettingsContext } from 'contexts/SettingsContext'
import { Room } from './'
const mockUserId = 'user-id'
const mockRoomId = 'room-123'
const userSettingsStub = userSettingsContextStubFactory({
userId: mockUserId,
})
window.AudioContext = jest.fn().mockImplementation()
const mockGetUuid = jest.fn()
const mockMessagedSender = jest
@ -35,9 +43,11 @@ jest.mock('trystero', () => ({
const RouteStub = ({ children }: PropsWithChildren) => {
return (
<Router initialEntries={['/public/abc123']}>
<Routes>
<Route path="/public/:roomId" element={children}></Route>
</Routes>
<SettingsContext.Provider value={userSettingsStub}>
<Routes>
<Route path="/public/:roomId" element={children}></Route>
</Routes>
</SettingsContext.Provider>
</Router>
)
}

View File

@ -134,7 +134,6 @@ export const RoomVideoDisplay = ({
{selectedPeerStream && (
<Box sx={{ height: '80%', width: '100%' }}>
<PeerVideo
isSelectedVideo
numberOfVideos={numberOfVideos}
onVideoClick={handleVideoClick}
userId={selectedPeerStream.peerId}

View File

@ -20,7 +20,7 @@ import {
isInlineMedia,
FileOfferMetadata,
} from 'models/chat'
import { getPeerName } from 'components/PeerNameDisplay'
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
import { NotificationService } from 'services/Notification'
import { Audio as AudioService } from 'services/Audio'
import { PeerRoom, PeerHookType } from 'services/PeerRoom'
@ -36,6 +36,11 @@ interface UseRoomConfig {
getUuid?: typeof uuid
}
interface UserMetadata {
userId: string
customUsername: string
}
export function useRoom(
{ password, ...roomConfig }: BaseRoomConfig & TorrentRoomConfig,
{ roomId, userId, getUuid = uuid }: UseRoomConfig
@ -57,6 +62,7 @@ export function useRoom(
setRoomId,
setPassword,
setIsPeerListOpen,
customUsername,
} = useContext(ShellContext)
const settingsContext = useContext(SettingsContext)
@ -68,6 +74,8 @@ export function useRoom(
() => new AudioService(process.env.PUBLIC_URL + '/sounds/new-message.aac')
)
const { getDisplayUsername } = usePeerNameDisplay()
const setMessageLog = (messages: Array<Message | InlineMedia>) => {
if (messages.length > messageTranscriptSizeLimit) {
const evictedMessages = messages.slice(
@ -181,10 +189,8 @@ export function useRoom(
if (isShowingMessages) setUnreadMessages(0)
}, [isShowingMessages, setUnreadMessages])
const [sendPeerId, receivePeerId] = usePeerRoomAction<string>(
peerRoom,
PeerActions.PEER_NAME
)
const [sendPeerMetadata, receivePeerMetadata] =
usePeerRoomAction<UserMetadata>(peerRoom, PeerActions.PEER_METADATA)
const [sendMessageTranscript, receiveMessageTranscript] = usePeerRoomAction<
Array<ReceivedMessage | ReceivedInlineMedia>
@ -217,14 +223,16 @@ export function useRoom(
setIsMessageSending(false)
}
receivePeerId((userId: string, peerId: string) => {
receivePeerMetadata(({ userId, customUsername }, peerId: string) => {
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
if (peerIndex === -1) {
setPeerList([
...peerList,
{
peerId,
userId,
customUsername,
audioState: AudioState.STOPPED,
videoState: VideoState.STOPPED,
screenShareState: ScreenShareState.NOT_SHARING,
@ -232,9 +240,18 @@ export function useRoom(
},
])
} else {
const oldUsername =
peerList[peerIndex].customUsername || getPeerName(userId)
const newUsername = customUsername || getPeerName(userId)
const newPeerList = [...peerList]
newPeerList[peerIndex].userId = userId
const newPeer = { ...newPeerList[peerIndex], userId, customUsername }
newPeerList[peerIndex] = newPeer
setPeerList(newPeerList)
if (oldUsername !== newUsername) {
showAlert(`${oldUsername} is now ${newUsername}`)
}
}
})
@ -257,8 +274,10 @@ export function useRoom(
}
if (userSettings.showNotificationOnNewMessage) {
const displayUsername = getDisplayUsername(message.authorId)
NotificationService.showNotification(
`${getPeerName(message.authorId)}: ${message.text}`
`${displayUsername}: ${message.text}`
)
}
}
@ -275,7 +294,9 @@ export function useRoom(
setNumberOfPeers(newNumberOfPeers)
;(async () => {
try {
const promises: Promise<any>[] = [sendPeerId(userId, peerId)]
const promises: Promise<any>[] = [
sendPeerMetadata({ userId, customUsername }, peerId),
]
if (!isPrivate) {
promises.push(
@ -293,9 +314,10 @@ export function useRoom(
peerRoom.onPeerLeave(PeerHookType.NEW_PEER, (peerId: string) => {
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
const peerExist = peerIndex !== -1
showAlert(
`${
peerExist ? getPeerName(peerList[peerIndex].userId) : 'Someone'
peerExist ? getDisplayUsername(peerList[peerIndex].userId) : 'Someone'
} has left the room`,
{
severity: 'warning',
@ -353,7 +375,7 @@ export function useRoom(
if (userSettings.showNotificationOnNewMessage) {
NotificationService.showNotification(
`${getPeerName(inlineMedia.authorId)} shared media`
`${getDisplayUsername(inlineMedia.authorId)} shared media`
)
}
}
@ -361,6 +383,10 @@ export function useRoom(
setMessageLog([...messageLog, { ...inlineMedia, timeReceived: Date.now() }])
})
useEffect(() => {
sendPeerMetadata({ customUsername, userId })
}, [customUsername, userId, sendPeerMetadata])
return {
isPrivate,
handleInlineMediaUpload,

View File

@ -3,7 +3,6 @@ import { Link } from 'react-router-dom'
import { Theme } from '@mui/material/styles'
import MuiDrawer from '@mui/material/Drawer'
import List from '@mui/material/List'
import Typography from '@mui/material/Typography'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
@ -21,7 +20,6 @@ import ReportIcon from '@mui/icons-material/Report'
import { routes } from 'config/routes'
import { SettingsContext } from 'contexts/SettingsContext'
import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { DrawerHeader } from './DrawerHeader'
@ -35,7 +33,6 @@ export interface DrawerProps extends PropsWithChildren {
onHomeLinkClick: () => void
onSettingsLinkClick: () => void
theme: Theme
userPeerId: string
}
export const Drawer = ({
@ -46,7 +43,6 @@ export const Drawer = ({
onHomeLinkClick,
onSettingsLinkClick,
theme,
userPeerId,
}: DrawerProps) => {
const settingsContext = useContext(SettingsContext)
const colorMode = settingsContext.getUserSettings().colorMode
@ -80,22 +76,6 @@ export const Drawer = ({
</IconButton>
</DrawerHeader>
<Divider />
<ListItem disablePadding>
<ListItemText
sx={{
padding: '1em 1.5em',
}}
primary={
<Typography>
Your user name:{' '}
<PeerNameDisplay sx={{ fontWeight: 'bold' }}>
{userPeerId}
</PeerNameDisplay>
</Typography>
}
/>
</ListItem>
<Divider />
<List role="navigation">
<Link to={routes.ROOT} onClick={onHomeLinkClick}>
<ListItem disablePadding>

View File

@ -11,7 +11,7 @@ import { Peer } from 'models/chat'
import { ShellContext } from 'contexts/ShellContext'
import './PeerDownloadFileButton.sass'
import { getPeerName } from 'components/PeerNameDisplay/getPeerName'
import { usePeerNameDisplay } from 'components/PeerNameDisplay/usePeerNameDisplay'
interface PeerDownloadFileButtonProps {
peer: Peer
@ -23,6 +23,7 @@ export const PeerDownloadFileButton = ({
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
const shellContext = useContext(ShellContext)
const { getDisplayUsername } = usePeerNameDisplay()
const { offeredFileId } = peer
const onProgress = (progress: number) => {
@ -67,7 +68,9 @@ export const PeerDownloadFileButton = ({
/>
) : (
<Tooltip
title={`Download files being offered by ${getPeerName(peer.userId)}`}
title={`Download files being offered by ${getDisplayUsername(
peer.userId
)}`}
>
<Fab color="primary" size="small" onClick={handleDownloadFileClick}>
<Download />

View File

@ -12,6 +12,7 @@ import ListItem from '@mui/material/ListItem'
import { PeerListHeader } from 'components/Shell/PeerListHeader'
import { AudioVolume } from 'components/AudioVolume'
import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { Username } from 'components/Username/Username'
import { AudioState, Peer } from 'models/chat'
import { PeerDownloadFileButton } from './PeerDownloadFileButton'
@ -67,7 +68,7 @@ export const PeerList = ({
</ListItemIcon>
)}
<ListItemText>
<PeerNameDisplay>{userId}</PeerNameDisplay> (you)
<Username userId={userId} />
</ListItemText>
</ListItem>
{peerList.map((peer: Peer) => (

View File

@ -1,13 +1,27 @@
import { waitFor, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SettingsContext } from 'contexts/SettingsContext'
import { MemoryRouter as Router } from 'react-router-dom'
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
import { Shell, ShellProps } from './Shell'
const ShellStub = (overrides: Partial<ShellProps> = {}) => {
const mockUserPeerId = 'abc123'
const userSettingsStub = userSettingsContextStubFactory({
userId: mockUserPeerId,
})
const ShellStub = (shellProps: Partial<ShellProps> = {}) => {
return (
<Router>
<Shell appNeedsUpdate={false} userPeerId="abc123" {...overrides} />
<SettingsContext.Provider value={userSettingsStub}>
<Shell
appNeedsUpdate={false}
userPeerId={mockUserPeerId}
{...shellProps}
/>
</SettingsContext.Provider>
</Router>
)
}

View File

@ -33,7 +33,7 @@ export interface ShellProps extends PropsWithChildren {
}
export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const settingsContext = useContext(SettingsContext)
const { getUserSettings, updateUserSettings } = useContext(SettingsContext)
const [isAlertShowing, setIsAlertShowing] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [isQRCodeDialogOpen, setIsQRCodeDialogOpen] = useState(false)
@ -56,6 +56,9 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [screenState, setScreenState] = useState<ScreenShareState>(
ScreenShareState.NOT_SHARING
)
const [customUsername, setCustomUsername] = useState(
getUserSettings().customUsername
)
const [peerAudios, setPeerAudios] = useState<
Record<string, HTMLAudioElement>
>({})
@ -94,6 +97,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setScreenState,
peerAudios,
setPeerAudios,
customUsername,
setCustomUsername,
}),
[
isPeerListOpen,
@ -119,10 +124,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setScreenState,
peerAudios,
setPeerAudios,
customUsername,
setCustomUsername,
]
)
const colorMode = settingsContext.getUserSettings().colorMode
const { colorMode } = getUserSettings()
const theme = useMemo(
() =>
@ -145,6 +152,12 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setIsAlertShowing(false)
}
useEffect(() => {
if (customUsername === getUserSettings().customUsername) return
updateUserSettings({ customUsername })
}, [customUsername, getUserSettings, updateUserSettings])
useEffect(() => {
document.title = title
}, [title])
@ -314,7 +327,6 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
onHomeLinkClick={handleHomeLinkClick}
onSettingsLinkClick={handleSettingsLinkClick}
theme={theme}
userPeerId={userPeerId}
/>
<RouteContent
isDrawerOpen={isDrawerOpen}

View File

@ -0,0 +1,65 @@
import { useState, useContext, ChangeEvent, SyntheticEvent } from 'react'
import TextField from '@mui/material/TextField'
import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import { ShellContext } from 'contexts/ShellContext'
import { getPeerName } from 'components/PeerNameDisplay/getPeerName'
interface UsernameProps {
userId: string
}
const maxCustomUsernameLength = 30
export const Username = ({ userId }: UsernameProps) => {
const userName = getPeerName(userId)
const { customUsername, setCustomUsername, showAlert } =
useContext(ShellContext)
const [inflightCustomUsername, setInflightCustomUsername] =
useState(customUsername)
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
setInflightCustomUsername(evt.target.value)
}
const updateCustomUsername = () => {
const trimmedUsername = inflightCustomUsername.trim()
setCustomUsername(trimmedUsername)
if (trimmedUsername.length) {
showAlert(`Username changed to "${trimmedUsername}"`, {
severity: 'success',
})
} else {
showAlert(`Username reset`, { severity: 'success' })
}
}
const handleSubmit = (evt: SyntheticEvent<HTMLFormElement>) => {
evt.preventDefault()
updateCustomUsername()
}
const handleBlur = () => {
updateCustomUsername()
}
return (
<form onSubmit={handleSubmit}>
<FormControl sx={{ width: '100%' }}>
<TextField
onChange={handleChange}
onBlur={handleBlur}
variant="outlined"
label={`${userName}`}
sx={{ width: '100%' }}
value={inflightCustomUsername}
inputProps={{ maxLength: maxCustomUsernameLength }}
/>
<FormHelperText>Your username</FormHelperText>
</FormControl>
</form>
)
}

View File

@ -0,0 +1 @@
export * from './Username'

View File

@ -2,7 +2,7 @@ import { createContext } from 'react'
import { UserSettings } from 'models/settings'
interface SettingsContextProps {
export interface SettingsContextProps {
updateUserSettings: (settings: Partial<UserSettings>) => Promise<void>
getUserSettings: () => UserSettings
}
@ -11,6 +11,7 @@ export const SettingsContext = createContext<SettingsContextProps>({
updateUserSettings: () => Promise.resolve(),
getUserSettings: () => ({
userId: '',
customUsername: '',
colorMode: 'dark',
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,

View File

@ -28,6 +28,8 @@ interface ShellContextProps {
setScreenState: Dispatch<SetStateAction<ScreenShareState>>
peerAudios: Record<string, HTMLAudioElement>
setPeerAudios: Dispatch<SetStateAction<Record<string, HTMLAudioElement>>>
customUsername: string
setCustomUsername: Dispatch<SetStateAction<string>>
}
export const ShellContext = createContext<ShellContextProps>({
@ -55,4 +57,6 @@ export const ShellContext = createContext<ShellContextProps>({
setScreenState: () => {},
peerAudios: {},
setPeerAudios: () => {},
customUsername: '',
setCustomUsername: () => {},
})

View File

@ -44,6 +44,7 @@ export enum ScreenShareState {
export interface Peer {
peerId: string
userId: string
customUsername: string
audioState: AudioState
videoState: VideoState
screenShareState: ScreenShareState

View File

@ -3,7 +3,7 @@ export enum PeerActions {
MESSAGE = 'MESSAGE',
MEDIA_MESSAGE = 'MEDIA_MSG',
MESSAGE_TRANSCRIPT = 'MSG_XSCRIPT',
PEER_NAME = 'PEER_NAME',
PEER_METADATA = 'PEER_META',
AUDIO_CHANGE = 'AUDIO_CHANGE',
VIDEO_CHANGE = 'VIDEO_CHANGE',
SCREEN_SHARE = 'SCREEN_SHARE',

View File

@ -1,6 +1,7 @@
export interface UserSettings {
colorMode: 'dark' | 'light'
userId: string
customUsername: string
playSoundOnNewMessage: boolean
showNotificationOnNewMessage: boolean
}

View File

@ -58,7 +58,7 @@ export function Home({ userId }: HomeProps) {
</Link>
<form onSubmit={handleFormSubmit} className="max-w-xl mx-auto">
<Typography sx={{ mb: 2 }}>
Your user name:{' '}
Your username:{' '}
<PeerNameDisplay paragraph={false} sx={{ fontWeight: 'bold' }}>
{userId}
</PeerNameDisplay>

View File

@ -0,0 +1,20 @@
import { SettingsContextProps } from 'contexts/SettingsContext'
import { UserSettings } from 'models/settings'
export const userSettingsContextStubFactory = (
userSettingsOverrides: Partial<UserSettings> = {}
) => {
const userSettingsStub: SettingsContextProps = {
updateUserSettings: () => Promise.resolve(),
getUserSettings: () => ({
userId: '',
customUsername: '',
colorMode: 'dark',
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
...userSettingsOverrides,
}),
}
return userSettingsStub
}