From dfe510e642e7e6aa9cbdbace7ffb98b422883e54 Mon Sep 17 00:00:00 2001 From: Jeremy Kahn Date: Sat, 4 Mar 2023 12:55:37 -0600 Subject: [PATCH] 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 --- src/Bootstrap.test.tsx | 1 + src/Bootstrap.tsx | 13 ++-- src/components/Message/Message.test.tsx | 20 ++++-- .../PeerNameDisplay/PeerNameDisplay.tsx | 27 ++++++-- src/components/PeerNameDisplay/index.ts | 1 + .../PeerNameDisplay/usePeerNameDisplay.ts | 52 +++++++++++++++ src/components/Room/PeerVideo.tsx | 6 +- src/components/Room/Room.test.tsx | 16 ++++- src/components/Room/RoomVideoDisplay.tsx | 1 - src/components/Room/useRoom.ts | 48 ++++++++++---- src/components/Shell/Drawer.tsx | 20 ------ .../Shell/PeerDownloadFileButton.tsx | 7 +- src/components/Shell/PeerList.tsx | 3 +- src/components/Shell/Shell.test.tsx | 18 ++++- src/components/Shell/Shell.tsx | 18 ++++- src/components/Username/Username.tsx | 65 +++++++++++++++++++ src/components/Username/index.ts | 1 + src/contexts/SettingsContext.ts | 3 +- src/contexts/ShellContext.ts | 4 ++ src/models/chat.ts | 1 + src/models/network.ts | 2 +- src/models/settings.ts | 1 + src/pages/Home/Home.tsx | 2 +- src/test-utils/stubs/settingsContext.ts | 20 ++++++ 24 files changed, 284 insertions(+), 66 deletions(-) create mode 100644 src/components/PeerNameDisplay/usePeerNameDisplay.ts create mode 100644 src/components/Username/Username.tsx create mode 100644 src/components/Username/index.ts create mode 100644 src/test-utils/stubs/settingsContext.ts diff --git a/src/Bootstrap.test.tsx b/src/Bootstrap.test.tsx index 99a9f74..2fda0f4 100644 --- a/src/Bootstrap.test.tsx +++ b/src/Bootstrap.test.tsx @@ -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, }) diff --git a/src/Bootstrap.tsx b/src/Bootstrap.tsx index 689936a..c8399d5 100644 --- a/src/Bootstrap.tsx +++ b/src/Bootstrap.tsx @@ -39,6 +39,7 @@ function Bootstrap({ const [hasLoadedSettings, setHasLoadedSettings] = useState(false) const [userSettings, setUserSettings] = useState({ userId: getUuid(), + customUsername: '', colorMode: 'dark', playSoundOnNewMessage: true, showNotificationOnNewMessage: true, @@ -100,8 +101,8 @@ function Bootstrap({ - - {hasLoadedSettings ? ( + {hasLoadedSettings ? ( + {[routes.ROOT, routes.INDEX_HTML].map(path => ( } /> - ) : ( - <> - )} - + + ) : ( + <> + )} diff --git a/src/components/Message/Message.test.tsx b/src/components/Message/Message.test.tsx index f1f6810..fe4f43e 100644 --- a/src/components/Message/Message.test.tsx +++ b/src/components/Message/Message.test.tsx @@ -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) => ( + + + +) + describe('Message', () => { test('renders unsent message text', () => { render( - { test('renders received message text', () => { render( - { test('renders message author', () => { render( - { - return ( - - {getPeerName(children)} - - ) + const { getCustomUsername, getFriendlyName } = usePeerNameDisplay() + + const friendlyName = getFriendlyName(userId) + const customUsername = getCustomUsername(userId) + + if (customUsername === friendlyName) { + return ( + + {friendlyName} + ({getPeerName(userId)}) + + ) + } else { + return ( + + {getPeerName(userId)} + + ) + } } diff --git a/src/components/PeerNameDisplay/index.ts b/src/components/PeerNameDisplay/index.ts index 593dfcf..76da450 100644 --- a/src/components/PeerNameDisplay/index.ts +++ b/src/components/PeerNameDisplay/index.ts @@ -1,2 +1,3 @@ export * from './PeerNameDisplay' +export * from './usePeerNameDisplay' export * from './getPeerName' diff --git a/src/components/PeerNameDisplay/usePeerNameDisplay.ts b/src/components/PeerNameDisplay/usePeerNameDisplay.ts new file mode 100644 index 0000000..a60fa47 --- /dev/null +++ b/src/components/PeerNameDisplay/usePeerNameDisplay.ts @@ -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, + } +} diff --git a/src/components/Room/PeerVideo.tsx b/src/components/Room/PeerVideo.tsx index 6cfa8f8..804d748 100644 --- a/src/components/Room/PeerVideo.tsx +++ b/src/components/Room/PeerVideo.tsx @@ -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} > {userId}} placement="top" componentsProps={{ tooltip: { sx: { position: 'absolute', top: '25px' } }, diff --git a/src/components/Room/Room.test.tsx b/src/components/Room/Room.test.tsx index 3446b61..29f208a 100644 --- a/src/components/Room/Room.test.tsx +++ b/src/components/Room/Room.test.tsx @@ -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 ( - - - + + + + + ) } diff --git a/src/components/Room/RoomVideoDisplay.tsx b/src/components/Room/RoomVideoDisplay.tsx index 2ee5423..060660e 100644 --- a/src/components/Room/RoomVideoDisplay.tsx +++ b/src/components/Room/RoomVideoDisplay.tsx @@ -134,7 +134,6 @@ export const RoomVideoDisplay = ({ {selectedPeerStream && ( new AudioService(process.env.PUBLIC_URL + '/sounds/new-message.aac') ) + const { getDisplayUsername } = usePeerNameDisplay() + const setMessageLog = (messages: Array) => { 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( - peerRoom, - PeerActions.PEER_NAME - ) + const [sendPeerMetadata, receivePeerMetadata] = + usePeerRoomAction(peerRoom, PeerActions.PEER_METADATA) const [sendMessageTranscript, receiveMessageTranscript] = usePeerRoomAction< Array @@ -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[] = [sendPeerId(userId, peerId)] + const promises: Promise[] = [ + 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, diff --git a/src/components/Shell/Drawer.tsx b/src/components/Shell/Drawer.tsx index 1472d69..c2f0bb3 100644 --- a/src/components/Shell/Drawer.tsx +++ b/src/components/Shell/Drawer.tsx @@ -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 = ({ - - - Your user name:{' '} - - {userPeerId} - - - } - /> - - diff --git a/src/components/Shell/PeerDownloadFileButton.tsx b/src/components/Shell/PeerDownloadFileButton.tsx index 235ba1f..9259952 100644 --- a/src/components/Shell/PeerDownloadFileButton.tsx +++ b/src/components/Shell/PeerDownloadFileButton.tsx @@ -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(null) const shellContext = useContext(ShellContext) + const { getDisplayUsername } = usePeerNameDisplay() const { offeredFileId } = peer const onProgress = (progress: number) => { @@ -67,7 +68,9 @@ export const PeerDownloadFileButton = ({ /> ) : ( diff --git a/src/components/Shell/PeerList.tsx b/src/components/Shell/PeerList.tsx index 5b130d8..242c8d4 100644 --- a/src/components/Shell/PeerList.tsx +++ b/src/components/Shell/PeerList.tsx @@ -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 = ({ )} - {userId} (you) + {peerList.map((peer: Peer) => ( diff --git a/src/components/Shell/Shell.test.tsx b/src/components/Shell/Shell.test.tsx index 77daa2c..67cdd50 100644 --- a/src/components/Shell/Shell.test.tsx +++ b/src/components/Shell/Shell.test.tsx @@ -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 = {}) => { +const mockUserPeerId = 'abc123' + +const userSettingsStub = userSettingsContextStubFactory({ + userId: mockUserPeerId, +}) + +const ShellStub = (shellProps: Partial = {}) => { return ( - + + + ) } diff --git a/src/components/Shell/Shell.tsx b/src/components/Shell/Shell.tsx index d93b431..37604a0 100644 --- a/src/components/Shell/Shell.tsx +++ b/src/components/Shell/Shell.tsx @@ -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.NOT_SHARING ) + const [customUsername, setCustomUsername] = useState( + getUserSettings().customUsername + ) const [peerAudios, setPeerAudios] = useState< Record >({}) @@ -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} /> { + const userName = getPeerName(userId) + + const { customUsername, setCustomUsername, showAlert } = + useContext(ShellContext) + const [inflightCustomUsername, setInflightCustomUsername] = + useState(customUsername) + + const handleChange = (evt: ChangeEvent) => { + 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) => { + evt.preventDefault() + updateCustomUsername() + } + + const handleBlur = () => { + updateCustomUsername() + } + + return ( +
+ + + Your username + +
+ ) +} diff --git a/src/components/Username/index.ts b/src/components/Username/index.ts new file mode 100644 index 0000000..f2da48a --- /dev/null +++ b/src/components/Username/index.ts @@ -0,0 +1 @@ +export * from './Username' diff --git a/src/contexts/SettingsContext.ts b/src/contexts/SettingsContext.ts index 25467d9..b3917c8 100644 --- a/src/contexts/SettingsContext.ts +++ b/src/contexts/SettingsContext.ts @@ -2,7 +2,7 @@ import { createContext } from 'react' import { UserSettings } from 'models/settings' -interface SettingsContextProps { +export interface SettingsContextProps { updateUserSettings: (settings: Partial) => Promise getUserSettings: () => UserSettings } @@ -11,6 +11,7 @@ export const SettingsContext = createContext({ updateUserSettings: () => Promise.resolve(), getUserSettings: () => ({ userId: '', + customUsername: '', colorMode: 'dark', playSoundOnNewMessage: true, showNotificationOnNewMessage: true, diff --git a/src/contexts/ShellContext.ts b/src/contexts/ShellContext.ts index d04e272..f36e27a 100644 --- a/src/contexts/ShellContext.ts +++ b/src/contexts/ShellContext.ts @@ -28,6 +28,8 @@ interface ShellContextProps { setScreenState: Dispatch> peerAudios: Record setPeerAudios: Dispatch>> + customUsername: string + setCustomUsername: Dispatch> } export const ShellContext = createContext({ @@ -55,4 +57,6 @@ export const ShellContext = createContext({ setScreenState: () => {}, peerAudios: {}, setPeerAudios: () => {}, + customUsername: '', + setCustomUsername: () => {}, }) diff --git a/src/models/chat.ts b/src/models/chat.ts index bb5a6f3..fb74f68 100644 --- a/src/models/chat.ts +++ b/src/models/chat.ts @@ -44,6 +44,7 @@ export enum ScreenShareState { export interface Peer { peerId: string userId: string + customUsername: string audioState: AudioState videoState: VideoState screenShareState: ScreenShareState diff --git a/src/models/network.ts b/src/models/network.ts index 6dfd329..7e994fc 100644 --- a/src/models/network.ts +++ b/src/models/network.ts @@ -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', diff --git a/src/models/settings.ts b/src/models/settings.ts index 00b7a57..2b8e47e 100644 --- a/src/models/settings.ts +++ b/src/models/settings.ts @@ -1,6 +1,7 @@ export interface UserSettings { colorMode: 'dark' | 'light' userId: string + customUsername: string playSoundOnNewMessage: boolean showNotificationOnNewMessage: boolean } diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 652d076..62a55e3 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -58,7 +58,7 @@ export function Home({ userId }: HomeProps) {
- Your user name:{' '} + Your username:{' '} {userId} diff --git a/src/test-utils/stubs/settingsContext.ts b/src/test-utils/stubs/settingsContext.ts new file mode 100644 index 0000000..9fc59c6 --- /dev/null +++ b/src/test-utils/stubs/settingsContext.ts @@ -0,0 +1,20 @@ +import { SettingsContextProps } from 'contexts/SettingsContext' +import { UserSettings } from 'models/settings' + +export const userSettingsContextStubFactory = ( + userSettingsOverrides: Partial = {} +) => { + const userSettingsStub: SettingsContextProps = { + updateUserSettings: () => Promise.resolve(), + getUserSettings: () => ({ + userId: '', + customUsername: '', + colorMode: 'dark', + playSoundOnNewMessage: true, + showNotificationOnNewMessage: true, + ...userSettingsOverrides, + }), + } + + return userSettingsStub +}