feat: add campfire mode (#42663)

* feat: add campfire mode

fix: resolve lint issues

feat: add sound to editor

fix: restore flash messages

fix: linter issues

fix: obey sound setting

Update the editor to obey the camper's sound setting.

chore: apply suggestions from code review

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

fix: use @types/store

fix: linter issues

feat: simplify sound saga

Update client/src/redux/sound-mode-saga.js

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

fix: missing bracket

chore: use new s3 bucket

fix: lint

fix: import only needed bits

fix: remove from navbar

(was intermittently broken here anyway)

fix: dynamic imports?

fix: more dynamic imports

fix: tweak theme logic

chore: boolean | undefined

fix: dns

fix: no hammer local storage

* chore: apply oliver's review suggestions

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* fix: lost an import

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Nicholas Carrigan (he/him)
2021-10-27 15:50:29 -07:00
committed by GitHub
parent 87c31f5bc9
commit 07bfe87419
19 changed files with 383 additions and 13 deletions

View File

@ -251,6 +251,10 @@
"type": "string",
"default": "default"
},
"sound": {
"type": "boolean",
"default": false
},
"profileUI": {
"type": "object",
"default": {

View File

@ -51,6 +51,7 @@ export const userPropsForSession = [
'id',
'sendQuincyEmail',
'theme',
'sound',
'completedChallengeCount',
'completedProjectCount',
'completedCertCount',

View File

@ -133,7 +133,8 @@
"my-portfolio": "My portfolio",
"my-timeline": "My timeline",
"my-donations": "My donations",
"night-mode": "Night Mode"
"night-mode": "Night Mode",
"sound-mode": "Campfire Mode"
},
"headings": {
"certs": "Certifications",

View File

@ -6264,6 +6264,30 @@
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
},
"automation-events": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/automation-events/-/automation-events-4.0.3.tgz",
"integrity": "sha512-FVIO4QTVi4u4ORRMszcXhoUj1U8rtgIVCbMKgw1i/zlcbEn1MICbhfquDJiA1QZgh0KysUGuxrn2JeloH04drQ==",
"requires": {
"@babel/runtime": "^7.14.6",
"tslib": "^2.3.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz",
"integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
},
"autoprefixer": {
"version": "10.3.7",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.3.7.tgz",
@ -19344,6 +19368,31 @@
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.0.tgz",
"integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA=="
},
"standardized-audio-context": {
"version": "25.2.9",
"resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.2.9.tgz",
"integrity": "sha512-8xRl8Cqv3j8UZdRvAYhgqSpVRntjxUBxute+9Zg8zeXTr+aRfDgbAu5FHzTLpUlu8uAJ9232CdOOMkTceSNAWA==",
"requires": {
"@babel/runtime": "^7.14.6",
"automation-events": "^4.0.3",
"tslib": "^2.3.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz",
"integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
},
"state-toggle": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",
@ -20027,6 +20076,22 @@
"ieee754": "^1.2.1"
}
},
"tone": {
"version": "14.7.77",
"resolved": "https://registry.npmjs.org/tone/-/tone-14.7.77.tgz",
"integrity": "sha512-tCfK73IkLHyzoKUvGq47gyDyxiKLFvKiVCOobynGgBB9Dl0NkxTM2p+eRJXyCYrjJwy9Y0XCMqD3uOYsYt2Fdg==",
"requires": {
"standardized-audio-context": "^25.1.8",
"tslib": "^2.0.1"
},
"dependencies": {
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
},
"totalist": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz",

View File

@ -121,6 +121,7 @@
"sass.js": "0.11.1",
"store": "2.0.12",
"stream-browserify": "3.0.0",
"tone": "^14.7.77",
"typescript": "4.4.4",
"uuid": "8.3.2",
"validator": "13.6.0"

View File

@ -35,6 +35,7 @@ interface IShowSettingsProps {
showLoading: boolean;
submitNewAbout: () => void;
toggleNightMode: (theme: string) => void;
toggleSoundMode: (sound: boolean) => void;
updateInternetSettings: () => void;
updateIsHonest: () => void;
updatePortfolio: () => void;
@ -60,6 +61,7 @@ const mapDispatchToProps = {
navigate,
submitNewAbout,
toggleNightMode: (theme: string) => updateUserFlag({ theme }),
toggleSoundMode: (sound: boolean) => updateUserFlag({ sound }),
updateInternetSettings: updateUserFlag,
updateIsHonest: updateUserFlag,
updatePortfolio: updateUserFlag,
@ -75,6 +77,7 @@ export function ShowSettings(props: IShowSettingsProps): JSX.Element {
isSignedIn,
submitNewAbout,
toggleNightMode,
toggleSoundMode,
user: {
completedChallenges,
email,
@ -101,6 +104,7 @@ export function ShowSettings(props: IShowSettingsProps): JSX.Element {
picture,
points,
theme,
sound,
location,
name,
githubProfile,
@ -143,8 +147,10 @@ export function ShowSettings(props: IShowSettingsProps): JSX.Element {
name={name}
picture={picture}
points={points}
sound={sound}
submitNewAbout={submitNewAbout}
toggleNightMode={toggleNightMode}
toggleSoundMode={toggleSoundMode}
username={username}
/>
<Spacer />

View File

@ -6,9 +6,11 @@ import { connect } from 'react-redux';
import { goToAnchor } from 'react-scrollable-anchor';
import { bindActionCreators, Dispatch, AnyAction } from 'redux';
import { createSelector } from 'reselect';
import store from 'store';
import { modalDefaultDonation } from '../../../../config/donation-settings';
import Cup from '../../assets/icons/cup';
import Heart from '../../assets/icons/heart';
import {
closeDonationModal,
isDonationModalOpenSelector,
@ -76,6 +78,16 @@ function DonateModal({
useEffect(() => {
if (show) {
const playSound = store.get('fcc-sound') as boolean | undefined;
if (playSound) {
void import('tone').then(tone => {
const player = new tone.Player(
'https://campfire-mode.freecodecamp.org/donate.mp3'
).toDestination();
if (tone.context.state !== 'running') void tone.context.resume();
player.autostart = playSound;
});
}
executeGA({ type: 'modal', data: '/donation-modal' });
executeGA({
type: 'event',

View File

@ -1,4 +1,5 @@
import { nanoid } from 'nanoid';
import store from 'store';
import { FlashState, State } from '../../../redux/types';
export const FlashApp = 'flash';
@ -31,10 +32,32 @@ export type FlashMessageArg = {
export const createFlashMessage = (
flash: FlashMessageArg
): ReducerPayload<FlashActionTypes.createFlashMessage> => ({
): ReducerPayload<FlashActionTypes.createFlashMessage> => {
const playSound = store.get('fcc-sound') as boolean | undefined;
if (playSound) {
void import('tone').then(tone => {
if (tone.context.state !== 'running') {
void tone.context.resume();
}
if (flash.message === 'flash.incomplete-steps') {
const player = new tone.Player(
'https://campfire-mode.freecodecamp.org/try-again.mp3'
).toDestination();
player.autostart = playSound;
}
if (flash.message === 'flash.cert-claim-success') {
const player = new tone.Player(
'https://campfire-mode.freecodecamp.org/cert.mp3'
).toDestination();
player.autostart = playSound;
}
});
}
return {
type: FlashActionTypes.createFlashMessage,
payload: { ...flash, id: nanoid() }
});
};
};
export const removeFlashMessage =
(): ReducerPayload<FlashActionTypes.removeFlashMessage> => ({

View File

@ -10,6 +10,7 @@ import React, { Component } from 'react';
import { TFunction, withTranslation } from 'react-i18next';
import { FullWidthRow, Spacer } from '../helpers';
import BlockSaveButton from '../helpers/form/block-save-button';
import SoundSettings from './sound';
import ThemeSettings from './theme';
import UsernameSettings from './username';
@ -27,9 +28,11 @@ type AboutProps = {
name: string;
picture: string;
points: number;
sound: boolean;
submitNewAbout: (formValues: FormValues) => void;
t: TFunction;
toggleNightMode: (theme: string) => void;
toggleSoundMode: (sound: boolean) => void;
username: string;
};
@ -184,7 +187,14 @@ class AboutSettings extends Component<AboutProps, AboutState> {
const {
formValues: { name, location, picture, about }
} = this.state;
const { currentTheme, username, t, toggleNightMode } = this.props;
const {
currentTheme,
sound,
username,
t,
toggleNightMode,
toggleSoundMode
} = this.props;
return (
<div className='about-settings'>
<UsernameSettings username={username} />
@ -241,6 +251,7 @@ class AboutSettings extends Component<AboutProps, AboutState> {
currentTheme={currentTheme}
toggleNightMode={toggleNightMode}
/>
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
</FullWidthRow>
</div>
);

View File

@ -0,0 +1,34 @@
import { Form } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import ToggleSetting from './toggle-setting';
type SoundProps = {
sound: boolean;
toggleSoundMode: (sound: boolean) => void;
};
export default function SoundSettings({
sound,
toggleSoundMode
}: SoundProps): JSX.Element {
const { t } = useTranslation();
return (
<Form inline={true} onSubmit={(e: React.FormEvent) => e.preventDefault()}>
<ToggleSetting
action={t('settings.labels.sound-mode')}
flag={sound}
flagName='sound'
offLabel={t('buttons.off')}
onLabel={t('buttons.on')}
toggleFlag={() => {
toggleSoundMode(sound ? false : true);
}}
/>
</Form>
);
}
SoundSettings.displayName = 'SoundSettings';

View File

@ -1,6 +1,7 @@
import { Form } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import store from 'store';
import ToggleSetting from './toggle-setting';
@ -26,9 +27,33 @@ export default function ThemeSettings({
flagName='currentTheme'
offLabel={t('buttons.off')}
onLabel={t('buttons.on')}
toggleFlag={() =>
toggleNightMode(currentTheme === 'night' ? 'default' : 'night')
toggleFlag={async () => {
const playSound = store.get('fcc-sound') as boolean | undefined;
if (playSound) {
const tone = await import('tone');
const nightToDayPlayer = new tone.Player(
'https://campfire-mode.freecodecamp.org/day.mp3'
).toDestination();
const dayToNightPlayer = new tone.Player(
'https://campfire-mode.freecodecamp.org/night.mp3'
).toDestination();
if (tone.context.state !== 'running') await tone.context.resume();
if (currentTheme === 'night') {
if (!nightToDayPlayer.loaded)
await nightToDayPlayer.load(
'https://campfire-mode.freecodecamp.org/day.mp3'
);
nightToDayPlayer.start();
} else {
if (!dayToNightPlayer.loaded)
await dayToNightPlayer.load(
'https://campfire-mode.freecodecamp.org/night.mp3'
);
dayToNightPlayer.start();
}
}
toggleNightMode(currentTheme === 'night' ? 'default' : 'night');
}}
/>
</Form>
);

View File

@ -14,9 +14,9 @@ import { createGaSaga } from './ga-saga';
import hardGoToEpic from './hard-go-to-epic';
import { createReportUserSaga } from './report-user-saga';
import { actionTypes as settingsTypes } from './settings/action-types';
import { createShowCertSaga } from './show-cert-saga';
import { createSoundModeSaga } from './sound-mode-saga';
import updateCompleteEpic from './update-complete-epic';
export const MainApp = 'app';
@ -74,7 +74,8 @@ export const sagas = [
...createGaSaga(actionTypes),
...createFetchUserSaga(actionTypes),
...createShowCertSaga(actionTypes),
...createReportUserSaga(actionTypes)
...createReportUserSaga(actionTypes),
...createSoundModeSaga({ ...actionTypes, ...settingsTypes })
];
export const appMount = createAction(actionTypes.appMount);

View File

@ -118,6 +118,7 @@ export const User = PropTypes.shape({
})
),
sendQuincyEmail: PropTypes.bool,
sound: PropTypes.bool,
theme: PropTypes.string,
twitter: PropTypes.string,
username: PropTypes.string,
@ -295,6 +296,7 @@ export type UserType = {
};
progressTimestamps: Array<unknown>;
sendQuincyEmail: boolean;
sound: boolean;
theme: string;
twitter: string;
username: string;

View File

@ -0,0 +1,26 @@
/* eslint-disable require-yield */
import { takeEvery } from 'redux-saga/effects';
import store from 'store';
const soundKey = 'fcc-sound';
export function setSound(setting) {
store.set(soundKey, setting);
}
function* updateLocalSoundSaga({ payload: { user, sound } }) {
if (user) {
const { sound = false } = user;
setSound(sound);
} else if (typeof sound !== 'undefined') {
setSound(sound);
}
}
export function createSoundModeSaga(types) {
return [
takeEvery(types.fetchUserComplete, updateLocalSoundSaga),
takeEvery(types.updateUserFlagComplete, updateLocalSoundSaga)
];
}

View File

@ -17,6 +17,7 @@ import React, {
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import store from 'store';
import { Loader } from '../../../components/helpers';
import { userSelector, isDonationModalOpenSelector } from '../../../redux';
import {
@ -27,7 +28,8 @@ import {
ResizePropsType,
Test
} from '../../../redux/prop-types';
import { editorToneOptions } from '../../../utils/tone/editor-config';
import { editorNotes } from '../../../utils/tone/editor-notes';
import {
canFocusEditorSelector,
consoleOutputSelector,
@ -44,6 +46,7 @@ import {
import './editor.css';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const MonacoEditor = Loadable(() => import('react-monaco-editor'));
interface EditorProps {
@ -191,6 +194,17 @@ const Editor = (props: EditorProps): JSX.Element => {
const monacoRef: MutableRefObject<typeof monacoEditor | null> =
useRef<typeof monacoEditor>(null);
const dataRef = useRef<EditorProperties>({ ...initialData });
const player = useRef<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sampler: any;
noteIndex: number;
shouldPlay: boolean | undefined;
}>({
// eslint-disable-next-line no-undefined
sampler: undefined,
noteIndex: 0,
shouldPlay: store.get('fcc-sound') as boolean | undefined
});
// since editorDidMount runs once with the initial props object, it keeps a
// reference to *those* props. If we want it to use the latest props, we can
@ -265,6 +279,14 @@ const Editor = (props: EditorProps): JSX.Element => {
);
dataRef.current.model = model;
if (player.current.shouldPlay && !player.current.sampler) {
void import('tone').then(tone => {
player.current.sampler = new tone.Sampler(
editorToneOptions
).toDestination();
});
}
// TODO: do we need to return this?
return { model };
};
@ -535,6 +557,21 @@ const Editor = (props: EditorProps): JSX.Element => {
coveringRange.startLineNumber - 1,
coveringRange.endLineNumber + 1
];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (player.current.sampler?.loaded && player.current.shouldPlay) {
void import('tone').then(tone => {
if (tone.context.state !== 'running') void tone.context.resume();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
player.current.sampler?.triggerAttack(
editorNotes[player.current.noteIndex]
);
player.current.noteIndex++;
if (player.current.noteIndex >= editorNotes.length) {
player.current.noteIndex = 0;
}
});
}
updateFile({ fileKey, editorValue, editableRegionBoundaries });
};

View File

@ -13,6 +13,7 @@ import {
take,
cancel
} from 'redux-saga/effects';
import store from 'store';
import {
buildChallenge,
@ -97,10 +98,21 @@ export function* executeChallengeSaga({ payload }) {
yield put(updateTests(testResults));
const challengeComplete = testResults.every(test => test.pass && !test.err);
const playSound = store.get('fcc-sound');
let player;
if (playSound) {
void import('tone').then(tone => {
player = new tone.Player(
challengeComplete && payload?.showCompletionModal
? 'https://campfire-mode.freecodecamp.org/chal-comp.mp3'
: 'https://campfire-mode.freecodecamp.org/try-again.mp3'
).toDestination();
player.autostart = true;
});
}
if (challengeComplete && payload?.showCompletionModal) {
yield put(openModal('completion'));
}
yield put(updateConsole(i18next.t('learn.tests-completed')));
yield put(logsToConsole(i18next.t('learn.console-output')));
} catch (e) {

View File

@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import ScrollableAnchor from 'react-scrollable-anchor';
import { bindActionCreators } from 'redux';
import { createSelector } from 'reselect';
import store from 'store';
import envData from '../../../../../config/env.json';
import { isAuditedCert } from '../../../../../utils/is-audited';
@ -56,6 +57,16 @@ export class Block extends Component {
handleBlockClick() {
const { blockDashedName, toggleBlock, executeGA } = this.props;
const playSound = store.get('fcc-sound');
if (playSound) {
void import('tone').then(tone => {
const player = new tone.Player(
'https://tonejs.github.io/audio/berklee/guitar_chord1.mp3'
).toDestination();
if (tone.context.state !== 'running') tone.context.resume();
player.autostart = playSound;
});
}
executeGA({
type: 'event',
data: {

View File

@ -0,0 +1,51 @@
export const editorToneOptions = {
urls: {
C1: 'Guitar-Sample-01.mp3',
D1: 'Guitar-Sample-02.mp3',
E1: 'Guitar-Sample-03.mp3',
F1: 'Guitar-Sample-04.mp3',
G1: 'Guitar-Sample-05.mp3',
A1: 'Guitar-Sample-06.mp3',
B1: 'Guitar-Sample-07.mp3',
C2: 'Guitar-Sample-08.mp3',
D2: 'Guitar-Sample-09.mp3',
E2: 'Guitar-Sample-10.mp3',
F2: 'Guitar-Sample-11.mp3',
G2: 'Guitar-Sample-12.mp3',
A2: 'Guitar-Sample-13.mp3',
B2: 'Guitar-Sample-14.mp3',
C3: 'Guitar-Sample-15.mp3',
D3: 'Guitar-Sample-16.mp3',
E3: 'Guitar-Sample-17.mp3',
F3: 'Guitar-Sample-18.mp3',
G3: 'Guitar-Sample-19.mp3',
A3: 'Guitar-Sample-20.mp3',
B3: 'Guitar-Sample-21.mp3',
C4: 'Guitar-Sample-22.mp3',
D4: 'Guitar-Sample-23.mp3',
E4: 'Guitar-Sample-24.mp3',
F4: 'Guitar-Sample-25.mp3',
G4: 'Guitar-Sample-26.mp3',
A4: 'Guitar-Sample-27.mp3',
B4: 'Guitar-Sample-28.mp3',
C5: 'Guitar-Sample-29.mp3',
D5: 'Guitar-Sample-30.mp3',
E5: 'Guitar-Sample-31.mp3',
F5: 'Guitar-Sample-32.mp3',
G5: 'Guitar-Sample-33.mp3',
A5: 'Guitar-Sample-34.mp3',
B5: 'Guitar-Sample-35.mp3',
C6: 'Guitar-Sample-36.mp3',
D6: 'Guitar-Sample-37.mp3',
E6: 'Guitar-Sample-38.mp3',
F6: 'Guitar-Sample-39.mp3',
G6: 'Guitar-Sample-40.mp3',
A6: 'Guitar-Sample-41.mp3',
B6: 'Guitar-Sample-42.mp3',
C7: 'Guitar-Sample-43.mp3',
D7: 'Guitar-Sample-44.mp3',
E7: 'Guitar-Sample-45.mp3'
},
release: 1,
baseUrl: 'https://campfire-mode.freecodecamp.org/mariachi/'
};

View File

@ -0,0 +1,47 @@
export const editorNotes = [
'C1',
'D1',
'E1',
'F1',
'G1',
'A1',
'B1',
'C2',
'D2',
'E2',
'F2',
'G2',
'A2',
'B2',
'C3',
'D3',
'E3',
'F3',
'G3',
'A3',
'B3',
'C4',
'D4',
'E4',
'F4',
'G4',
'A4',
'B4',
'C5',
'D5',
'E5',
'F5',
'G5',
'A5',
'B5',
'C6',
'D6',
'E6',
'F6',
'G6',
'A6',
'B6',
'C7',
'D7',
'E7'
];