Skip to content

Commit 6b93c32

Browse files
committed
Keyboard shortcut preference system
1 parent ec13871 commit 6b93c32

File tree

7 files changed

+213
-31
lines changed

7 files changed

+213
-31
lines changed

src/client/components/ChatV2/ChatBox.tsx

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next'
1313
import ModelSelector from './ModelSelector'
1414
import { BlueButton, GrayButton, OutlineButtonBlack } from './general/Buttons'
1515
import { useIsEmbedded } from '../../contexts/EmbeddedContext'
16+
import useCurrentUser from '../../hooks/useCurrentUser'
17+
import { SendPreferenceConfiguratorModal } from './SendPreferenceConfigurator'
1618

1719
export const ChatBox = ({
1820
disabled,
@@ -57,11 +59,14 @@ export const ChatBox = ({
5759
}) => {
5860
const { courseId } = useParams()
5961
const isEmbedded = useIsEmbedded()
62+
const { user } = useCurrentUser()
6063
const { userStatus, isLoading: statusLoading, refetch: refetchStatus } = useUserStatus(courseId)
6164

6265
const [isTokenLimitExceeded, setIsTokenLimitExceeded] = useState<boolean>(false)
6366
const [disallowedFileType, setDisallowedFileType] = useState<string>('')
6467
const [fileTypeAlertOpen, setFileTypeAlertOpen] = useState<boolean>(false)
68+
const [sendPreferenceConfiguratorOpen, setSendPreferenceConfiguratorOpen] = useState<boolean>(false)
69+
const sendButtonRef = useRef<HTMLButtonElement>(null)
6570

6671
const [defaultMessage, setDefaultMessage] = useState<string>('') // <--- used to trigger re render only onSubmit to empty the textField, dont update this on every key press
6772
const textFieldRef = useRef<HTMLInputElement>(null)
@@ -96,12 +101,14 @@ export const ChatBox = ({
96101
// This is here to prevent the form from submitting on disabled.
97102
// It is done this way instead of explicitely disabling the textfield
98103
// so that it doesnt break the re-focus back on the text field after message is send
99-
if (disabled) return
104+
if (disabled || !messageText.trim()) return
100105

101-
if (messageText.trim()) {
102-
handleSubmit(messageText)
103-
refetchStatus()
104-
setDefaultMessage('') //<--- just triggers the textField to go empty
106+
handleSubmit(messageText)
107+
refetchStatus()
108+
setDefaultMessage('') //<--- just triggers the textField to go empty
109+
110+
if (user && user.preferences?.sendShortcutMode === undefined) {
111+
setSendPreferenceConfiguratorOpen(true)
105112
}
106113

107114
if (textFieldRef.current) {
@@ -159,8 +166,16 @@ export const ChatBox = ({
159166
component="form"
160167
onSubmit={onSubmit}
161168
onKeyDown={(e) => {
162-
if (e.key === 'Enter' && e.shiftKey) {
163-
onSubmit(e)
169+
if (e.key === 'Enter') {
170+
if (user?.preferences?.sendShortcutMode === 'enter') {
171+
if (e.shiftKey) {
172+
// Do nothing with this event, it will result in a newline being inserted
173+
} else {
174+
onSubmit(e)
175+
}
176+
} else if (e.shiftKey) {
177+
onSubmit(e)
178+
}
164179
}
165180
}}
166181
>
@@ -221,21 +236,17 @@ export const ChatBox = ({
221236
)}
222237
</Box>
223238

224-
{disabled ? (
225-
// Stop signal is currently not supported due to OpenAI response cancel endpoint not working properly.
226-
// Try implementing this in the fall 2025.
227-
<Tooltip title={t('chat:cancelResponse')} arrow placement="top">
228-
<IconButton disabled={!disabled}>
229-
<StopIcon />
230-
</IconButton>
231-
</Tooltip>
232-
) : (
233-
<Tooltip title={t('chat:shiftEnter')} arrow placement="top">
234-
<IconButton disabled={disabled} type="submit">
235-
<Send />
236-
</IconButton>
237-
</Tooltip>
238-
)}
239+
<Tooltip title={disabled ? t('chat:cancelResponse') : t('chat:shiftEnter')} arrow placement="top">
240+
<IconButton type={disabled ? 'button' : 'submit'} ref={sendButtonRef}>
241+
{disabled ? <StopIcon /> : <Send />}
242+
</IconButton>
243+
</Tooltip>
244+
<SendPreferenceConfiguratorModal
245+
open={sendPreferenceConfiguratorOpen}
246+
onClose={() => setSendPreferenceConfiguratorOpen(false)}
247+
anchorEl={sendButtonRef.current}
248+
context="chat"
249+
/>
239250
</Box>
240251
</Box>
241252

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Box, Divider, FormControl, FormControlLabel, FormLabel, ListSubheader, Menu, MenuItem, Radio, RadioGroup, Typography } from '@mui/material'
2+
import useCurrentUser from '../../hooks/useCurrentUser'
3+
import { useTranslation } from 'react-i18next'
4+
import { OutlineButtonBlack } from './general/Buttons'
5+
import { ArrowUpward, KeyboardReturn, Settings } from '@mui/icons-material'
6+
import { usePreferencesUpdateMutation } from '../../hooks/usePreferencesUpdateMutation'
7+
import { UserPreferencesSchema } from '../../../shared/user'
8+
import { useSnackbar } from 'notistack'
9+
import { useState } from 'react'
10+
11+
export const SendPreferenceConfiguratorModal = ({ open, onClose, anchorEl, context }) => {
12+
const { user } = useCurrentUser()
13+
const preferenceUpdate = usePreferencesUpdateMutation()
14+
const { enqueueSnackbar } = useSnackbar()
15+
const { t } = useTranslation()
16+
17+
const defaultValue = user?.preferences?.sendShortcutMode || 'shift+enter'
18+
const [value, setValue] = useState(defaultValue)
19+
20+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
21+
event.preventDefault()
22+
onClose()
23+
const preferenceUpdates = {
24+
sendShortcutMode: value,
25+
}
26+
let msg = t('sendPreferenceConfigurator:success')
27+
if (context === 'chat') {
28+
msg += ` ${t('sendPreferenceConfigurator:canChangeInSettings')}`
29+
}
30+
enqueueSnackbar(msg, { variant: 'success' })
31+
await preferenceUpdate.mutateAsync(preferenceUpdates)
32+
}
33+
34+
const handleClose = async () => {
35+
onClose()
36+
if (context === 'chat') {
37+
enqueueSnackbar(t('sendPreferenceConfigurator:canChangeInSettings'), { variant: 'info' })
38+
}
39+
await preferenceUpdate.mutateAsync({ sendShortcutMode: defaultValue })
40+
}
41+
42+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
43+
// Zod parsing to please ts
44+
const preferenceUpdates = UserPreferencesSchema.parse({
45+
sendShortcutMode: (event.target as HTMLInputElement).value,
46+
})
47+
if (preferenceUpdates.sendShortcutMode) {
48+
setValue(preferenceUpdates.sendShortcutMode)
49+
}
50+
}
51+
52+
return (
53+
<Menu
54+
open={open}
55+
onClose={handleClose}
56+
anchorEl={anchorEl}
57+
anchorOrigin={{
58+
vertical: 'top',
59+
horizontal: 'right',
60+
}}
61+
transformOrigin={{
62+
vertical: 'bottom',
63+
horizontal: 'right',
64+
}}
65+
>
66+
<form onSubmit={handleSubmit}>
67+
<FormControl sx={{ p: 2 }}>
68+
<Typography>{t('sendPreferenceConfigurator:title')}</Typography>
69+
<RadioGroup value={value} onChange={handleChange} name="sendPreferenceConfigurator">
70+
<FormControlLabel
71+
sx={{ my: 2 }}
72+
value="shift+enter"
73+
control={<Radio />}
74+
label={
75+
<div>
76+
<Box display="flex" alignItems="center" gap={1}>
77+
<strong>{t('sendPreferenceConfigurator:shift')}</strong>
78+
<ArrowUpward fontSize="small" />+ <strong>{t('sendPreferenceConfigurator:return')}</strong>
79+
<KeyboardReturn fontSize="small" />
80+
{t('sendPreferenceConfigurator:toSend')}
81+
</Box>
82+
<Box display="flex" alignItems="center" gap={1}>
83+
<strong>{t('sendPreferenceConfigurator:return')}</strong>
84+
<KeyboardReturn fontSize="small" />
85+
{t('sendPreferenceConfigurator:toAddNewline')}
86+
</Box>
87+
</div>
88+
}
89+
/>
90+
<FormControlLabel
91+
sx={{ mb: 2 }}
92+
value="enter"
93+
control={<Radio />}
94+
label={
95+
<div>
96+
<Box display="flex" alignItems="center" gap={1}>
97+
<strong>{t('sendPreferenceConfigurator:return')}</strong>
98+
<KeyboardReturn fontSize="small" />
99+
{t('sendPreferenceConfigurator:toSend')}
100+
</Box>
101+
<Box display="flex" alignItems="center" gap={1}>
102+
<strong>{t('sendPreferenceConfigurator:shift')}</strong>
103+
<ArrowUpward fontSize="small" />+ <strong>{t('sendPreferenceConfigurator:return')}</strong>
104+
<KeyboardReturn fontSize="small" />
105+
{t('sendPreferenceConfigurator:toAddNewline')}
106+
</Box>
107+
</div>
108+
}
109+
/>
110+
</RadioGroup>
111+
<OutlineButtonBlack type="submit">{t('sendPreferenceConfigurator:ok')}</OutlineButtonBlack>
112+
</FormControl>
113+
</form>
114+
</Menu>
115+
)
116+
}
117+
118+
export const SendPreferenceConfiguratorButton = () => {
119+
const { t } = useTranslation()
120+
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
121+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => setAnchorEl(event.currentTarget)
122+
const handleClose = () => setAnchorEl(null)
123+
const open = Boolean(anchorEl)
124+
125+
return (
126+
<>
127+
<OutlineButtonBlack onClick={handleClick} endIcon={<Settings />}>
128+
{t('sendPreferenceConfigurator:openConfigurator')}
129+
</OutlineButtonBlack>
130+
<SendPreferenceConfiguratorModal open={open} onClose={handleClose} anchorEl={anchorEl} context="settings" />
131+
</>
132+
)
133+
}

src/client/components/ChatV2/SettingsModal.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Add, Close } from '@mui/icons-material'
1+
import { Add, Close, Settings } from '@mui/icons-material'
22
import { Box, IconButton, Modal, Slider, Typography } from '@mui/material'
33
import { useEffect, useRef, useState } from 'react'
44
import { useTranslation } from 'react-i18next'
@@ -14,6 +14,7 @@ import { BlueButton, OutlineButtonBlack } from './general/Buttons'
1414
import { useAnalyticsDispatch } from '../../stores/analytics'
1515
import useLocalStorageState from '../../hooks/useLocalStorageState'
1616
import { isAxiosError } from 'axios'
17+
import { SendPreferenceConfiguratorButton } from './SendPreferenceConfigurator'
1718

1819
const useUrlPromptId = () => {
1920
const [searchParams] = useSearchParams()
@@ -241,6 +242,13 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
241242
</Box>
242243
</Box>
243244
</Box>
245+
246+
<Typography variant="h6" fontWeight={600} mt="2rem">
247+
{t('settings:other')}
248+
</Typography>
249+
<div>
250+
<SendPreferenceConfiguratorButton />
251+
</div>
244252
</Box>
245253

246254
<Box

src/client/hooks/usePreferencesUpdateMutation.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { useMutation, useQueryClient } from '@tanstack/react-query'
22
import apiClient from '../util/apiClient'
3-
import type { UserPreferences } from '../../shared/user'
3+
import type { User, UserPreferences } from '../../shared/user'
44

55
export const usePreferencesUpdateMutation = () => {
66
const queryClient = useQueryClient()
77

88
return useMutation({
99
mutationFn: async (preferences: UserPreferences) => {
1010
// Optimistic update. It is safe to update preferences without waiting for server validation.
11-
queryClient.setQueryData(['login'], (oldData: object) => ({
11+
queryClient.setQueryData(['login'], (oldData: User) => ({
1212
...oldData,
13-
preferences,
13+
preferences: {
14+
...oldData.preferences,
15+
...preferences,
16+
},
1417
}))
1518

1619
const response = await apiClient.put<UserPreferences>('/users/preferences', preferences)

src/client/locales/en.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@
129129
"default": "Default",
130130
"confirmDeletePrompt": "Are you sure you want to delete the prompt '{{name}}'?",
131131
"selectedSource": "No selected source",
132-
"sourceDescription": "Materials specified by the teacher can be used as a source in the course chat. You can select the source below."
132+
"sourceDescription": "Materials specified by the teacher can be used as a source in the course chat. You can select the source below.",
133+
"other": "Other settings"
133134
},
134135
"email": {
135136
"save": "Save as email",
@@ -307,5 +308,16 @@
307308
"buttonV2": "Select as default",
308309
"titleV1": "This is the old chat view. You've selected the new view as default but can restore the old default here:",
309310
"buttonV1": "Select as default"
311+
},
312+
"sendPreferenceConfigurator": {
313+
"success": "Preference updated.",
314+
"canChangeInSettings": "You can change the send shortcut in chat settings.",
315+
"title": "What key combination would you like to use to send a message?",
316+
"shift": "Shift",
317+
"return": "Return",
318+
"toSend": "to send a message",
319+
"toAddNewline": "to add a new line",
320+
"ok": "Ok",
321+
"openConfigurator": "Keyboard shortcut for sending a message"
310322
}
311323
}

src/client/locales/fi.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@
129129
"selectedSource": "Ei valittua lähdettä",
130130
"default": "Oletus",
131131
"confirmDeletePrompt": "Haluatko varmasti poistaa alustuksen '{{name}}'?",
132-
"sourceDescription": "Kurssichatissa voidaan käyttää lähteenä opettajan määrittämiä materiaaleja. Voit valita lähteen alta."
132+
"sourceDescription": "Kurssichatissa voidaan käyttää lähteenä opettajan määrittämiä materiaaleja. Voit valita lähteen alta.",
133+
"other": "Muut asetukset"
133134
},
134135
"chats": {
135136
"header": "Auki olevat kurssichatit",
@@ -307,5 +308,16 @@
307308
"buttonV2": "Ota käyttöön oletusarvoisena",
308309
"titleV1": "Tämä on vanha chattinäkymä. Olet valinnut oletukseksi uuden näkymän mutta voit palauttaa vanhan tästä:",
309310
"buttonV1": "Valitse oletusarvoksi"
311+
},
312+
"sendPreferenceConfigurator": {
313+
"success": "Valinta päivitetty.",
314+
"canChangeInSettings": "Voit muuttaa lähetyksen pikanäppäintä keskustelun asetuksissa.",
315+
"title": "Millä näppäinyhdistelmällä haluat lähettää viestin jatkossa?",
316+
"shift": "Shift",
317+
"return": "Return",
318+
"toSend": "lähettää viestin",
319+
"toAddNewline": "lisää uuden rivin",
320+
"ok": "Ok",
321+
"openConfigurator": "Viestin lähetyksen näppäinyhdistelmä"
310322
}
311323
}

src/shared/user.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { z } from 'zod/v4'
22

3-
export const UserPreferencesSchema = z.object({
4-
chatVersion: z.number().min(1).max(2).default(1),
5-
})
3+
export const UserPreferencesSchema = z
4+
.object({
5+
chatVersion: z.number().min(1).max(2).default(1),
6+
sendShortcutMode: z.enum(['shift+enter', 'enter']).default('shift+enter'),
7+
})
8+
.partial()
69

710
export type UserPreferences = z.infer<typeof UserPreferencesSchema>
811

0 commit comments

Comments
 (0)