From 08a0237f23d25d00705e11b33228684aa1fa1a60 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 24 Jul 2026 11:32:32 +0200 Subject: [PATCH] Fix reported interface and sound issues --- apps/codebattle/assets/css/custom.scss | 6 +- apps/codebattle/assets/css/style.scss | 54 +++++++++++++-- .../js/__tests__/ChatInputEmoji.test.ts | 12 ++++ .../assets/js/__tests__/Registration.test.tsx | 20 ++++++ .../assets/js/__tests__/Sound.test.ts | 65 +++++++++++++++++++ .../js/widgets/components/ChatInput.tsx | 16 ++++- .../js/widgets/components/EmojiTooltip.tsx | 8 +-- .../codebattle/assets/js/widgets/lib/sound.ts | 46 ++++++++----- .../assets/js/widgets/pages/RoomWidget.tsx | 2 +- .../js/widgets/pages/game/ChatWidget.tsx | 2 +- .../js/widgets/pages/game/GameWidget.tsx | 2 +- .../js/widgets/pages/lobby/ActiveGames.tsx | 2 +- .../js/widgets/pages/lobby/LobbyWidget.tsx | 3 +- .../pages/registration/Registration.tsx | 6 +- .../widgets/pages/settings/UserSettings.tsx | 2 + .../templates/task_pack/_form.html.heex | 3 +- 16 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 apps/codebattle/assets/js/__tests__/ChatInputEmoji.test.ts create mode 100644 apps/codebattle/assets/js/__tests__/Sound.test.ts diff --git a/apps/codebattle/assets/css/custom.scss b/apps/codebattle/assets/css/custom.scss index 88448ba63..e902680d3 100644 --- a/apps/codebattle/assets/css/custom.scss +++ b/apps/codebattle/assets/css/custom.scss @@ -451,8 +451,9 @@ .cb-profile-heatmap-grid { width: 100%; - overflow: hidden; - padding: 0 14px 0.25rem 6px; + overflow-x: auto; + overflow-y: visible; + padding: 0.75rem 14px 0.25rem 6px; } .cb-profile-heatmap-grid svg { @@ -461,6 +462,7 @@ min-width: 0; max-width: 100%; height: auto; + overflow: visible; } .cb-profile-heatmap-overlay { diff --git a/apps/codebattle/assets/css/style.scss b/apps/codebattle/assets/css/style.scss index b35c12178..1d270ed2f 100644 --- a/apps/codebattle/assets/css/style.scss +++ b/apps/codebattle/assets/css/style.scss @@ -1305,6 +1305,15 @@ main > #app { min-width: 250px; } +.cb-game > [class*='col-'] { + min-width: 0; +} + +.cb-game-chat-layout, +.cb-game-chat-container { + min-width: 0; +} + @media screen and (min-width: $md) { .main-nav { padding-left: clamp(0.25rem, 1vw, 0.5rem); @@ -1394,6 +1403,18 @@ main > #app { .cb-game-control-container { width: 100%; + min-width: 0; + flex-shrink: 0; + } + + .cb-game-chat-layout { + flex-direction: column; + } + + .cb-game-chat-container { + width: 100%; + min-height: 0; + height: auto !important; } .main-nav { @@ -1614,19 +1635,32 @@ $cb-bronze-bottom: #4a3223; .dropdown-menu, .cb-dropdown-menu { & .dropdown-item.cb-dropdown-item { - color: white; + color: white !important; - &:hover:not(:active):not(.active) { + &:hover:not(:active):not(.active), + &:focus:not(:active):not(.active) { + color: white !important; background-color: $cb-bg-panel; } &:active, &.active { + color: white !important; background-color: $cb-bg-panel; } } } +.cb-dark-select, +.cb-dark-select option { + color: white; + background-color: $cb-bg-panel; +} + +.cb-dark-select { + color-scheme: dark; +} + div.cb-dropdown-menu { transform: translate3d(-100%, 0, 0); } @@ -1734,9 +1768,19 @@ div.cb-dropdown-menu { } @media screen and (min-width: $md) { - .cb-join-game-modal .modal-dialog { - min-width: 900px; - /* New width for default modal */ + .modal-dialog.cb-join-game-modal { + width: calc(100vw - 2rem); + min-width: 0; + max-width: 900px; + } + + .cb-join-game-modal table { + width: 100%; + table-layout: fixed; + } + + .cb-join-game-modal .cb-username-td { + max-width: 0; } } diff --git a/apps/codebattle/assets/js/__tests__/ChatInputEmoji.test.ts b/apps/codebattle/assets/js/__tests__/ChatInputEmoji.test.ts new file mode 100644 index 000000000..424ed6200 --- /dev/null +++ b/apps/codebattle/assets/js/__tests__/ChatInputEmoji.test.ts @@ -0,0 +1,12 @@ +import { getEmojiSearchQuery } from '../widgets/components/ChatInput'; + +describe('chat emoticon search', () => { + test('maps happy and sad text emoticons to the matching emoji', () => { + expect(getEmojiSearchQuery(':)')).toBe('smiley'); + expect(getEmojiSearchQuery(':(')).toBe('disappointed'); + }); + + test('keeps regular emoji shortcodes unchanged', () => { + expect(getEmojiSearchQuery(':rocket')).toBe('rocket'); + }); +}); diff --git a/apps/codebattle/assets/js/__tests__/Registration.test.tsx b/apps/codebattle/assets/js/__tests__/Registration.test.tsx index 563331a7d..eb98b48a9 100644 --- a/apps/codebattle/assets/js/__tests__/Registration.test.tsx +++ b/apps/codebattle/assets/js/__tests__/Registration.test.tsx @@ -36,6 +36,7 @@ describe('sign up', () => { }); beforeEach(() => { + window.history.pushState({}, '', '/users/new'); fetchMock = vi.fn(); globalThis.fetch = fetchMock as unknown as typeof fetch; }); @@ -85,4 +86,23 @@ describe('sign up', () => { }); }); }); + + test('shows a readable password recovery confirmation', async () => { + window.history.pushState({}, '', '/remind_password'); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const { getByLabelText, findByText, user } = setup(); + + await user.type(getByLabelText('email'), 'user@example.com'); + await user.click(getByLabelText('SubmitForm')); + + const confirmation = await findByText( + 'We have sent you an email with instructions on how to reset your password', + ); + + expect(confirmation).toHaveClass('text-white'); + }); }); diff --git a/apps/codebattle/assets/js/__tests__/Sound.test.ts b/apps/codebattle/assets/js/__tests__/Sound.test.ts new file mode 100644 index 000000000..5753e8e1a --- /dev/null +++ b/apps/codebattle/assets/js/__tests__/Sound.test.ts @@ -0,0 +1,65 @@ +import sound, { configureSound } from '../widgets/lib/sound'; + +const howlerMocks = vi.hoisted(() => ({ + howl: vi.fn(), + play: vi.fn(), + stop: vi.fn(), + volume: vi.fn(), +})); + +vi.mock('howler', () => ({ + Howl: function Howl(options: unknown) { + howlerMocks.howl(options); + + return { + play: howlerMocks.play, + volume: howlerMocks.volume, + }; + }, + Howler: { + stop: howlerMocks.stop, + volume: howlerMocks.volume, + }, +})); + +vi.mock('@/inertia/pageProps', () => ({ + getPageProp: () => ({ + sound_settings: { + type: 'standard', + level: 5, + }, + }), +})); + +describe('game sound settings', () => { + beforeEach(() => { + localStorage.clear(); + howlerMocks.howl.mockClear(); + howlerMocks.play.mockClear(); + howlerMocks.volume.mockClear(); + }); + + test('uses newly saved sound settings without a page reload', () => { + configureSound({ type: 'cs', level: 7, tournamentLevel: 3 }); + + sound.play('win'); + + const options = howlerMocks.howl.mock.calls[0][0] as { + src: string; + volume: number; + }; + + expect(options.src).toBe('/assets/audio/audioSprites/csSpritesAudio.wav'); + expect(options.volume).toBeCloseTo(0.7); + expect(howlerMocks.play).toHaveBeenCalledWith('win'); + }); + + test('does not create a player for silent mode', () => { + configureSound({ type: 'silent', level: 5 }); + + sound.play('win'); + + expect(howlerMocks.howl).not.toHaveBeenCalled(); + expect(howlerMocks.play).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/codebattle/assets/js/widgets/components/ChatInput.tsx b/apps/codebattle/assets/js/widgets/components/ChatInput.tsx index f78e10214..1aa633720 100644 --- a/apps/codebattle/assets/js/widgets/components/ChatInput.tsx +++ b/apps/codebattle/assets/js/widgets/components/ChatInput.tsx @@ -34,11 +34,21 @@ const trimColons = (message: string) => message.slice(0, message.lastIndexOf(':' const getColons = (message: string) => message.slice(message.lastIndexOf(':') + 1); +const emoticonSearchQueries: Record = { + ')': 'smiley', + '(': 'disappointed', +}; + +export const getEmojiSearchQuery = (message: string) => { + const query = getColons(message); + + return emoticonSearchQueries[query] || query; +}; + const getTooltipVisibility = async (msg: string) => { const endsWithEmojiCodeRegex = /.*:[a-zA-Z]{0,}([^ ])+$/; if (!endsWithEmojiCodeRegex.test(msg)) return Promise.resolve(false); - const colons = getColons(msg); - return !isEmpty(await SearchIndex.search(colons)); + return !isEmpty(await SearchIndex.search(getEmojiSearchQuery(msg))); }; interface ChatInputProps { @@ -210,7 +220,7 @@ export default function ChatInput({ )} {isTooltipVisible && ( diff --git a/apps/codebattle/assets/js/widgets/components/EmojiTooltip.tsx b/apps/codebattle/assets/js/widgets/components/EmojiTooltip.tsx index 724d95eb9..09d1adef3 100644 --- a/apps/codebattle/assets/js/widgets/components/EmojiTooltip.tsx +++ b/apps/codebattle/assets/js/widgets/components/EmojiTooltip.tsx @@ -14,12 +14,12 @@ interface EmojiItem { } interface EmojiTooltipProps { - colons: string; + query: string; handleSelect: (emoji: EmojiItem) => void; hide: () => void; } -export default function EmojiTooltip({ colons, handleSelect, hide }: EmojiTooltipProps) { +export default function EmojiTooltip({ query, handleSelect, hide }: EmojiTooltipProps) { const [activeIndex, setActiveIndex] = useState(0); const [emojis, setEmojis] = useState([]); @@ -32,7 +32,7 @@ export default function EmojiTooltip({ colons, handleSelect, hide }: EmojiToolti useEffect(() => { const fetchEmojis = async () => { - const rawEmojis = await SearchIndex.search(colons); + const rawEmojis = await SearchIndex.search(query); const preparedEmojis = rawEmojis.map((emoji: EmojiItem) => ({ ...emoji, native: emoji.skins[0].native, @@ -42,7 +42,7 @@ export default function EmojiTooltip({ colons, handleSelect, hide }: EmojiToolti }; fetchEmojis(); - }, [colons]); + }, [query]); const decreaseIndex = () => { setActiveIndex((prevIndex) => { diff --git a/apps/codebattle/assets/js/widgets/lib/sound.ts b/apps/codebattle/assets/js/widgets/lib/sound.ts index e7a9c88d8..595f62069 100644 --- a/apps/codebattle/assets/js/widgets/lib/sound.ts +++ b/apps/codebattle/assets/js/widgets/lib/sound.ts @@ -24,18 +24,30 @@ interface SoundSettings { type: string; level: number; tournament_level?: number; + tournamentLevel?: number; } -const soundSettings = getPageProp<{ sound_settings: SoundSettings }>('current_user', { - sound_settings: { type: 'standard', level: 5 }, +const defaultSoundSettings: SoundSettings = { type: 'standard', level: 5 }; +const initialSoundSettings = getPageProp<{ sound_settings: SoundSettings }>('current_user', { + sound_settings: defaultSoundSettings, }).sound_settings; -const soundType = soundSettings.type; -const defaultSoundLevel = soundSettings.level * 0.1; -const tournamentSoundLevel = isUndefined(soundSettings.tournament_level) - ? defaultSoundLevel - : soundSettings.tournament_level * 0.1; -const audio = (type: string = soundType, volume: number = defaultSoundLevel) => +let currentSoundSettings = { ...defaultSoundSettings, ...initialSoundSettings }; + +const getSoundType = () => currentSoundSettings.type; +const getDefaultSoundLevel = () => currentSoundSettings.level * 0.1; +const getTournamentSoundLevel = () => { + const tournamentLevel = + currentSoundSettings.tournamentLevel ?? currentSoundSettings.tournament_level; + + return isUndefined(tournamentLevel) ? getDefaultSoundLevel() : tournamentLevel * 0.1; +}; + +const configureSound = (settings: SoundSettings) => { + currentSoundSettings = { ...defaultSoundSettings, ...settings }; +}; + +const audio = (type: string = getSoundType(), volume: number = getDefaultSoundLevel()) => new Howl({ src: audioPaths[type as keyof typeof audioPaths], sprite: (audioConfigs[type as keyof typeof audioConfigs] as { sprite?: unknown }) @@ -48,7 +60,7 @@ const getAssetPlayer = (path: string) => { if (!assetPlayers[path]) { assetPlayers[path] = new Howl({ src: path, - volume: defaultSoundLevel, + volume: getDefaultSoundLevel(), }); } @@ -58,27 +70,27 @@ const getAssetPlayer = (path: string) => { const sound = { play: (type: string, soundLevel?: number) => { const isMute = JSON.parse((localStorage.getItem('ui_mute_sound') || false) as string); + if (getSoundType() === 'silent' || isMute) return; const soundEffect = audio(); - if (soundType === 'silent' || isMute) return; - Howler.volume(isUndefined(soundLevel) ? defaultSoundLevel : soundLevel); + Howler.volume(isUndefined(soundLevel) ? getDefaultSoundLevel() : soundLevel); soundEffect.play(type); }, playAsset: (path: string, soundLevel?: number) => { const isMute = JSON.parse((localStorage.getItem('ui_mute_sound') || false) as string); - if (soundType === 'silent' || isMute) return; - Howler.volume(isUndefined(soundLevel) ? defaultSoundLevel : soundLevel); + if (getSoundType() === 'silent' || isMute) return; + Howler.volume(isUndefined(soundLevel) ? getDefaultSoundLevel() : soundLevel); getAssetPlayer(path).play(); }, playTournamentAsset: (path: string) => { const isMute = JSON.parse((localStorage.getItem('ui_mute_sound') || false) as string); - if (soundType === 'silent' || isMute) return; - Howler.volume(tournamentSoundLevel); + if (getSoundType() === 'silent' || isMute) return; + Howler.volume(getTournamentSoundLevel()); const player = getAssetPlayer(path); player.volume(1); player.play(); }, stop: () => Howler.stop(), - toggle: (volume: number = defaultSoundLevel) => { + toggle: (volume: number = getDefaultSoundLevel()) => { Howler.volume(volume); }, }; @@ -98,5 +110,5 @@ const createPlayer = () => ({ stop: () => Howler.stop(), }); -export { createPlayer }; +export { configureSound, createPlayer }; export default sound; diff --git a/apps/codebattle/assets/js/widgets/pages/RoomWidget.tsx b/apps/codebattle/assets/js/widgets/pages/RoomWidget.tsx index 696ed0fa7..ae3c69d6c 100644 --- a/apps/codebattle/assets/js/widgets/pages/RoomWidget.tsx +++ b/apps/codebattle/assets/js/widgets/pages/RoomWidget.tsx @@ -90,7 +90,7 @@ function RoomWidget({ pageName, mainMachine, taskMachine, editorMachine }: RoomW invisible: !visible, })} > -
+
{showBattleRoom && ( <> diff --git a/apps/codebattle/assets/js/widgets/pages/game/ChatWidget.tsx b/apps/codebattle/assets/js/widgets/pages/game/ChatWidget.tsx index 6d5fd5959..1ddbf1d5b 100644 --- a/apps/codebattle/assets/js/widgets/pages/game/ChatWidget.tsx +++ b/apps/codebattle/assets/js/widgets/pages/game/ChatWidget.tsx @@ -70,7 +70,7 @@ function ChatWidget() { inputRef={inputRef as React.RefObject} request={menuRequest} > -
+
-
+
diff --git a/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx b/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx index 31cd300e2..d9dcd4f8c 100644 --- a/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx +++ b/apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx @@ -206,7 +206,8 @@ function LobbyWidget() { {i18n.t('Join a game')} diff --git a/apps/codebattle/assets/js/widgets/pages/registration/Registration.tsx b/apps/codebattle/assets/js/widgets/pages/registration/Registration.tsx index 3ffd53811..c5b5249c1 100644 --- a/apps/codebattle/assets/js/widgets/pages/registration/Registration.tsx +++ b/apps/codebattle/assets/js/widgets/pages/registration/Registration.tsx @@ -381,7 +381,11 @@ function ResetPassword() { if (isSend) { return ( - We have sent you an email with instructions on how to reset your password + +

+ We have sent you an email with instructions on how to reset your password +

+
); } diff --git a/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx b/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx index dbdf5a738..48f34b329 100644 --- a/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx +++ b/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.tsx @@ -13,6 +13,7 @@ import type { RootState } from '@/slices/store'; import { getPageProp } from '@/inertia/pageProps'; import i18n, { getSupportedLocale } from '../../../i18n'; +import { configureSound } from '../../lib/sound'; import { userSettingsSelector } from '../../selectors'; import { actions } from '../../slices'; @@ -226,6 +227,7 @@ function UserSettings() { await i18n.changeLanguage(getSupportedLocale(data.locale)); dispatch(actions.updateUserSettings(camelizeKeys(data))); + configureSound(settingsValues.soundSettings); if (Object.values(passwordValues).some((value) => value.trim())) { await updatePassword(passwordValues); diff --git a/apps/codebattle/lib/codebattle_web/templates/task_pack/_form.html.heex b/apps/codebattle/lib/codebattle_web/templates/task_pack/_form.html.heex index 7c779e4a5..6bd690ad8 100644 --- a/apps/codebattle/lib/codebattle_web/templates/task_pack/_form.html.heex +++ b/apps/codebattle/lib/codebattle_web/templates/task_pack/_form.html.heex @@ -17,7 +17,8 @@
{label(f, :visibility, class: "text-white")} {select(f, :visibility, Codebattle.TaskPack.visibility_types(), - class: "form-select form-select-lg custom-select cb-bg-panel cb-border-color text-white" + class: + "form-select form-select-lg custom-select cb-dark-select cb-bg-panel cb-border-color text-white" )} {error_tag(f, :visibility)}