From e597a667a1d1ce69e0fe6d24bb7f3bd8fa133f67 Mon Sep 17 00:00:00 2001 From: Jeremy Kahn Date: Thu, 27 Jul 2023 21:06:35 -0500 Subject: [PATCH] feat(chat): [closes #13] Active typing indicators (#133) * feat(typing-indicator): wire up handleMessageChange * feat(typing-indicator): send typing: true status * feat(typing-indicator): expire typing state * feat(typing-indicator): update typing state received from peers * refactor(shell): add updatePeer utility * feat(typing-indicator): display peer typing status * feat(typing-indicator): reset typing status when a message is sent * feat(typing-indicator): move indicator below message form * feat(typing-indicator): keep status text to one line --- package-lock.json | 34 +++++++-- package.json | 1 + src/components/MessageForm/MessageForm.tsx | 5 +- .../PeerNameDisplay/PeerNameDisplay.tsx | 2 +- src/components/Room/Room.tsx | 14 ++-- src/components/Room/TypingStatusBar.tsx | 71 +++++++++++++++++++ src/components/Room/useRoom.ts | 50 +++++++++++-- src/components/Shell/Shell.tsx | 22 +++++- src/contexts/ShellContext.ts | 2 + src/models/chat.ts | 5 ++ src/models/network.ts | 1 + 11 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 src/components/Room/TypingStatusBar.tsx diff --git a/package-lock.json b/package-lock.json index e1cc2e3..fe6e4ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@emotion/styled": "^11.10.0", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.9.3", + "@react-hook/debounce": "^4.0.0", "@react-hook/window-size": "^3.1.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.3.0", @@ -4587,9 +4588,9 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "node_modules/@react-hook/debounce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-3.0.0.tgz", - "integrity": "sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-4.0.0.tgz", + "integrity": "sha512-706Xcg+KKWHk9BuZQUQ0ZQKp9zhv3/MbqFenWVfHcynYpSGRVwQTzJRGvPxvsdtXxJv+HfgKTY/O/hEejakwmA==", "dependencies": { "@react-hook/latest": "^1.0.2" }, @@ -4637,6 +4638,17 @@ "react": ">=16.8" } }, + "node_modules/@react-hook/window-size/node_modules/@react-hook/debounce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==", + "dependencies": { + "@react-hook/latest": "^1.0.2" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -29935,9 +29947,9 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "@react-hook/debounce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-3.0.0.tgz", - "integrity": "sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-4.0.0.tgz", + "integrity": "sha512-706Xcg+KKWHk9BuZQUQ0ZQKp9zhv3/MbqFenWVfHcynYpSGRVwQTzJRGvPxvsdtXxJv+HfgKTY/O/hEejakwmA==", "requires": { "@react-hook/latest": "^1.0.2" } @@ -29970,6 +29982,16 @@ "@react-hook/debounce": "^3.0.0", "@react-hook/event": "^1.2.1", "@react-hook/throttle": "^2.2.0" + }, + "dependencies": { + "@react-hook/debounce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==", + "requires": { + "@react-hook/latest": "^1.0.2" + } + } } }, "@rollup/plugin-babel": { diff --git a/package.json b/package.json index b56f77e..8c05e6f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@emotion/styled": "^11.10.0", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.9.3", + "@react-hook/debounce": "^4.0.0", "@react-hook/window-size": "^3.1.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.3.0", diff --git a/src/components/MessageForm/MessageForm.tsx b/src/components/MessageForm/MessageForm.tsx index 6784487..6690942 100644 --- a/src/components/MessageForm/MessageForm.tsx +++ b/src/components/MessageForm/MessageForm.tsx @@ -15,11 +15,13 @@ import { messageCharacterSizeLimit } from 'config/messaging' interface MessageFormProps { onMessageSubmit: (message: string) => void + onMessageChange: (message: string) => void isMessageSending: boolean } export const MessageForm = ({ onMessageSubmit, + onMessageChange, isMessageSending, }: MessageFormProps) => { const textFieldRef = useRef(null) @@ -43,6 +45,7 @@ export const MessageForm = ({ const handleMessageChange = (event: React.ChangeEvent) => { const { value } = event.target setTextMessage(value) + onMessageChange(value) } const submitMessage = () => { @@ -68,7 +71,7 @@ export const MessageForm = ({ } return ( -
+ - + + + + )} diff --git a/src/components/Room/TypingStatusBar.tsx b/src/components/Room/TypingStatusBar.tsx new file mode 100644 index 0000000..cfbf352 --- /dev/null +++ b/src/components/Room/TypingStatusBar.tsx @@ -0,0 +1,71 @@ +import Box from '@mui/material/Box' +import { Typography } from '@mui/material' +import { useContext } from 'react' +import { ShellContext } from 'contexts/ShellContext' +import { + PeerNameDisplay, + PeerNameDisplayProps, +} from 'components/PeerNameDisplay/PeerNameDisplay' + +export const TypingStatusBar = () => { + const { peerList } = useContext(ShellContext) + const typingPeers = peerList.filter(({ isTyping }) => isTyping) + + const peerNameDisplayProps: Partial = { + variant: 'caption', + sx: theme => ({ + color: theme.palette.text.secondary, + fontWeight: theme.typography.fontWeightBold, + }), + } + + let statusMessage = <> + + if (typingPeers.length === 1) { + statusMessage = ( + <> + + {typingPeers[0].userId} + {' '} + is typing... + + ) + } else if (typingPeers.length === 2) { + statusMessage = ( + <> + + {typingPeers[0].userId} + {' '} + and{' '} + + {typingPeers[1].userId} + {' '} + are typing... + + ) + } else if (typingPeers.length > 2) { + statusMessage = <>Several people are typing... + } + + return ( + + ({ + color: theme.palette.text.secondary, + display: 'block', + fontWeight: theme.typography.fontWeightBold, + height: '1.75rem', + maxHeight: '1.75rem', + overflow: 'hidden', + px: 2, + py: 0.5, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + })} + > + {statusMessage} + + + ) +} diff --git a/src/components/Room/useRoom.ts b/src/components/Room/useRoom.ts index cf7dfc4..97e7980 100644 --- a/src/components/Room/useRoom.ts +++ b/src/components/Room/useRoom.ts @@ -2,6 +2,7 @@ import { useContext, useEffect, useMemo, useState } from 'react' import { BaseRoomConfig } from 'trystero' import { TorrentRoomConfig } from 'trystero/torrent' import { v4 as uuid } from 'uuid' +import { useDebounce } from '@react-hook/debounce' import { ShellContext } from 'contexts/ShellContext' import { SettingsContext } from 'contexts/SettingsContext' @@ -19,6 +20,7 @@ import { isMessageReceived, isInlineMedia, FileOfferMetadata, + TypingStatus, } from 'models/chat' import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay' import { NotificationService } from 'services/Notification' @@ -60,6 +62,7 @@ export function useRoom( setRoomId, setPassword, customUsername, + updatePeer, } = useContext(ShellContext) const settingsContext = useContext(SettingsContext) @@ -151,12 +154,26 @@ export function useRoom( ] ) + const [sendTypingStatusChange, receiveTypingStatusChange] = + usePeerRoomAction(peerRoom, PeerActions.TYPING_STATUS_CHANGE) + + const [isTyping, setIsTypingDebounced, setIsTyping] = useDebounce( + false, + 2000, + true + ) + + useEffect(() => { + sendTypingStatusChange({ isTyping }) + }, [isTyping, sendTypingStatusChange]) + useEffect(() => { return () => { + sendTypingStatusChange({ isTyping: false }) peerRoom.leaveRoom() setPeerList([]) } - }, [peerRoom, setPeerList]) + }, [peerRoom, setPeerList, sendTypingStatusChange]) useEffect(() => { setPassword(password) @@ -201,6 +218,7 @@ export function useRoom( id: getUuid(), } + setIsTyping(false) setIsMessageSending(true) setMessageLog([...messageLog, unsentMessage]) await sendPeerMessage(unsentMessage) @@ -226,6 +244,7 @@ export function useRoom( videoState: VideoState.STOPPED, screenShareState: ScreenShareState.NOT_SHARING, offeredFileId: null, + isTyping: false, }, ]) } else { @@ -250,7 +269,7 @@ export function useRoom( setMessageLog(transcript) }) - receivePeerMessage(message => { + receivePeerMessage((message, peerId) => { const userSettings = settingsContext.getUserSettings() if (!isShowingMessages) { @@ -272,6 +291,7 @@ export function useRoom( } setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }]) + updatePeer(peerId, { isTyping: false }) }) peerRoom.onPeerJoin(PeerHookType.NEW_PEER, (peerId: string) => { @@ -299,18 +319,20 @@ export function useRoom( peerRoom.onPeerLeave(PeerHookType.NEW_PEER, (peerId: string) => { const peerIndex = peerList.findIndex(peer => peer.peerId === peerId) - const peerExist = peerIndex !== -1 + const doesPeerExist = peerIndex !== -1 showAlert( `${ - peerExist ? getDisplayUsername(peerList[peerIndex].userId) : 'Someone' + doesPeerExist + ? getDisplayUsername(peerList[peerIndex].userId) + : 'Someone' } has left the room`, { severity: 'warning', } ) - if (peerExist) { + if (doesPeerExist) { const peerListClone = [...peerList] peerListClone.splice(peerIndex, 1) setPeerList(peerListClone) @@ -347,6 +369,18 @@ export function useRoom( setIsMessageSending(false) } + const handleMessageChange = () => { + if (isTyping) { + setIsTypingDebounced(true) + } else { + setIsTyping(true) + } + + // This queues up the expiration of the typing state. It is effectively + // cancelled once this message change handler is called again. + setIsTypingDebounced(false) + } + receivePeerInlineMedia(inlineMedia => { const userSettings = settingsContext.getUserSettings() @@ -365,6 +399,11 @@ export function useRoom( setMessageLog([...messageLog, { ...inlineMedia, timeReceived: Date.now() }]) }) + receiveTypingStatusChange((typingStatus, peerId) => { + const { isTyping } = typingStatus + updatePeer(peerId, { isTyping }) + }) + useEffect(() => { sendPeerMetadata({ customUsername, userId }) }, [customUsername, userId, sendPeerMetadata]) @@ -378,6 +417,7 @@ export function useRoom( return { isPrivate, handleInlineMediaUpload, + handleMessageChange, isMessageSending, messageLog, peerRoom, diff --git a/src/components/Shell/Shell.tsx b/src/components/Shell/Shell.tsx index 2833196..46bc5de 100644 --- a/src/components/Shell/Shell.tsx +++ b/src/components/Shell/Shell.tsx @@ -88,9 +88,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { const [peerAudios, setPeerAudios] = useState< Record >({}) - const showAlert = useCallback< - (message: string, options?: AlertOptions) => void - >((message, options) => { + + const showAlert = useCallback((message: string, options?: AlertOptions) => { setAlertText(message) setAlertSeverity(options?.severity ?? 'info') setIsAlertShowing(true) @@ -98,6 +97,21 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { const { connectionTestResults } = useConnectionTest() + const updatePeer = useCallback( + (peerId: string, updatedProperties: Partial) => { + const peerIndex = peerList.findIndex(peer => peer.peerId === peerId) + const doesPeerExist = peerIndex !== -1 + + if (!doesPeerExist) return + + const peerListClone = [...peerList] + const peer = peerList[peerIndex] + peerListClone[peerIndex] = { ...peer, ...updatedProperties } + setPeerList(peerListClone) + }, + [peerList] + ) + const shellContextValue = useMemo( () => ({ tabHasFocus, @@ -129,6 +143,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { customUsername, setCustomUsername, connectionTestResults, + updatePeer, }), [ isPeerListOpen, @@ -157,6 +172,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { customUsername, setCustomUsername, connectionTestResults, + updatePeer, ] ) diff --git a/src/contexts/ShellContext.ts b/src/contexts/ShellContext.ts index aa2e526..6a4b1c4 100644 --- a/src/contexts/ShellContext.ts +++ b/src/contexts/ShellContext.ts @@ -37,6 +37,7 @@ interface ShellContextProps { customUsername: string setCustomUsername: Dispatch> connectionTestResults: ConnectionTestResults + updatePeer: (peerId: string, updatedProperties: Partial) => void } export const ShellContext = createContext({ @@ -72,4 +73,5 @@ export const ShellContext = createContext({ hasRelay: false, trackerConnection: TrackerConnection.SEARCHING, }, + updatePeer: () => {}, }) diff --git a/src/models/chat.ts b/src/models/chat.ts index fb74f68..6653638 100644 --- a/src/models/chat.ts +++ b/src/models/chat.ts @@ -49,6 +49,7 @@ export interface Peer { videoState: VideoState screenShareState: ScreenShareState offeredFileId: string | null + isTyping: boolean } export const isMessageReceived = ( @@ -65,3 +66,7 @@ export interface FileOfferMetadata { magnetURI: string isAllInlineMedia: boolean } + +export interface TypingStatus { + isTyping: boolean +} diff --git a/src/models/network.ts b/src/models/network.ts index 7e994fc..c2ccf28 100644 --- a/src/models/network.ts +++ b/src/models/network.ts @@ -8,4 +8,5 @@ export enum PeerActions { VIDEO_CHANGE = 'VIDEO_CHANGE', SCREEN_SHARE = 'SCREEN_SHARE', FILE_OFFER = 'FILE_OFFER', + TYPING_STATUS_CHANGE = 'TYPNG_CHANGE', }