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
This commit is contained in:
Jeremy Kahn 2023-07-27 21:06:35 -05:00 committed by GitHub
parent af4cba8449
commit e597a667a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 187 additions and 20 deletions

34
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@emotion/styled": "^11.10.0", "@emotion/styled": "^11.10.0",
"@mui/icons-material": "^5.8.4", "@mui/icons-material": "^5.8.4",
"@mui/material": "^5.9.3", "@mui/material": "^5.9.3",
"@react-hook/debounce": "^4.0.0",
"@react-hook/window-size": "^3.1.1", "@react-hook/window-size": "^3.1.1",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0", "@testing-library/react": "^13.3.0",
@ -4587,9 +4588,9 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
}, },
"node_modules/@react-hook/debounce": { "node_modules/@react-hook/debounce": {
"version": "3.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-4.0.0.tgz",
"integrity": "sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==", "integrity": "sha512-706Xcg+KKWHk9BuZQUQ0ZQKp9zhv3/MbqFenWVfHcynYpSGRVwQTzJRGvPxvsdtXxJv+HfgKTY/O/hEejakwmA==",
"dependencies": { "dependencies": {
"@react-hook/latest": "^1.0.2" "@react-hook/latest": "^1.0.2"
}, },
@ -4637,6 +4638,17 @@
"react": ">=16.8" "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": { "node_modules/@rollup/plugin-babel": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@ -29935,9 +29947,9 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
}, },
"@react-hook/debounce": { "@react-hook/debounce": {
"version": "3.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@react-hook/debounce/-/debounce-4.0.0.tgz",
"integrity": "sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==", "integrity": "sha512-706Xcg+KKWHk9BuZQUQ0ZQKp9zhv3/MbqFenWVfHcynYpSGRVwQTzJRGvPxvsdtXxJv+HfgKTY/O/hEejakwmA==",
"requires": { "requires": {
"@react-hook/latest": "^1.0.2" "@react-hook/latest": "^1.0.2"
} }
@ -29970,6 +29982,16 @@
"@react-hook/debounce": "^3.0.0", "@react-hook/debounce": "^3.0.0",
"@react-hook/event": "^1.2.1", "@react-hook/event": "^1.2.1",
"@react-hook/throttle": "^2.2.0" "@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": { "@rollup/plugin-babel": {

View File

@ -9,6 +9,7 @@
"@emotion/styled": "^11.10.0", "@emotion/styled": "^11.10.0",
"@mui/icons-material": "^5.8.4", "@mui/icons-material": "^5.8.4",
"@mui/material": "^5.9.3", "@mui/material": "^5.9.3",
"@react-hook/debounce": "^4.0.0",
"@react-hook/window-size": "^3.1.1", "@react-hook/window-size": "^3.1.1",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0", "@testing-library/react": "^13.3.0",

View File

@ -15,11 +15,13 @@ import { messageCharacterSizeLimit } from 'config/messaging'
interface MessageFormProps { interface MessageFormProps {
onMessageSubmit: (message: string) => void onMessageSubmit: (message: string) => void
onMessageChange: (message: string) => void
isMessageSending: boolean isMessageSending: boolean
} }
export const MessageForm = ({ export const MessageForm = ({
onMessageSubmit, onMessageSubmit,
onMessageChange,
isMessageSending, isMessageSending,
}: MessageFormProps) => { }: MessageFormProps) => {
const textFieldRef = useRef<HTMLInputElement>(null) const textFieldRef = useRef<HTMLInputElement>(null)
@ -43,6 +45,7 @@ export const MessageForm = ({
const handleMessageChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleMessageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target const { value } = event.target
setTextMessage(value) setTextMessage(value)
onMessageChange(value)
} }
const submitMessage = () => { const submitMessage = () => {
@ -68,7 +71,7 @@ export const MessageForm = ({
} }
return ( return (
<form onSubmit={handleMessageSubmit} className="p-4"> <form onSubmit={handleMessageSubmit} className="pt-4 px-4">
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<FormControl fullWidth> <FormControl fullWidth>
<TextField <TextField

View File

@ -3,7 +3,7 @@ import Typography, { TypographyProps } from '@mui/material/Typography'
import { usePeerNameDisplay } from './usePeerNameDisplay' import { usePeerNameDisplay } from './usePeerNameDisplay'
import { getPeerName } from './getPeerName' import { getPeerName } from './getPeerName'
interface PeerNameDisplayProps extends TypographyProps { export interface PeerNameDisplayProps extends TypographyProps {
children: string children: string
} }

View File

@ -24,6 +24,7 @@ import { RoomFileUploadControls } from './RoomFileUploadControls'
import { RoomVideoDisplay } from './RoomVideoDisplay' import { RoomVideoDisplay } from './RoomVideoDisplay'
import { RoomShowMessagesControls } from './RoomShowMessagesControls' import { RoomShowMessagesControls } from './RoomShowMessagesControls'
import { RoomHideRoomControls } from './RoomHideRoomControls' import { RoomHideRoomControls } from './RoomHideRoomControls'
import { TypingStatusBar } from './TypingStatusBar'
export interface RoomProps { export interface RoomProps {
appId?: string appId?: string
@ -43,6 +44,7 @@ export function Room({
const { const {
isMessageSending, isMessageSending,
handleInlineMediaUpload, handleInlineMediaUpload,
handleMessageChange,
messageLog, messageLog,
peerRoom, peerRoom,
roomContextValue, roomContextValue,
@ -150,10 +152,14 @@ export function Room({
className="grow overflow-auto px-4" className="grow overflow-auto px-4"
/> />
<Divider /> <Divider />
<MessageForm <Box>
onMessageSubmit={handleMessageSubmit} <MessageForm
isMessageSending={isMessageSending} onMessageSubmit={handleMessageSubmit}
/> isMessageSending={isMessageSending}
onMessageChange={handleMessageChange}
/>
<TypingStatusBar />
</Box>
</Box> </Box>
)} )}
</Box> </Box>

View File

@ -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<PeerNameDisplayProps> = {
variant: 'caption',
sx: theme => ({
color: theme.palette.text.secondary,
fontWeight: theme.typography.fontWeightBold,
}),
}
let statusMessage = <></>
if (typingPeers.length === 1) {
statusMessage = (
<>
<PeerNameDisplay {...peerNameDisplayProps}>
{typingPeers[0].userId}
</PeerNameDisplay>{' '}
is typing...
</>
)
} else if (typingPeers.length === 2) {
statusMessage = (
<>
<PeerNameDisplay {...peerNameDisplayProps}>
{typingPeers[0].userId}
</PeerNameDisplay>{' '}
and{' '}
<PeerNameDisplay {...peerNameDisplayProps}>
{typingPeers[1].userId}
</PeerNameDisplay>{' '}
are typing...
</>
)
} else if (typingPeers.length > 2) {
statusMessage = <>Several people are typing...</>
}
return (
<Box>
<Typography
variant="caption"
sx={theme => ({
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}
</Typography>
</Box>
)
}

View File

@ -2,6 +2,7 @@ import { useContext, useEffect, useMemo, useState } from 'react'
import { BaseRoomConfig } from 'trystero' import { BaseRoomConfig } from 'trystero'
import { TorrentRoomConfig } from 'trystero/torrent' import { TorrentRoomConfig } from 'trystero/torrent'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { useDebounce } from '@react-hook/debounce'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { SettingsContext } from 'contexts/SettingsContext' import { SettingsContext } from 'contexts/SettingsContext'
@ -19,6 +20,7 @@ import {
isMessageReceived, isMessageReceived,
isInlineMedia, isInlineMedia,
FileOfferMetadata, FileOfferMetadata,
TypingStatus,
} from 'models/chat' } from 'models/chat'
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay' import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
import { NotificationService } from 'services/Notification' import { NotificationService } from 'services/Notification'
@ -60,6 +62,7 @@ export function useRoom(
setRoomId, setRoomId,
setPassword, setPassword,
customUsername, customUsername,
updatePeer,
} = useContext(ShellContext) } = useContext(ShellContext)
const settingsContext = useContext(SettingsContext) const settingsContext = useContext(SettingsContext)
@ -151,12 +154,26 @@ export function useRoom(
] ]
) )
const [sendTypingStatusChange, receiveTypingStatusChange] =
usePeerRoomAction<TypingStatus>(peerRoom, PeerActions.TYPING_STATUS_CHANGE)
const [isTyping, setIsTypingDebounced, setIsTyping] = useDebounce(
false,
2000,
true
)
useEffect(() => {
sendTypingStatusChange({ isTyping })
}, [isTyping, sendTypingStatusChange])
useEffect(() => { useEffect(() => {
return () => { return () => {
sendTypingStatusChange({ isTyping: false })
peerRoom.leaveRoom() peerRoom.leaveRoom()
setPeerList([]) setPeerList([])
} }
}, [peerRoom, setPeerList]) }, [peerRoom, setPeerList, sendTypingStatusChange])
useEffect(() => { useEffect(() => {
setPassword(password) setPassword(password)
@ -201,6 +218,7 @@ export function useRoom(
id: getUuid(), id: getUuid(),
} }
setIsTyping(false)
setIsMessageSending(true) setIsMessageSending(true)
setMessageLog([...messageLog, unsentMessage]) setMessageLog([...messageLog, unsentMessage])
await sendPeerMessage(unsentMessage) await sendPeerMessage(unsentMessage)
@ -226,6 +244,7 @@ export function useRoom(
videoState: VideoState.STOPPED, videoState: VideoState.STOPPED,
screenShareState: ScreenShareState.NOT_SHARING, screenShareState: ScreenShareState.NOT_SHARING,
offeredFileId: null, offeredFileId: null,
isTyping: false,
}, },
]) ])
} else { } else {
@ -250,7 +269,7 @@ export function useRoom(
setMessageLog(transcript) setMessageLog(transcript)
}) })
receivePeerMessage(message => { receivePeerMessage((message, peerId) => {
const userSettings = settingsContext.getUserSettings() const userSettings = settingsContext.getUserSettings()
if (!isShowingMessages) { if (!isShowingMessages) {
@ -272,6 +291,7 @@ export function useRoom(
} }
setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }]) setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }])
updatePeer(peerId, { isTyping: false })
}) })
peerRoom.onPeerJoin(PeerHookType.NEW_PEER, (peerId: string) => { peerRoom.onPeerJoin(PeerHookType.NEW_PEER, (peerId: string) => {
@ -299,18 +319,20 @@ export function useRoom(
peerRoom.onPeerLeave(PeerHookType.NEW_PEER, (peerId: string) => { peerRoom.onPeerLeave(PeerHookType.NEW_PEER, (peerId: string) => {
const peerIndex = peerList.findIndex(peer => peer.peerId === peerId) const peerIndex = peerList.findIndex(peer => peer.peerId === peerId)
const peerExist = peerIndex !== -1 const doesPeerExist = peerIndex !== -1
showAlert( showAlert(
`${ `${
peerExist ? getDisplayUsername(peerList[peerIndex].userId) : 'Someone' doesPeerExist
? getDisplayUsername(peerList[peerIndex].userId)
: 'Someone'
} has left the room`, } has left the room`,
{ {
severity: 'warning', severity: 'warning',
} }
) )
if (peerExist) { if (doesPeerExist) {
const peerListClone = [...peerList] const peerListClone = [...peerList]
peerListClone.splice(peerIndex, 1) peerListClone.splice(peerIndex, 1)
setPeerList(peerListClone) setPeerList(peerListClone)
@ -347,6 +369,18 @@ export function useRoom(
setIsMessageSending(false) 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 => { receivePeerInlineMedia(inlineMedia => {
const userSettings = settingsContext.getUserSettings() const userSettings = settingsContext.getUserSettings()
@ -365,6 +399,11 @@ export function useRoom(
setMessageLog([...messageLog, { ...inlineMedia, timeReceived: Date.now() }]) setMessageLog([...messageLog, { ...inlineMedia, timeReceived: Date.now() }])
}) })
receiveTypingStatusChange((typingStatus, peerId) => {
const { isTyping } = typingStatus
updatePeer(peerId, { isTyping })
})
useEffect(() => { useEffect(() => {
sendPeerMetadata({ customUsername, userId }) sendPeerMetadata({ customUsername, userId })
}, [customUsername, userId, sendPeerMetadata]) }, [customUsername, userId, sendPeerMetadata])
@ -378,6 +417,7 @@ export function useRoom(
return { return {
isPrivate, isPrivate,
handleInlineMediaUpload, handleInlineMediaUpload,
handleMessageChange,
isMessageSending, isMessageSending,
messageLog, messageLog,
peerRoom, peerRoom,

View File

@ -88,9 +88,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [peerAudios, setPeerAudios] = useState< const [peerAudios, setPeerAudios] = useState<
Record<string, HTMLAudioElement> Record<string, HTMLAudioElement>
>({}) >({})
const showAlert = useCallback<
(message: string, options?: AlertOptions) => void const showAlert = useCallback((message: string, options?: AlertOptions) => {
>((message, options) => {
setAlertText(message) setAlertText(message)
setAlertSeverity(options?.severity ?? 'info') setAlertSeverity(options?.severity ?? 'info')
setIsAlertShowing(true) setIsAlertShowing(true)
@ -98,6 +97,21 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const { connectionTestResults } = useConnectionTest() const { connectionTestResults } = useConnectionTest()
const updatePeer = useCallback(
(peerId: string, updatedProperties: Partial<Peer>) => {
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( const shellContextValue = useMemo(
() => ({ () => ({
tabHasFocus, tabHasFocus,
@ -129,6 +143,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
customUsername, customUsername,
setCustomUsername, setCustomUsername,
connectionTestResults, connectionTestResults,
updatePeer,
}), }),
[ [
isPeerListOpen, isPeerListOpen,
@ -157,6 +172,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
customUsername, customUsername,
setCustomUsername, setCustomUsername,
connectionTestResults, connectionTestResults,
updatePeer,
] ]
) )

View File

@ -37,6 +37,7 @@ interface ShellContextProps {
customUsername: string customUsername: string
setCustomUsername: Dispatch<SetStateAction<string>> setCustomUsername: Dispatch<SetStateAction<string>>
connectionTestResults: ConnectionTestResults connectionTestResults: ConnectionTestResults
updatePeer: (peerId: string, updatedProperties: Partial<Peer>) => void
} }
export const ShellContext = createContext<ShellContextProps>({ export const ShellContext = createContext<ShellContextProps>({
@ -72,4 +73,5 @@ export const ShellContext = createContext<ShellContextProps>({
hasRelay: false, hasRelay: false,
trackerConnection: TrackerConnection.SEARCHING, trackerConnection: TrackerConnection.SEARCHING,
}, },
updatePeer: () => {},
}) })

View File

@ -49,6 +49,7 @@ export interface Peer {
videoState: VideoState videoState: VideoState
screenShareState: ScreenShareState screenShareState: ScreenShareState
offeredFileId: string | null offeredFileId: string | null
isTyping: boolean
} }
export const isMessageReceived = ( export const isMessageReceived = (
@ -65,3 +66,7 @@ export interface FileOfferMetadata {
magnetURI: string magnetURI: string
isAllInlineMedia: boolean isAllInlineMedia: boolean
} }
export interface TypingStatus {
isTyping: boolean
}

View File

@ -8,4 +8,5 @@ export enum PeerActions {
VIDEO_CHANGE = 'VIDEO_CHANGE', VIDEO_CHANGE = 'VIDEO_CHANGE',
SCREEN_SHARE = 'SCREEN_SHARE', SCREEN_SHARE = 'SCREEN_SHARE',
FILE_OFFER = 'FILE_OFFER', FILE_OFFER = 'FILE_OFFER',
TYPING_STATUS_CHANGE = 'TYPNG_CHANGE',
} }