diff --git a/android/app/src/main/res/raw/attention.mp3 b/android/app/src/main/res/raw/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/android/app/src/main/res/raw/attention.mp3 differ diff --git a/android/app/src/main/res/raw/done.mp3 b/android/app/src/main/res/raw/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/android/app/src/main/res/raw/done.mp3 differ diff --git a/android/app/src/main/res/raw/receive.mp3 b/android/app/src/main/res/raw/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/android/app/src/main/res/raw/receive.mp3 differ diff --git a/android/app/src/main/res/raw/success.mp3 b/android/app/src/main/res/raw/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/android/app/src/main/res/raw/success.mp3 differ diff --git a/assets/sounds/attention.mp3 b/assets/sounds/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/assets/sounds/attention.mp3 differ diff --git a/assets/sounds/done.mp3 b/assets/sounds/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/assets/sounds/done.mp3 differ diff --git a/assets/sounds/receive.mp3 b/assets/sounds/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/assets/sounds/receive.mp3 differ diff --git a/assets/sounds/success.mp3 b/assets/sounds/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/assets/sounds/success.mp3 differ diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 9b1a28382fa2..9fcbec23b70e 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -97,6 +97,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'web/manifest.json'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, + {from: 'assets/sounds', to: 'sounds'}, {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, {from: 'node_modules/react-pdf/dist/esm/Page/TextLayer.css', to: 'css/TextLayer.css'}, {from: 'assets/images/shadow.png', to: 'images/shadow.png'}, @@ -200,7 +201,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ alias: { 'react-native-config': 'react-web-config', 'react-native$': 'react-native-web', - + 'react-native-sound': 'react-native-web-sound', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias '@assets': path.resolve(__dirname, '../../assets'), @@ -239,6 +240,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'process/browser': require.resolve('process/browser'), }, }, + optimization: { runtimeChunk: 'single', splitChunks: { diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 5d3bf1c07985..9b1451b2bf94 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 059DC4EFD39EF39437E6823D /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A997AA8204EA3D90907FA80 /* libPods-NotificationServiceExtension.a */; }; + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E72B5AB22900C603C0 /* attention.mp3 */; }; + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E82B5AB22900C603C0 /* done.mp3 */; }; + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E92B5AB22900C603C0 /* receive.mp3 */; }; + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353EA2B5AB22900C603C0 /* success.mp3 */; }; 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */; }; 0CDA8E34287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; 0CDA8E35287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; @@ -79,6 +83,10 @@ 00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NewExpensifyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 076FD9E41E08971BBF51D580 /* libPods-NewExpensify-NewExpensifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify-NewExpensifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 083353E72B5AB22900C603C0 /* attention.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = attention.mp3; path = ../assets/sounds/attention.mp3; sourceTree = ""; }; + 083353E82B5AB22900C603C0 /* done.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = done.mp3; path = ../assets/sounds/done.mp3; sourceTree = ""; }; + 083353E92B5AB22900C603C0 /* receive.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = receive.mp3; path = ../assets/sounds/receive.mp3; sourceTree = ""; }; + 083353EA2B5AB22900C603C0 /* success.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = success.mp3; path = ../assets/sounds/success.mp3; sourceTree = ""; }; 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = NewExpensify/AppDelegate.mm; sourceTree = ""; }; 0CDA8E36287DD6A0004ECBEC /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = NewExpensify/Images.xcassets; sourceTree = ""; }; 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -311,6 +319,10 @@ A9EA265D209D4558995C9BD4 /* Resources */ = { isa = PBXGroup; children = ( + 083353E72B5AB22900C603C0 /* attention.mp3 */, + 083353E82B5AB22900C603C0 /* done.mp3 */, + 083353E92B5AB22900C603C0 /* receive.mp3 */, + 083353EA2B5AB22900C603C0 /* success.mp3 */, 44BF435285B94E5B95F90994 /* ExpensifyNewKansas-Medium.otf */, D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */, DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */, @@ -496,13 +508,17 @@ 0F5BE0CE252686330097D869 /* GoogleService-Info.plist in Resources */, E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */, F0C450EA2705020500FD2970 /* colors.json in Resources */, + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */, 0CDA8E37287DD6A0004ECBEC /* Images.xcassets in Resources */, 70CF6E82262E297300711ADC /* BootSplash.storyboard in Resources */, FF941A8D48F849269AB85C9A /* ExpensifyNewKansas-Medium.otf in Resources */, BDB853621F354EBB84E619C2 /* ExpensifyNewKansas-MediumItalic.otf in Resources */, 26AF3C3540374A9FACB6C19E /* ExpensifyMono-Bold.otf in Resources */, + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */, 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */, 2A9F8CDA983746B0B9204209 /* ExpensifyNeue-Bold.otf in Resources */, + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */, + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */, ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */, 30581EA8AAFD4FCE88C5D191 /* ExpensifyNeue-Italic.otf in Resources */, 1246A3EF20E54E7A9494C8B9 /* ExpensifyNeue-Regular.otf in Resources */, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c05bf4175d8..7212f0bd872e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1438,6 +1438,11 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - RNSound (0.11.2): + - React-Core + - RNSound/Core (= 0.11.2) + - RNSound/Core (0.11.2): + - React-Core - RNSVG (14.0.0): - React-Core - SDWebImage (5.17.0): @@ -1580,6 +1585,7 @@ DEPENDENCIES: - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSound (from `../node_modules/react-native-sound`) - RNSVG (from `../node_modules/react-native-svg`) - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1827,6 +1833,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSound: + :path: "../node_modules/react-native-sound" RNSVG: :path: "../node_modules/react-native-svg" VisionCamera: @@ -1973,6 +1981,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0 RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 + RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 RNSVG: 255767813dac22db1ec2062c8b7e7b856d4e5ae6 SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 SDWebImageAVIFCoder: 8348fef6d0ec69e129c66c9fe4d74fbfbf366112 diff --git a/jest/setup.ts b/jest/setup.ts index 68d904fac5be..55774ff136f1 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -38,3 +38,11 @@ jest.mock('react-native-fs', () => ({ unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); + +jest.mock('react-native-sound', () => { + class SoundMock { + play = jest.fn(); + } + + return SoundMock; +}); diff --git a/package-lock.json b/package-lock.json index f3a35a2b5d87..eab50aa5df98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,6 +109,7 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", + "react-native-sound": "^0.11.2", "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", @@ -116,6 +117,7 @@ "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", @@ -33754,6 +33756,11 @@ "node": ">=10" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -45343,6 +45350,14 @@ "react-native": "*" } }, + "node_modules/react-native-sound": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz", + "integrity": "sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==", + "peerDependencies": { + "react-native": ">=0.8.0" + } + }, "node_modules/react-native-svg": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.1.0.tgz", @@ -45428,6 +45443,17 @@ "react-native-web": "*" } }, + "node_modules/react-native-web-sound": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/react-native-web-sound/-/react-native-web-sound-0.1.3.tgz", + "integrity": "sha512-aUWNZuRFM7aREePiDgsTaBaaKX+aHKF0cDXtfOwFn4fQXkN+w5Ny3HykFrbDMxZagXSav5QjPntcA51lpWnSgg==", + "dependencies": { + "howler": "^2.2.1" + }, + "peerDependencies": { + "react-native-web": "*" + } + }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", diff --git a/package.json b/package.json index 72526d98bbdb..ad26ff297966 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", + "react-native-sound": "^0.11.2", "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", @@ -164,6 +165,7 @@ "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", diff --git a/patches/react-native-sound+0.11.2.patch b/patches/react-native-sound+0.11.2.patch new file mode 100644 index 000000000000..661e39263c43 --- /dev/null +++ b/patches/react-native-sound+0.11.2.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/react-native-sound/RNSound/RNSound.h b/node_modules/react-native-sound/RNSound/RNSound.h +index 7f5b97b..1a3c840 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.h ++++ b/node_modules/react-native-sound/RNSound/RNSound.h +@@ -1,17 +1,7 @@ +-#if __has_include() + #import +-#else +-#import "RCTBridgeModule.h" +-#endif +- + #import +- +-#if __has_include() + #import +-#else +-#import "RCTEventEmitter.h" +-#endif + + @interface RNSound : RCTEventEmitter +-@property (nonatomic, weak) NSNumber *_key; ++@property(nonatomic, weak) NSNumber *_key; + @end +diff --git a/node_modules/react-native-sound/RNSound/RNSound.m b/node_modules/react-native-sound/RNSound/RNSound.m +index df3784e..d34ac01 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.m ++++ b/node_modules/react-native-sound/RNSound/RNSound.m +@@ -1,10 +1,6 @@ + #import "RNSound.h" + +-#if __has_include("RCTUtils.h") +-#import "RCTUtils.h" +-#else + #import +-#endif + + @implementation RNSound { + NSMutableDictionary *_playerPool; diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 537769d1ebf6..418ba97a70b9 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -21,6 +21,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -549,6 +550,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ return; } + playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm(selectedParticipants); } diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 80bedc84f069..50bfcd4cc8be 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; import * as PaymentMethods from '@userActions/PaymentMethods'; @@ -201,6 +202,7 @@ function SettlementButton({ return; } + playSound(SOUNDS.DONE); onPress(iouPaymentType); }; diff --git a/src/languages/en.ts b/src/languages/en.ts index f24b0e3e2438..6fe1ca77e627 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1053,6 +1053,7 @@ export default { }, preferencesPage: { receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + muteAllSounds: 'Mute all sounds from Expensify', }, priorityModePage: { priorityMode: 'Priority mode', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3238e7fa94b0..660f851e9017 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1050,6 +1050,7 @@ export default { }, preferencesPage: { receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + muteAllSounds: 'Silenciar todos los sonidos de Expensify', }, priorityModePage: { priorityMode: 'Modo prioridad', diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 911e665f3ff4..f44b6802b540 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -42,7 +42,7 @@ function canUseBrowserNotifications(): Promise { * @param icon Path to icon * @param data extra data to attach to the notification */ -function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}) { +function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}, silent = false) { canUseBrowserNotifications().then((canUseNotifications) => { if (!canUseNotifications) { return; @@ -54,6 +54,7 @@ function push(title: string, body = '', icon: string | ImageSourcePropType = '', body, icon: String(icon), data, + silent, }); notificationCache[notificationID].onclick = () => { onClick(); @@ -104,7 +105,7 @@ export default { reportID: report.reportID, }; - push(title, body, icon, data, onClick); + push(title, body, icon, data, onClick, true); }, pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { diff --git a/src/libs/Sound/config/index.native.ts b/src/libs/Sound/config/index.native.ts new file mode 100644 index 000000000000..e948c1e189e8 --- /dev/null +++ b/src/libs/Sound/config/index.native.ts @@ -0,0 +1,3 @@ +const config = {prefix: ''}; + +export default config; diff --git a/src/libs/Sound/config/index.ts b/src/libs/Sound/config/index.ts new file mode 100644 index 000000000000..f58755750d9d --- /dev/null +++ b/src/libs/Sound/config/index.ts @@ -0,0 +1,3 @@ +const config = {prefix: '/sounds/'}; + +export default config; diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts new file mode 100644 index 000000000000..4639887e831c --- /dev/null +++ b/src/libs/Sound/index.ts @@ -0,0 +1,71 @@ +import Onyx from 'react-native-onyx'; +import Sound from 'react-native-sound'; +import type {ValueOf} from 'type-fest'; +import ONYXKEYS from '@src/ONYXKEYS'; +import config from './config'; + +let isMuted = false; + +Onyx.connect({ + key: ONYXKEYS.USER, + callback: (val) => (isMuted = !!val?.isMutedAllSounds), +}); + +const SOUNDS = { + DONE: 'done', + SUCCESS: 'success', + ATTENTION: 'attention', + RECEIVE: 'receive', +} as const; + +/** + * Creates a version of the given function that, when called, queues the execution and ensures that + * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows + * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls. + * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling. + */ +function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) { + const queue: Array<[() => ReturnType, (value?: unknown) => void]> = []; + let timerId: NodeJS.Timeout | null = null; + + function processQueue() { + if (queue.length > 0) { + const next = queue.shift(); + + if (!next) { + return; + } + + const [nextFunc, resolve] = next; + nextFunc(); + resolve(); + timerId = setTimeout(processQueue, minExecutionTime); + } else { + timerId = null; + } + } + + return function (...args: Parameters) { + return new Promise((resolve) => { + queue.push([() => func(...args), resolve]); + + if (!timerId) { + // If the timer isn't running, start processing the queue + processQueue(); + } + }); + }; +} + +const playSound = (soundFile: ValueOf) => { + const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { + if (error || isMuted) { + return; + } + + sound.play(); + }); +}; + +export {SOUNDS}; +export default withMinimalExecutionTime(playSound, 300); diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 901e3698376b..28cecf460a5f 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -13,6 +13,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -309,6 +310,7 @@ function completeTask(taskReport: OnyxEntry) { completedTaskReportActionID: completedTaskReportAction.reportActionID, }; + playSound(SOUNDS.SUCCESS); API.write(WRITE_COMMANDS.COMPLETE_TASK, parameters, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 0a3159298c74..75c61d7fab5f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -28,6 +28,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -37,6 +38,7 @@ import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OriginalMessage} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as Link from './Link'; @@ -469,6 +471,101 @@ function triggerNotifications(onyxUpdates: OnyxServerUpdate[]) { }); } +const isChannelMuted = (reportId: string) => + new Promise((resolve) => { + const connectionId = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, + callback: (report) => { + Onyx.disconnect(connectionId); + + resolve( + !report?.notificationPreference || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + }, + }); + }); + +function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { + const reportActionsOnly = pushJSON.filter((update) => update.key.includes('reportActions_')); + // "reportActions_5134363522480668" -> "5134363522480668" + const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]); + + Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID))) + .then((muted) => muted.every((isMuted) => isMuted)) + .then((isSoundMuted) => { + if (isSoundMuted) { + return; + } + + try { + const flatten = reportActionsOnly.flatMap((update) => { + const value = update.value as OnyxCollection; + + if (!value) { + return []; + } + + return Object.values(value); + }) as ReportAction[]; + + for (const data of flatten) { + // Someone completes a task + if (data.actionName === 'TASKCOMPLETED') { + return playSound(SOUNDS.SUCCESS); + } + } + + const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; + + for (const message of types) { + // someone sent money + if ('IOUDetails' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // mention user + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // mention @here + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // assign a task + if ('taskReportID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // request money + if ('IOUTransactionID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // Someone completes a money request + if ('IOUReportID' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // plain message + if ('html' in message) { + return playSound(SOUNDS.RECEIVE); + } + } + } catch (e) { + let errorMessage = String(e); + if (e instanceof Error) { + errorMessage = e.message; + } + + Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + } + }); +} + /** * Handles the newest events from Pusher where a single mega multipleEvents contains * an array of singular events all in one event @@ -514,8 +611,10 @@ function subscribeToUserEvents() { }); // Handles Onyx updates coming from Pusher through the mega multipleEvents. - PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => - SequentialQueue.getCurrentRequest().then(() => { + PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => { + playSoundForMessageType(pushJSON); + + return SequentialQueue.getCurrentRequest().then(() => { // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher if (currentUserAccountID === -1) { return; @@ -528,8 +627,8 @@ function subscribeToUserEvents() { // Return a promise when Onyx is done updating so that the OnyxUpdatesManager can properly apply all // the onyx updates in order return onyxUpdatePromise; - }), - ); + }); + }); } /** @@ -613,6 +712,10 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } +function setMuteAllSounds(isMutedAllSounds: boolean) { + Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); +} + /** * Clear the data about a screen share request from Onyx. */ @@ -880,6 +983,7 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setShouldUseStagingServer, + setMuteAllSounds, clearUserErrorMessage, updateFrequentlyUsedEmojis, joinScreenShare, diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index cc07716209a2..8ec0bce9d1a7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -24,6 +24,7 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import ReportDropUI from '@pages/home/report/ReportDropUI'; @@ -255,6 +256,7 @@ function ReportActionCompose({ */ const addAttachment = useCallback( (file) => { + playSound(SOUNDS.DONE); const newComment = composerRef.current.prepareCommentAndResetComposer(); Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); @@ -287,6 +289,7 @@ function ReportActionCompose({ return; } + playSound(SOUNDS.DONE); onSubmit(newComment); }, [onSubmit], diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js index 6ea7e1eb5280..0fe643884f25 100755 --- a/src/pages/settings/Preferences/PreferencesPage.js +++ b/src/pages/settings/Preferences/PreferencesPage.js @@ -78,6 +78,18 @@ function PreferencesPage(props) { /> + + + {translate('preferencesPage.muteAllSounds')} + + + + +