diff --git a/.circleci/config.yml b/.circleci/config.yml index 232b792e72..e9a73594b9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -326,6 +326,16 @@ jobs: paths: - << parameters.build_config_name >>.apk root: native/android + - run: + command: mv app/build/outputs/bundle/release/app-release.aab << parameters.build_config_name >>.aab + name: Rename aab + working_directory: native/android + - store_artifacts: + path: native/android/<< parameters.build_config_name >>.aab + - persist_to_workspace: + paths: + - << parameters.build_config_name >>.aab + root: native/android - unless: condition: or: @@ -569,7 +579,7 @@ jobs: name: '[FL] Browserstack Upload Live' working_directory: native - run: - command: bundle exec fastlane android playstore_upload build_config_name:<< parameters.build_config_name >> apk_path:attached_workspace/<< parameters.build_config_name >>.apk production_delivery:"<< parameters.production_delivery >>" version_name:${NEW_VERSION_NAME} version_code:${NEW_VERSION_CODE} + command: bundle exec fastlane android playstore_upload build_config_name:<< parameters.build_config_name >> aab_path:attached_workspace/<< parameters.build_config_name >>.aab production_delivery:"<< parameters.production_delivery >>" version_name:${NEW_VERSION_NAME} version_code:${NEW_VERSION_CODE} name: '[FL] Play Store Upload' working_directory: native - notify diff --git a/.circleci/src/jobs/build_android.yml b/.circleci/src/jobs/build_android.yml index a1516164ea..c8e5213882 100644 --- a/.circleci/src/jobs/build_android.yml +++ b/.circleci/src/jobs/build_android.yml @@ -70,6 +70,16 @@ steps: root: native/android paths: - << parameters.build_config_name >>.apk + - run: + name: Rename aab + command: mv app/build/outputs/bundle/release/app-release.aab << parameters.build_config_name >>.aab + working_directory: native/android + - store_artifacts: + path: native/android/<< parameters.build_config_name >>.aab + - persist_to_workspace: + root: native/android + paths: + - << parameters.build_config_name >>.aab - unless: condition: or: diff --git a/.circleci/src/jobs/deliver_android.yml b/.circleci/src/jobs/deliver_android.yml index 35d7c7cd6a..0fdd494385 100644 --- a/.circleci/src/jobs/deliver_android.yml +++ b/.circleci/src/jobs/deliver_android.yml @@ -32,6 +32,6 @@ steps: working_directory: native - run: name: '[FL] Play Store Upload' - command: bundle exec fastlane android playstore_upload build_config_name:<< parameters.build_config_name >> apk_path:attached_workspace/<< parameters.build_config_name >>.apk production_delivery:"<< parameters.production_delivery >>" version_name:${NEW_VERSION_NAME} version_code:${NEW_VERSION_CODE} + command: bundle exec fastlane android playstore_upload build_config_name:<< parameters.build_config_name >> aab_path:attached_workspace/<< parameters.build_config_name >>.aab production_delivery:"<< parameters.production_delivery >>" version_name:${NEW_VERSION_NAME} version_code:${NEW_VERSION_CODE} working_directory: native - notify diff --git a/.eslintrc.js b/.eslintrc.js index 3718e06eed..b222ab6fe5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -113,6 +113,12 @@ module.exports = { allowNullableString: true, }, ], + '@typescript-eslint/array-type': [ + 'error', + { + default: 'array', + }, + ], '@typescript-eslint/switch-exhaustiveness-check': 'error', '@typescript-eslint/no-non-null-assertion': 'error', diff --git a/assets/icons/chat-bot.svg b/assets/icons/chat-bot.svg new file mode 100644 index 0000000000..d269472545 --- /dev/null +++ b/assets/icons/chat-bot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/chat-person.svg b/assets/icons/chat-person.svg new file mode 100644 index 0000000000..de09e960a7 --- /dev/null +++ b/assets/icons/chat-person.svg @@ -0,0 +1,4 @@ + + + + diff --git a/build-configs/BuildConfigType.ts b/build-configs/BuildConfigType.ts index 3bb5b9d357..b8303e244e 100644 --- a/build-configs/BuildConfigType.ts +++ b/build-configs/BuildConfigType.ts @@ -52,9 +52,9 @@ export type CommonBuildConfigType = { // Host name of the web app, used for sharing, deep linking and social media previews. hostName: string // Hostnames from which resources are automatically downloaded for offline usage. - allowedHostNames: Array + allowedHostNames: string[] // Linked hosts that can may look similar https://chromium.googlesource.com/chromium/src/+/master/docs/security/lookalikes/lookalike-domains.md#automated-warning-removal - allowedLookalikes: Array + allowedLookalikes: string[] // Regex defining which urls to intercept as they are internal ones. supportedIframeSources: string[] internalLinksHijackPattern: string diff --git a/build-configs/common/theme/colors.ts b/build-configs/common/theme/colors.ts index e04c60431e..392fe6003a 100644 --- a/build-configs/common/theme/colors.ts +++ b/build-configs/common/theme/colors.ts @@ -14,6 +14,7 @@ export type ColorsType = { positiveHighlight: string negativeHighlight: string invalidInput: string + warningColor: string linkColor: string themeContrast: string } @@ -31,5 +32,6 @@ export const commonLightColors = { positiveHighlight: '#188038', negativeHighlight: '#8b0000', invalidInput: '#B3261E', + warningColor: '#FFA726', linkColor: '#0b57d0', } diff --git a/e2e-tests/native/test/helpers/Selector.ts b/e2e-tests/native/test/helpers/Selector.ts index f2c4d636a8..79278eb070 100644 --- a/e2e-tests/native/test/helpers/Selector.ts +++ b/e2e-tests/native/test/helpers/Selector.ts @@ -1,5 +1,5 @@ export class Selector { - private queries: Array = new Array() + private queries: string[] = new Array() public ByText(text: string): Selector { if (driver.isAndroid) { diff --git a/e2e-tests/shared/constants.ts b/e2e-tests/shared/constants.ts index 9d50835dba..e4cad3e4a2 100644 --- a/e2e-tests/shared/constants.ts +++ b/e2e-tests/shared/constants.ts @@ -1,6 +1,6 @@ export const filter = 'wirschaffendas' export const contentSearch = 'language' -export const defaultCity = 'Testumgebung Ende-zu-Ende-Testing' +export const defaultCity = 'E2E-Testumgebung' export const augsburgCity = 'Stadt Augsburg' export const language = 'en' diff --git a/e2e-tests/web/wdio.conf.ts b/e2e-tests/web/wdio.conf.ts index 83fbd8874e..b6711e4bd3 100644 --- a/e2e-tests/web/wdio.conf.ts +++ b/e2e-tests/web/wdio.conf.ts @@ -1,7 +1,7 @@ import { browsers, ciCapabilities } from './capabilities.js' import waitForLocalhost from './waitForLocalhost.js' -const getCapabilities = (): Array => { +const getCapabilities = (): WebdriverIO.Capabilities[] => { if (process.env.CI) { return [ciCapabilities] } diff --git a/native/android/fastlane/Fastfile b/native/android/fastlane/Fastfile index f7a1f5c99f..108f47da94 100644 --- a/native/android/fastlane/Fastfile +++ b/native/android/fastlane/Fastfile @@ -68,7 +68,7 @@ lane :build do |options| end gradle( - task: "assembleRelease", + tasks: ["assembleRelease", "bundleRelease"], properties: { :BUILD_CONFIG_NAME => build_config_name, :VERSION_CODE => version_code, diff --git a/native/fastlane/Fastfile b/native/fastlane/Fastfile index d44554b317..d0a8505b6b 100644 --- a/native/fastlane/Fastfile +++ b/native/fastlane/Fastfile @@ -66,8 +66,8 @@ platform :android do # version_code: The version code of the app # version_name: The version name of the app # build_config_name: The name of the build config - # apk_path: The path of the apk to upload (relative to home dir) - # production_delivery: Whether the apk should be uploaded to the production track + # aab_path: The path of the aab to upload (relative to home dir) + # production_delivery: Whether the aab should be uploaded to the production track desc "Deliver the app to Play Store. Depending on the option `production_delivery` the update is released to the general public." lane :playstore_upload do |options| ensure_env_vars( @@ -77,10 +77,10 @@ platform :android do version_code = options[:version_code] version_name = options[:version_name] build_config_name = options[:build_config_name] - apk_path = options[:apk_path] + aab_path = options[:aab_path] production_delivery = options[:production_delivery] - if [version_name, version_code, build_config_name, apk_path, production_delivery].include?(nil) + if [version_name, version_code, build_config_name, aab_path, production_delivery].include?(nil) raise "'nil' passed as parameter! Aborting..." end @@ -102,7 +102,7 @@ platform :android do skip_upload_screenshots: skip_images, skip_upload_metadata: false, release_status: "completed", - apk: "#{ENV['HOME']}/#{apk_path}", + aab: "#{ENV['HOME']}/#{aab_path}", json_key_data: ENV["GOOGLE_SERVICE_ACCOUNT_JSON"] ) end @@ -153,6 +153,7 @@ platform :android do skip_upload_screenshots: true, skip_upload_metadata: true, skip_upload_apk: true, + skip_upload_aab: true, release_status: "completed", json_key_data: ENV["GOOGLE_SERVICE_ACCOUNT_JSON"] ) diff --git a/native/run b/native/run index 912740166f..a93ab41d51 100755 --- a/native/run +++ b/native/run @@ -21,7 +21,7 @@ program .option('--production', 'whether a production (release) build should be made') .action((buildConfigName, options) => { const { production } = options - const productionFlag = production ? '--mode=release' : '' + const buildFlag = production ? '--mode=release' : '--active-arch-only' const jsonBuildConfig = execSync( `yarn workspace --silent build-configs --silent manage to-json ${buildConfigName} android`, @@ -34,7 +34,7 @@ program .toString() .replaceAll('\n', ' ') execSync( - `yarn cross-env ${buildConfig} yarn react-native run-android --no-packager --appId ${applicationId} ${productionFlag}`, + `yarn cross-env ${buildConfig} yarn react-native run-android --no-packager --appId ${applicationId} ${buildFlag}`, { stdio: 'inherit' }, ) }) diff --git a/native/src/Navigator.tsx b/native/src/Navigator.tsx index b9afa30bc9..aeb056c4c6 100644 --- a/native/src/Navigator.tsx +++ b/native/src/Navigator.tsx @@ -88,7 +88,8 @@ const Stack = createStackNavigator() const Navigator = (): ReactElement | null => { const showSnackbar = useSnackbar() - const { settings, cityCode, changeCityCode, languageCode, updateSettings } = useAppContext() + const appContext = useAppContext() + const { settings, cityCode, changeCityCode, updateSettings } = appContext const navigation = useNavigation>() const [initialRoute, setInitialRoute] = useState(null) @@ -96,8 +97,8 @@ const Navigator = (): ReactElement | null => { const { data: cities, error: citiesError, refresh: refreshCities } = useLoadCities() useEffect(() => { - initialPushNotificationRequest(cityCode, languageCode).catch(reportError) - }, [cityCode, languageCode]) + initialPushNotificationRequest(appContext).catch(reportError) + }, [appContext]) useForegroundPushNotificationListener({ showSnackbar, navigate: navigation.navigate }) diff --git a/native/src/__mocks__/react-native-blob-util.ts b/native/src/__mocks__/react-native-blob-util.ts index 48fe48979f..5797b5e786 100644 --- a/native/src/__mocks__/react-native-blob-util.ts +++ b/native/src/__mocks__/react-native-blob-util.ts @@ -27,7 +27,7 @@ const existsMock = (file: string): Promise => { return Promise.resolve(exists || isParentOfExisting) } -const lsMock = (path: string): Promise> => { +const lsMock = (path: string): Promise => { const filesInPath = Object.keys(mockFiles).filter(filePath => filePath.startsWith(path)) return Promise.resolve(filesInPath) } @@ -58,7 +58,7 @@ export default { }, }, fs: { - ls: jest.fn>, [string]>(lsMock), + ls: jest.fn, [string]>(lsMock), exists: jest.fn, [string]>(existsMock), isDir: jest.fn, [string]>(async () => true), writeFile: jest.fn, [string, string, string]>(writeMockFile), diff --git a/native/src/components/CitySelector.tsx b/native/src/components/CitySelector.tsx index c49259e8da..9f70fd07b0 100644 --- a/native/src/components/CitySelector.tsx +++ b/native/src/components/CitySelector.tsx @@ -33,7 +33,7 @@ const SearchCounter = styled.Text` ` type CitySelectorProps = { - cities: Array + cities: CityModel[] navigateToDashboard: (city: CityModel) => void } diff --git a/native/src/components/CustomHeaderButtons.tsx b/native/src/components/CustomHeaderButtons.tsx index 8367de5e9d..31dae0478f 100644 --- a/native/src/components/CustomHeaderButtons.tsx +++ b/native/src/components/CustomHeaderButtons.tsx @@ -40,8 +40,8 @@ const onOverflowMenuPress = (cancelButtonLabel: string) => (props: OnOverflowMen const CustomHeaderButtons = (props: { cancelLabel: string - items: Array - overflowItems: Array + items: ReactNode[] + overflowItems: ReactNode[] }): ReactElement => { const { cancelLabel, items, overflowItems } = props const { t } = useTranslation('common') diff --git a/native/src/components/ExportEventButton.tsx b/native/src/components/ExportEventButton.tsx index f09e16abbf..35ea57a4d7 100644 --- a/native/src/components/ExportEventButton.tsx +++ b/native/src/components/ExportEventButton.tsx @@ -1,9 +1,9 @@ import { DateTime } from 'luxon' import React, { ReactElement, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Linking, Platform } from 'react-native' +import { Platform } from 'react-native' import RNCalendarEvents, { Calendar, CalendarEventWritable, RecurrenceFrequency } from 'react-native-calendar-events' -import { PERMISSIONS, requestMultiple } from 'react-native-permissions' +import { PERMISSIONS, openSettings, requestMultiple } from 'react-native-permissions' import { Frequency } from 'rrule' import styled from 'styled-components/native' @@ -89,8 +89,8 @@ const ExportEventButton = ({ event }: ExportEventButtonType): ReactElement => { showSnackbar({ text: 'noCalendarPermission', positiveAction: { - label: t('settings'), - onPress: Linking.openSettings, + label: t('layout:settings'), + onPress: openSettings, }, }) return diff --git a/native/src/components/List.tsx b/native/src/components/List.tsx index 118d0f016b..5e3db382ca 100644 --- a/native/src/components/List.tsx +++ b/native/src/components/List.tsx @@ -10,7 +10,7 @@ const NoItemsMessage = styled.Text` ` type ListProps = { - items: Array + items: T[] noItemsMessage?: ReactElement | string renderItem: (props: { item: T; index: number }) => ReactElement Header?: ReactElement diff --git a/native/src/components/NearbyCities.tsx b/native/src/components/NearbyCities.tsx index a953b61461..d4a06bd02d 100644 --- a/native/src/components/NearbyCities.tsx +++ b/native/src/components/NearbyCities.tsx @@ -31,7 +31,7 @@ const StyledIcon = styled(Icon)` ` type NearbyCitiesProps = { - cities: Array + cities: CityModel[] navigateToDashboard: (city: CityModel) => void filterText: string } diff --git a/native/src/components/News.tsx b/native/src/components/News.tsx index 98b7f55885..a33d6edea3 100644 --- a/native/src/components/News.tsx +++ b/native/src/components/News.tsx @@ -37,7 +37,7 @@ const getPageTitle = ( return t('localNews.pageTitle') } -type NewsModelsType = Array +type NewsModelsType = (LocalNewsModel | TunewsModel)[] type NewsProps = { news: NewsModelsType diff --git a/native/src/components/Note.tsx b/native/src/components/Note.tsx index a930bcdc9a..efc96a39c2 100644 --- a/native/src/components/Note.tsx +++ b/native/src/components/Note.tsx @@ -5,7 +5,7 @@ import { NoteIcon } from '../assets' import Icon from './base/Icon' const NoteBox = styled.View` - background-color: ${props => props.theme.colors.themeColor}; + background-color: ${props => props.theme.colors.warningColor}; margin-top: 12px; padding: 12px; flex-direction: row; diff --git a/native/src/components/Selector.tsx b/native/src/components/Selector.tsx index 8185e4f1dc..10253c02b0 100644 --- a/native/src/components/Selector.tsx +++ b/native/src/components/Selector.tsx @@ -15,7 +15,7 @@ export const Wrapper = styled.View` ` type SelectorProps = { - items: Array + items: SelectorItemModel[] selectedItemCode: string | null } diff --git a/native/src/components/SettingItem.tsx b/native/src/components/SettingItem.tsx index f73dac6eba..fb47868e76 100644 --- a/native/src/components/SettingItem.tsx +++ b/native/src/components/SettingItem.tsx @@ -45,7 +45,7 @@ const Badge = styled.View<{ enabled: boolean }>` type SettingItemProps = { title: string description?: string - onPress: () => void + onPress: () => Promise bigTitle?: boolean role?: Role hasSwitch?: boolean diff --git a/native/src/components/__tests__/News.spec.tsx b/native/src/components/__tests__/News.spec.tsx index 78ca6ee904..461dc16584 100644 --- a/native/src/components/__tests__/News.spec.tsx +++ b/native/src/components/__tests__/News.spec.tsx @@ -68,7 +68,7 @@ describe('News', () => { localNewsEnabled = true, }: { newsId?: number | null - data?: Array + data?: (LocalNewsModel | TunewsModel)[] loadingMore?: boolean selectedNewsType?: TuNewsType | LocalNewsType tuNewsEnabled?: boolean diff --git a/native/src/constants/NavigationTypes.ts b/native/src/constants/NavigationTypes.ts index 0e888387c6..1f452a1c05 100644 --- a/native/src/constants/NavigationTypes.ts +++ b/native/src/constants/NavigationTypes.ts @@ -100,8 +100,8 @@ export type RoutesParamsType = { } [LICENSES_ROUTE]: undefined [CHANGE_LANGUAGE_MODAL_ROUTE]: { - languages: Array - availableLanguages: Array + languages: LanguageModel[] + availableLanguages: string[] } [PDF_VIEW_MODAL_ROUTE]: { url: string diff --git a/native/src/contexts/AppContextProvider.tsx b/native/src/contexts/AppContextProvider.tsx index 14f72b140e..47cdc7ebde 100644 --- a/native/src/contexts/AppContextProvider.tsx +++ b/native/src/contexts/AppContextProvider.tsx @@ -34,6 +34,7 @@ type AppContextProviderProps = { const AppContextProvider = ({ children }: AppContextProviderProps): ReactElement | null => { const [settings, setSettings] = useState(null) + const allowPushNotifications = !!settings?.allowPushNotifications const cityCode = settings?.selectedCity const languageCode = settings?.contentLanguage const { i18n } = useTranslation() @@ -55,10 +56,10 @@ const AppContextProvider = ({ children }: AppContextProviderProps): ReactElement unsubscribeNews(cityCode, languageCode).catch(reportError) } if (languageCode && newCityCode) { - subscribeNews(newCityCode, languageCode).catch(reportError) + subscribeNews({ cityCode: newCityCode, languageCode, allowPushNotifications }).catch(reportError) } }, - [updateSettings, cityCode, languageCode], + [updateSettings, cityCode, languageCode, allowPushNotifications], ) const changeLanguageCode = useCallback( @@ -68,10 +69,10 @@ const AppContextProvider = ({ children }: AppContextProviderProps): ReactElement unsubscribeNews(cityCode, languageCode).catch(reportError) } if (cityCode) { - subscribeNews(cityCode, newLanguageCode).catch(reportError) + subscribeNews({ cityCode, languageCode: newLanguageCode, allowPushNotifications }).catch(reportError) } }, - [updateSettings, cityCode, languageCode], + [updateSettings, cityCode, languageCode, allowPushNotifications], ) useEffect(() => { diff --git a/native/src/contexts/__tests__/AppContextProvider.spec.tsx b/native/src/contexts/__tests__/AppContextProvider.spec.tsx index 04e0be5ab3..4429802558 100644 --- a/native/src/contexts/__tests__/AppContextProvider.spec.tsx +++ b/native/src/contexts/__tests__/AppContextProvider.spec.tsx @@ -135,7 +135,7 @@ describe('AppContextProvider', () => { expect(await appSettings.loadSettings()).toMatchObject({ selectedCity: 'hallo' }) expect(setSettings).toHaveBeenCalledTimes(1) expect(subscribeNews).toHaveBeenCalledTimes(1) - expect(subscribeNews).toHaveBeenCalledWith('hallo', 'de') + expect(subscribeNews).toHaveBeenCalledWith({ cityCode: 'hallo', languageCode: 'de', allowPushNotifications: true }) }) it('should select city', async () => { @@ -150,7 +150,11 @@ describe('AppContextProvider', () => { expect(await appSettings.loadSettings()).toMatchObject({ selectedCity: 'augsburg' }) expect(setSettings).toHaveBeenCalledTimes(1) expect(subscribeNews).toHaveBeenCalledTimes(1) - expect(subscribeNews).toHaveBeenCalledWith('augsburg', 'de') + expect(subscribeNews).toHaveBeenCalledWith({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: true, + }) expect(unsubscribeNews).not.toHaveBeenCalled() }) @@ -166,7 +170,11 @@ describe('AppContextProvider', () => { expect(await appSettings.loadSettings()).toMatchObject({ selectedCity: 'augsburg' }) expect(setSettings).toHaveBeenCalledTimes(1) expect(subscribeNews).toHaveBeenCalledTimes(1) - expect(subscribeNews).toHaveBeenCalledWith('augsburg', 'de') + expect(subscribeNews).toHaveBeenCalledWith({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: true, + }) expect(unsubscribeNews).toHaveBeenCalledTimes(1) expect(unsubscribeNews).toHaveBeenCalledWith('muenchen', 'de') }) @@ -198,7 +206,11 @@ describe('AppContextProvider', () => { expect(await appSettings.loadSettings()).toMatchObject({ contentLanguage: 'ar' }) expect(setSettings).toHaveBeenCalledTimes(1) expect(subscribeNews).toHaveBeenCalledTimes(1) - expect(subscribeNews).toHaveBeenCalledWith('muenchen', 'ar') + expect(subscribeNews).toHaveBeenCalledWith({ + cityCode: 'muenchen', + languageCode: 'ar', + allowPushNotifications: true, + }) expect(unsubscribeNews).toHaveBeenCalledTimes(1) expect(unsubscribeNews).toHaveBeenCalledWith('muenchen', 'de') }) diff --git a/native/src/hooks/useCityAppContext.ts b/native/src/hooks/useCityAppContext.ts index c6d9fe1763..07342b9206 100644 --- a/native/src/hooks/useCityAppContext.ts +++ b/native/src/hooks/useCityAppContext.ts @@ -2,13 +2,13 @@ import { useContext } from 'react' import { AppContext, AppContextType } from '../contexts/AppContextProvider' -type UseCityAppContextReturn = AppContextType & { +export type CityAppContext = AppContextType & { cityCode: string } export const useAppContext = (): AppContextType => useContext(AppContext) -const useCityAppContext = (): UseCityAppContextReturn => { +const useCityAppContext = (): CityAppContext => { const { cityCode, ...context } = useAppContext() if (!cityCode) { throw new Error('City code not set!') diff --git a/native/src/navigation/navigateToLanguageChange.ts b/native/src/navigation/navigateToLanguageChange.ts index df276c860c..7bd627487b 100644 --- a/native/src/navigation/navigateToLanguageChange.ts +++ b/native/src/navigation/navigateToLanguageChange.ts @@ -10,8 +10,8 @@ const navigateToLanguageChange = ({ availableLanguages, }: { navigation: NavigationProps - languages: Array - availableLanguages: Array + languages: LanguageModel[] + availableLanguages: string[] }): void => { sendTrackingSignal({ signal: { diff --git a/native/src/routes/Events.tsx b/native/src/routes/Events.tsx index 88aab065d0..26fa75fffa 100644 --- a/native/src/routes/Events.tsx +++ b/native/src/routes/Events.tsx @@ -32,7 +32,7 @@ const PageDetailsContainer = styled.View` export type EventsProps = { slug?: string - events: Array + events: EventModel[] cityModel: CityModel language: string navigateTo: (routeInformation: RouteInformationType) => void diff --git a/native/src/routes/Intro.tsx b/native/src/routes/Intro.tsx index 4dc44f1cee..4bd30d19e4 100644 --- a/native/src/routes/Intro.tsx +++ b/native/src/routes/Intro.tsx @@ -124,7 +124,7 @@ const Intro = ({ route, navigation }: IntroProps): ReactElement => { const renderSlide = ({ item }: { item: SlideContentType }) => - const onViewableItemsChanged = useCallback(({ viewableItems }: { viewableItems: Array }) => { + const onViewableItemsChanged = useCallback(({ viewableItems }: { viewableItems: ViewToken[] }) => { const viewableItem = viewableItems[0] if (viewableItem) { if (viewableItem.index !== null) { diff --git a/native/src/routes/Pois.tsx b/native/src/routes/Pois.tsx index e9843af558..8ef2ff83a2 100644 --- a/native/src/routes/Pois.tsx +++ b/native/src/routes/Pois.tsx @@ -46,7 +46,7 @@ const getBottomSheetSnapPoints = (deviceHeight: number): [number, number, number ] type PoisProps = { - pois: Array + pois: PoiModel[] cityModel: CityModel language: string refresh: () => void diff --git a/native/src/routes/SearchModal.tsx b/native/src/routes/SearchModal.tsx index f1b3103427..9e7da908ea 100644 --- a/native/src/routes/SearchModal.tsx +++ b/native/src/routes/SearchModal.tsx @@ -28,7 +28,7 @@ const SearchCounter = styled.Text` ` export type SearchModalProps = { - allPossibleResults: Array + allPossibleResults: SearchResult[] languageCode: string cityCode: string closeModal: (query: string) => void diff --git a/native/src/routes/Settings.tsx b/native/src/routes/Settings.tsx index 3a806e5296..37595d1c9d 100644 --- a/native/src/routes/Settings.tsx +++ b/native/src/routes/Settings.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useContext } from 'react' +import React, { ReactElement } from 'react' import { useTranslation } from 'react-i18next' import { SectionList, SectionListData } from 'react-native' import styled from 'styled-components/native' @@ -10,10 +10,8 @@ import Layout from '../components/Layout' import SettingItem from '../components/SettingItem' import ItemSeparator from '../components/base/ItemSeparator' import { NavigationProps } from '../constants/NavigationTypes' -import { AppContext } from '../contexts/AppContextProvider' -import { useAppContext } from '../hooks/useCityAppContext' +import useCityAppContext from '../hooks/useCityAppContext' import useSnackbar from '../hooks/useSnackbar' -import { SettingsType } from '../utils/AppSettings' import createSettingsSections, { SettingsSectionType } from '../utils/createSettingsSections' import { log, reportError } from '../utils/sentry' @@ -31,36 +29,27 @@ const SectionHeader = styled.Text` ` const Settings = ({ navigation }: SettingsProps): ReactElement => { - const { settings, updateSettings } = useAppContext() - const { cityCode, languageCode } = useContext(AppContext) + const appContext = useCityAppContext() const showSnackbar = useSnackbar() const { t } = useTranslation('settings') + const { settings } = appContext - const setSetting = async ( - changeSetting: (settings: SettingsType) => Partial, - changeAction?: (settings: SettingsType) => Promise, - ) => { + const safeOnPress = (update: () => Promise | void) => async () => { const oldSettings = settings - const newSettings = { ...oldSettings, ...changeSetting(settings) } - updateSettings(newSettings) - try { - const successful = changeAction ? await changeAction(newSettings) : true - - if (!successful) { - updateSettings(oldSettings) - } + await update() } catch (e) { log('Failed to persist settings.', 'error') reportError(e) - updateSettings(oldSettings) + appContext.updateSettings(oldSettings) + showSnackbar({ text: t('error:settingsError') }) } } const renderItem = ({ item }: { item: SettingsSectionType }) => { - const { getSettingValue, ...otherProps } = item + const { getSettingValue, onPress, ...otherProps } = item const value = !!(getSettingValue && getSettingValue(settings)) - return + return } const renderSectionHeader = ({ section: { title } }: { section: SectionType }) => { @@ -72,13 +61,10 @@ const Settings = ({ navigation }: SettingsProps): ReactElement => { } const sections = createSettingsSections({ - setSetting, - t, - languageCode, - cityCode, + appContext, navigation, - settings, showSnackbar, + t, }) return ( diff --git a/native/src/testing/TestingAppContext.tsx b/native/src/testing/TestingAppContext.tsx index 6a991ee9d7..fc1961a187 100644 --- a/native/src/testing/TestingAppContext.tsx +++ b/native/src/testing/TestingAppContext.tsx @@ -3,27 +3,30 @@ import React, { ReactElement, ReactNode } from 'react' import { AppContext, AppContextType } from '../contexts/AppContextProvider' import { defaultSettings, SettingsType } from '../utils/AppSettings' -const TestingAppContext = ({ - children, +type TestingAppContextParams = { settings?: Partial } & Omit, 'settings'> + +export const testingAppContext = ({ settings = {}, cityCode = 'augsburg', languageCode = 'de', changeCityCode = jest.fn(), changeLanguageCode = jest.fn(), updateSettings = jest.fn(), -}: { - settings?: Partial - children: ReactNode -} & Omit, 'settings'>): ReactElement => { - const context = { - settings: { ...defaultSettings, ...settings }, - cityCode, - languageCode, - updateSettings, - changeCityCode, - changeLanguageCode, - } +}: TestingAppContextParams): AppContextType => ({ + settings: { ...defaultSettings, ...settings }, + cityCode, + languageCode, + updateSettings, + changeCityCode, + changeLanguageCode, +}) + +const TestingAppContextProvider = ({ + children, + ...props +}: { children: ReactNode } & TestingAppContextParams): ReactElement => { + const context = testingAppContext(props) return {children} } -export default TestingAppContext +export default TestingAppContextProvider diff --git a/native/src/utils/AppSettings.ts b/native/src/utils/AppSettings.ts index 4f1b776d12..8f650dfae8 100644 --- a/native/src/utils/AppSettings.ts +++ b/native/src/utils/AppSettings.ts @@ -14,7 +14,7 @@ export type SettingsType = { apiUrlOverride: string | null jpalTrackingEnabled: boolean | null jpalTrackingCode: string | null - jpalSignals: Array + jpalSignals: SignalType[] externalSourcePermissions: ExternalSourcePermissions } export const defaultSettings: SettingsType = { diff --git a/native/src/utils/DataContainer.ts b/native/src/utils/DataContainer.ts index 0f361347fe..b319476b34 100644 --- a/native/src/utils/DataContainer.ts +++ b/native/src/utils/DataContainer.ts @@ -15,24 +15,24 @@ export type DataContainer = { * Returns an Array of PoiModels. * @throws Will throw an error if the array is null. */ - getPois: (city: string, language: string) => Promise> + getPois: (city: string, language: string) => Promise /** * Sets the pois and persist them ? */ - setPois: (city: string, language: string, pois: Array) => Promise + setPois: (city: string, language: string, pois: PoiModel[]) => Promise /** * Returns an Array of CityModels. * @throws Will throw an error if the array is null. */ - getCities: () => Promise> + getCities: () => Promise /** * Sets the cities but does not persist them. * For now switching cities when offline is not possible. */ - setCities: (cities: Array) => Promise + setCities: (cities: CityModel[]) => Promise /** * Returns the CategoriesMapModel. @@ -49,23 +49,23 @@ export type DataContainer = { * Returns an Array of events. * @throws Will throw an error if the array is null. */ - getEvents: (city: string, language: string) => Promise> + getEvents: (city: string, language: string) => Promise /** * Sets the events and persists them. */ - setEvents: (city: string, language: string, events: Array) => Promise + setEvents: (city: string, language: string, events: EventModel[]) => Promise /** * Returns an Array of local news. * @throws Will throw an error if the array is null. */ - getLocalNews: (city: string, language: string) => Promise> + getLocalNews: (city: string, language: string) => Promise /** * Sets the local news and persists them. */ - setLocalNews: (city: string, language: string, events: Array) => Promise + setLocalNews: (city: string, language: string, events: LocalNewsModel[]) => Promise /** * Returns the ResourceCache. diff --git a/native/src/utils/DatabaseConnector.ts b/native/src/utils/DatabaseConnector.ts index 561bdc8a7c..8b2ad9e728 100644 --- a/native/src/utils/DatabaseConnector.ts +++ b/native/src/utils/DatabaseConnector.ts @@ -54,7 +54,7 @@ type ContentCategoryJsonType = { thumbnail: string | null available_languages: Record parent_path: string - children: Array + children: string[] order: number organization: { name: string @@ -279,7 +279,7 @@ class DatabaseConnector { this._storeMetaCities(metaData) } - async _deleteMetaOfCities(cities: Array): Promise { + async _deleteMetaOfCities(cities: string[]): Promise { const metaCities = await this._loadMetaCities() cities.forEach(city => delete metaCities[city]) await this._storeMetaCities(metaCities) @@ -342,7 +342,7 @@ class DatabaseConnector { await this.writeFile(path, JSON.stringify(citiesMetaJson)) } - async loadLastUsages(): Promise> { + async loadLastUsages(): Promise { const metaData = await this._loadMetaCities() return map(metaData, (value, key) => ({ city: key, @@ -437,7 +437,7 @@ class DatabaseConnector { return this.readFile(path, mapCategoriesJson) } - async storePois(pois: Array, context: DatabaseContext): Promise { + async storePois(pois: PoiModel[], context: DatabaseContext): Promise { const jsonModels = pois.map( (poi: PoiModel): ContentPoiJsonType => ({ path: poi.path, @@ -484,7 +484,7 @@ class DatabaseConnector { await this.writeFile(this.getContentPath('pois', context), JSON.stringify(jsonModels)) } - async loadPois(context: DatabaseContext): Promise> { + async loadPois(context: DatabaseContext): Promise { const path = this.getContentPath('pois', context) const mapPoisJson = (json: ContentPoiJsonType[]) => json.map(jsonObject => { @@ -569,7 +569,7 @@ class DatabaseConnector { return this.readFile(path, mapLocalNewsJson) } - async storeCities(cities: Array): Promise { + async storeCities(cities: CityModel[]): Promise { const jsonModels = cities.map( (city: CityModel): ContentCityJsonType => ({ name: city.name, @@ -592,7 +592,7 @@ class DatabaseConnector { await this.writeFile(this.getCitiesPath(), JSON.stringify(jsonModels)) } - async loadCities(): Promise> { + async loadCities(): Promise { const path = this.getCitiesPath() const mapCityJson = (json: ContentCityJsonType[]) => json.map( @@ -619,7 +619,7 @@ class DatabaseConnector { return this.readFile(path, mapCityJson) } - async storeEvents(events: Array, context: DatabaseContext): Promise { + async storeEvents(events: EventModel[], context: DatabaseContext): Promise { const jsonModels = events.map( (event: EventModel): ContentEventJsonType => ({ path: event.path, @@ -663,7 +663,7 @@ class DatabaseConnector { await this.writeFile(this.getContentPath('events', context), JSON.stringify(jsonModels)) } - async loadEvents(context: DatabaseContext): Promise> { + async loadEvents(context: DatabaseContext): Promise { const path = this.getContentPath('events', context) const mapEventsJson = (json: ContentEventJsonType[]) => json.map(jsonObject => { diff --git a/native/src/utils/DefaultDataContainer.ts b/native/src/utils/DefaultDataContainer.ts index e0fe9cad6c..4151ceb7ea 100644 --- a/native/src/utils/DefaultDataContainer.ts +++ b/native/src/utils/DefaultDataContainer.ts @@ -16,11 +16,11 @@ import DatabaseConnector from './DatabaseConnector' import { log } from './sentry' type CacheType = { - pois: Cache> - cities: Cache> - events: Cache> + pois: Cache + cities: Cache + events: Cache categories: Cache - localNews: Cache> + localNews: Cache resourceCache: Cache lastUpdate: Cache } @@ -33,21 +33,21 @@ class DefaultDataContainer implements DataContainer { constructor() { this._databaseConnector = new DatabaseConnector() this.caches = { - pois: new Cache>( + pois: new Cache( this._databaseConnector, (connector: DatabaseConnector, context: DatabaseContext) => connector.loadPois(context), - (value: Array, connector: DatabaseConnector, context: DatabaseContext) => + (value: PoiModel[], connector: DatabaseConnector, context: DatabaseContext) => connector.storePois(value, context), ), - cities: new Cache>( + cities: new Cache( this._databaseConnector, (connector: DatabaseConnector) => connector.loadCities(), - (value: Array, connector: DatabaseConnector) => connector.storeCities(value), + (value: CityModel[], connector: DatabaseConnector) => connector.storeCities(value), ), - events: new Cache>( + events: new Cache( this._databaseConnector, (connector: DatabaseConnector, context: DatabaseContext) => connector.loadEvents(context), - (value: Array, connector: DatabaseConnector, context: DatabaseContext) => + (value: EventModel[], connector: DatabaseConnector, context: DatabaseContext) => connector.storeEvents(value, context), ), categories: new Cache( @@ -94,7 +94,7 @@ class DefaultDataContainer implements DataContainer { return this.caches[key].isCached(context) } - getCities = async (): Promise> => { + getCities = async (): Promise => { const cache = this.caches.cities return cache.get(new DatabaseContext()) } @@ -105,15 +105,15 @@ class DefaultDataContainer implements DataContainer { return cache.get(context) } - getEvents = (city: string, language: string): Promise> => { + getEvents = (city: string, language: string): Promise => { const context = new DatabaseContext(city, language) - const cache: Cache> = this.caches.events + const cache: Cache = this.caches.events return cache.get(context) } - getPois = (city: string, language: string): Promise> => { + getPois = (city: string, language: string): Promise => { const context = new DatabaseContext(city, language) - const cache: Cache> = this.caches.pois + const cache: Cache = this.caches.pois return cache.get(context) } @@ -143,9 +143,9 @@ class DefaultDataContainer implements DataContainer { await cache.cache(categories, context) } - setPois = async (city: string, language: string, pois: Array): Promise => { + setPois = async (city: string, language: string, pois: PoiModel[]): Promise => { const context = new DatabaseContext(city, language) - const cache: Cache> = this.caches.pois + const cache: Cache = this.caches.pois await cache.cache(pois, context) } @@ -155,22 +155,21 @@ class DefaultDataContainer implements DataContainer { await cache.cache(localNews, context) } - setCities = async (cities: Array): Promise => { + setCities = async (cities: CityModel[]): Promise => { const cache = this.caches.cities await cache.cache(cities, new DatabaseContext()) } - setEvents = async (city: string, language: string, events: Array): Promise => { + setEvents = async (city: string, language: string, events: EventModel[]): Promise => { const context = new DatabaseContext(city, language) - const cache: Cache> = this.caches.events + const cache: Cache = this.caches.events await cache.cache(events, context) } - getFilePathsFromLanguageResourceCache(languageResourceCache: LanguageResourceCacheStateType): Array { - const pageResourceCaches: Array = Object.values(languageResourceCache) - return flatMap( - pageResourceCaches, - (file: PageResourceCacheStateType): Array => map(file, ({ filePath }) => filePath), + getFilePathsFromLanguageResourceCache(languageResourceCache: LanguageResourceCacheStateType): string[] { + const pageResourceCaches: PageResourceCacheStateType[] = Object.values(languageResourceCache) + return flatMap(pageResourceCaches, (file: PageResourceCacheStateType): string[] => + map(file, ({ filePath }) => filePath), ) } diff --git a/native/src/utils/NativeLanguageDetector.ts b/native/src/utils/NativeLanguageDetector.ts index 3896304886..db5aab9b37 100644 --- a/native/src/utils/NativeLanguageDetector.ts +++ b/native/src/utils/NativeLanguageDetector.ts @@ -23,7 +23,7 @@ const NativeLanguageDetector: LanguageDetectorModule = { ) return acc }, - [] as Array, + [] as (string | undefined)[], ) // Return the first supported languageTag or our fallback return supportedKeys.find(it => it !== undefined) ?? config.defaultFallback diff --git a/native/src/utils/PushNotificationsManager.ts b/native/src/utils/PushNotificationsManager.ts index 24ca65232e..525e60a1d6 100644 --- a/native/src/utils/PushNotificationsManager.ts +++ b/native/src/utils/PushNotificationsManager.ts @@ -9,10 +9,12 @@ import { LOCAL_NEWS_TYPE, NEWS_ROUTE, NonNullableRouteInformationType } from 'sh import { SnackbarType } from '../components/SnackbarContainer' import { RoutesType } from '../constants/NavigationTypes' import buildConfig from '../constants/buildConfig' +import { AppContextType } from '../contexts/AppContextProvider' import urlFromRouteInformation from '../navigation/url' -import appSettings from './AppSettings' import { log, reportError } from './sentry' +type UpdateSettingsType = (settings: { allowPushNotifications: boolean }) => void + type Message = FirebaseMessagingTypes.RemoteMessage & { notification: { title: string } data: { @@ -30,7 +32,7 @@ const importFirebaseMessaging = async (): Promise<() => FirebaseMessagingTypes.M export const pushNotificationsEnabled = (): boolean => buildConfig().featureFlags.pushNotifications && !buildConfig().featureFlags.floss -export const requestPushNotificationPermission = async (): Promise => { +export const requestPushNotificationPermission = async (updateSettings: UpdateSettingsType): Promise => { if (!pushNotificationsEnabled()) { log('Push notifications disabled, no permissions requested.') return false @@ -41,7 +43,7 @@ export const requestPushNotificationPermission = async (): Promise => { if (permissionStatus !== RESULTS.GRANTED) { log(`Permission denied, disabling push notifications in settings.`) - await appSettings.setSettings({ allowPushNotifications: false }) + updateSettings({ allowPushNotifications: false }) } return permissionStatus === RESULTS.GRANTED @@ -65,15 +67,27 @@ export const unsubscribeNews = async (city: string, language: string): Promise => { + +type SubscribeNewsParams = { + cityCode: string + languageCode: string + allowPushNotifications: boolean + skipSettingsCheck?: boolean +} + +export const subscribeNews = async ({ + cityCode, + languageCode, + allowPushNotifications, + skipSettingsCheck = false, +}: SubscribeNewsParams): Promise => { try { - const { allowPushNotifications } = await appSettings.loadSettings() if (!pushNotificationsEnabled() || (!allowPushNotifications && !skipSettingsCheck)) { log('Push notifications disabled, subscription skipped.') return } - const topic = newsTopic(city, language) + const topic = newsTopic(cityCode, languageCode) const messaging = await importFirebaseMessaging() await messaging().subscribeToTopic(topic) @@ -154,7 +168,6 @@ export const backgroundAppStatePushNotificationListener = (listener: (url: strin importFirebaseMessaging() .then(messaging => { const onReceiveURL = ({ url }: { url: string }) => listener(url) - const onReceiveURLListener = Linking.addListener('url', onReceiveURL) const unsubscribeNotification = messaging().onNotificationOpenedApp(message => @@ -175,13 +188,15 @@ export const backgroundAppStatePushNotificationListener = (listener: (url: strin // Since Android 13 and iOS 17 an explicit permission request is needed, otherwise push notifications are not received. // Therefore request the permissions once if not yet granted and subscribe to the current channel if successful. // See https://github.com/digitalfabrik/integreat-app/issues/2438 and https://github.com/digitalfabrik/integreat-app/issues/2655 -export const initialPushNotificationRequest = async (cityCode: string | null, languageCode: string): Promise => { - const { allowPushNotifications } = await appSettings.loadSettings() +export const initialPushNotificationRequest = async (appContext: AppContextType): Promise => { + const { cityCode, languageCode, settings, updateSettings } = appContext + const { allowPushNotifications } = settings + const pushNotificationPermissionGranted = (await checkNotifications()).status === RESULTS.GRANTED if (!pushNotificationPermissionGranted && allowPushNotifications) { - const success = await requestPushNotificationPermission() + const success = await requestPushNotificationPermission(updateSettings) if (success && cityCode) { - await subscribeNews(cityCode, languageCode) + await subscribeNews({ cityCode, languageCode, allowPushNotifications }) } } } diff --git a/native/src/utils/ResourceURLFinder.ts b/native/src/utils/ResourceURLFinder.ts index 5beff7e5ac..f73f3b1a7b 100644 --- a/native/src/utils/ResourceURLFinder.ts +++ b/native/src/utils/ResourceURLFinder.ts @@ -24,9 +24,9 @@ type InputEntryType = { export default class ResourceURLFinder { _parser: Parser | null = null _foundUrls: Set = new Set() - _allowedHostNames: Array + _allowedHostNames: string[] - constructor(allowedHostNames: Array) { + constructor(allowedHostNames: string[]) { this._allowedHostNames = allowedHostNames } diff --git a/native/src/utils/__tests__/PushNotificationsManager.spec.ts b/native/src/utils/__tests__/PushNotificationsManager.spec.ts index f31691f04a..985219527a 100644 --- a/native/src/utils/__tests__/PushNotificationsManager.spec.ts +++ b/native/src/utils/__tests__/PushNotificationsManager.spec.ts @@ -1,23 +1,20 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging' import { mocked } from 'jest-mock' import { requestNotifications } from 'react-native-permissions' import buildConfig from '../../constants/buildConfig' -import appSettings from '../AppSettings' import * as PushNotificationsManager from '../PushNotificationsManager' jest.mock('@react-native-firebase/messaging', () => jest.fn(() => ({}))) describe('PushNotificationsManager', () => { - beforeEach(() => { - AsyncStorage.clear() - jest.clearAllMocks() - }) + beforeEach(jest.clearAllMocks) + const mockedFirebaseMessaging = mocked<() => FirebaseMessagingTypes.Module>(messaging) const mockedBuildConfig = mocked(buildConfig) const previousFirebaseMessaging = mockedFirebaseMessaging() const navigateToDeepLink = jest.fn() + const updateSettings = jest.fn() const mockBuildConfig = (pushNotifications: boolean, floss: boolean) => { const previous = buildConfig() @@ -57,7 +54,7 @@ describe('PushNotificationsManager', () => { return previous }) - expect(await PushNotificationsManager.requestPushNotificationPermission()).toBeFalsy() + expect(await PushNotificationsManager.requestPushNotificationPermission(updateSettings)).toBeFalsy() expect(mockRequestPermission).not.toHaveBeenCalled() }) @@ -70,7 +67,7 @@ describe('PushNotificationsManager', () => { return previous }) - expect(await PushNotificationsManager.requestPushNotificationPermission()).toBeFalsy() + expect(await PushNotificationsManager.requestPushNotificationPermission(updateSettings)).toBeFalsy() expect(mockRequestPermission).not.toHaveBeenCalled() }) @@ -78,16 +75,17 @@ describe('PushNotificationsManager', () => { mockBuildConfig(true, false) mocked(requestNotifications).mockImplementationOnce(async () => ({ status: 'blocked', settings: {} })) - expect(await PushNotificationsManager.requestPushNotificationPermission()).toBeFalsy() - expect((await appSettings.loadSettings()).allowPushNotifications).toBe(false) + expect(await PushNotificationsManager.requestPushNotificationPermission(updateSettings)).toBeFalsy() + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ allowPushNotifications: false }) }) it('should request permissions and return true if granted', async () => { mockBuildConfig(true, false) mocked(requestNotifications).mockImplementationOnce(async () => ({ status: 'granted', settings: {} })) - expect(await PushNotificationsManager.requestPushNotificationPermission()).toBeTruthy() - expect((await appSettings.loadSettings()).allowPushNotifications).toBe(true) + expect(await PushNotificationsManager.requestPushNotificationPermission(updateSettings)).toBeTruthy() + expect(updateSettings).not.toHaveBeenCalled() }) }) @@ -143,7 +141,11 @@ describe('PushNotificationsManager', () => { return previous }) - await PushNotificationsManager.subscribeNews('augsburg', 'de') + await PushNotificationsManager.subscribeNews({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: true, + }) expect(mockSubscribeToTopic).not.toHaveBeenCalled() }) @@ -156,7 +158,28 @@ describe('PushNotificationsManager', () => { return previous }) - await PushNotificationsManager.subscribeNews('augsburg', 'de') + await PushNotificationsManager.subscribeNews({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: true, + }) + expect(mockSubscribeToTopic).not.toHaveBeenCalled() + }) + + it('should return and do nothing if it is disabled in settings', async () => { + mockBuildConfig(true, false) + const mockSubscribeToTopic = jest.fn() + mockedFirebaseMessaging.mockImplementation(() => { + const previous = previousFirebaseMessaging + previous.subscribeToTopic = mockSubscribeToTopic + return previous + }) + + await PushNotificationsManager.subscribeNews({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: false, + }) expect(mockSubscribeToTopic).not.toHaveBeenCalled() }) @@ -169,7 +192,30 @@ describe('PushNotificationsManager', () => { return previous }) - await PushNotificationsManager.subscribeNews('augsburg', 'de') + await PushNotificationsManager.subscribeNews({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: true, + }) + expect(mockSubscribeToTopic).toHaveBeenCalledWith('augsburg-de-news') + expect(mockSubscribeToTopic).toHaveBeenCalledTimes(1) + }) + + it('should call subscribeToTopic even if push notifications are disabled but skipSettingsCheck is true', async () => { + mockBuildConfig(true, false) + const mockSubscribeToTopic = jest.fn() + mockedFirebaseMessaging.mockImplementation(() => { + const previous = previousFirebaseMessaging + previous.subscribeToTopic = mockSubscribeToTopic + return previous + }) + + await PushNotificationsManager.subscribeNews({ + cityCode: 'augsburg', + languageCode: 'de', + allowPushNotifications: false, + skipSettingsCheck: true, + }) expect(mockSubscribeToTopic).toHaveBeenCalledWith('augsburg-de-news') expect(mockSubscribeToTopic).toHaveBeenCalledTimes(1) }) diff --git a/native/src/utils/__tests__/createSettingsSections.spec.ts b/native/src/utils/__tests__/createSettingsSections.spec.ts index 7061ffda6c..bd6eb9f7db 100644 --- a/native/src/utils/__tests__/createSettingsSections.spec.ts +++ b/native/src/utils/__tests__/createSettingsSections.spec.ts @@ -1,11 +1,11 @@ import { TFunction } from 'i18next' import { mocked } from 'jest-mock' -import { openSettings } from 'react-native-permissions' import { SettingsRouteType } from 'shared' +import { testingAppContext } from '../../testing/TestingAppContext' import createNavigationScreenPropMock from '../../testing/createNavigationPropMock' -import { defaultSettings, SettingsType } from '../AppSettings' +import { SettingsType } from '../AppSettings' import { pushNotificationsEnabled, requestPushNotificationPermission, @@ -31,40 +31,23 @@ const mockUnsubscribeNews = mocked(unsubscribeNews) const mockSubscribeNews = mocked(subscribeNews) const mockedPushNotificationsEnabled = mocked(pushNotificationsEnabled) -type changeSettingFnType = (settings: SettingsType) => Partial -type changeActionFnType = void | ((newSettings: SettingsType) => Promise) - describe('createSettingsSections', () => { - let changeSetting: changeSettingFnType - let changeAction: void | ((newSettings: SettingsType) => Promise) - - beforeEach(() => { - jest.clearAllMocks() - changeSetting = settings => settings - changeAction = async () => true - }) - - const setSetting = async (newChangeSetting: changeSettingFnType, newChangeAction: changeActionFnType) => { - changeSetting = newChangeSetting - changeAction = newChangeAction - } + beforeEach(jest.clearAllMocks) const t = ((key: string) => key) as TFunction - const languageCode = 'de' + const updateSettings = jest.fn() const cityCode = 'augsburg' + const appContext = { ...testingAppContext({}), cityCode, updateSettings } const navigation = createNavigationScreenPropMock() const showSnackbar = jest.fn() - const createSettings = () => + const createSettings = (params: Partial = {}) => createSettingsSections({ - t, - languageCode, - cityCode, + appContext: { ...appContext, settings: { ...appContext.settings, ...params } }, navigation, - settings: defaultSettings, - setSetting, showSnackbar, + t, })[0]!.data describe('allowPushNotifications', () => { @@ -75,89 +58,83 @@ describe('createSettingsSections', () => { expect(sections.find(it => it.title === 'pushNewsTitle')).toBeFalsy() }) - it('should set correct setting on press', () => { + it('should set correct setting on press', async () => { mockedPushNotificationsEnabled.mockImplementation(() => true) const sections = createSettings() - const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle') - // Initialize changeSetting and changeAction - pushNotificationSection!.onPress() - - const settings = defaultSettings - settings.allowPushNotifications = false - const changedSettings = changeSetting(settings) - expect(pushNotificationSection!.getSettingValue!(settings)).toBeFalsy() - expect(changedSettings.allowPushNotifications).toBeTruthy() - settings.allowPushNotifications = true - const changedSettings2 = changeSetting(settings) - expect(pushNotificationSection!.getSettingValue!(settings)).toBeTruthy() - expect(changedSettings2.allowPushNotifications).toBeFalsy() + const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle')! + await pushNotificationSection!.onPress() + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ allowPushNotifications: false }) + + expect( + pushNotificationSection.getSettingValue!({ ...appContext.settings, allowPushNotifications: false }), + ).toBeFalsy() + expect( + pushNotificationSection!.getSettingValue!({ ...appContext.settings, allowPushNotifications: true }), + ).toBeTruthy() }) it('should unsubscribe from push notification topic', async () => { mockedPushNotificationsEnabled.mockImplementation(() => true) const sections = createSettings() - const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle') - // Initialize changeSetting and changeAction - pushNotificationSection?.onPress() - const newSettings = defaultSettings - newSettings.allowPushNotifications = false - - const assertedChangeAction = changeAction as (newSettings: SettingsType) => Promise + const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle')! expect(mockUnsubscribeNews).not.toHaveBeenCalled() - const successful = await assertedChangeAction(newSettings) - expect(successful).toBe(true) + await pushNotificationSection.onPress() + expect(mockUnsubscribeNews).toHaveBeenCalledTimes(1) - expect(mockUnsubscribeNews).toHaveBeenCalledWith(cityCode, languageCode) + expect(mockUnsubscribeNews).toHaveBeenCalledWith(cityCode, appContext.languageCode) expect(mockSubscribeNews).not.toHaveBeenCalled() expect(mockRequestPushNotificationPermission).not.toHaveBeenCalled() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ allowPushNotifications: false }) }) it('should subscribe to push notification topic if permission is granted', async () => { mockedPushNotificationsEnabled.mockImplementation(() => true) - const sections = createSettings() - const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle') - // Initialize changeSetting and changeAction - pushNotificationSection?.onPress() - const newSettings = defaultSettings - newSettings.allowPushNotifications = true - - const assertedChangeAction = changeAction as (newSettings: SettingsType) => Promise + const sections = createSettings({ allowPushNotifications: false }) + const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle')! expect(mockRequestPushNotificationPermission).not.toHaveBeenCalled() expect(mockSubscribeNews).not.toHaveBeenCalled() mockRequestPushNotificationPermission.mockImplementationOnce(async () => true) - const successful = await assertedChangeAction(newSettings) - expect(successful).toBe(true) + await pushNotificationSection.onPress() + expect(mockRequestPushNotificationPermission).toHaveBeenCalledTimes(1) expect(mockSubscribeNews).toHaveBeenCalledTimes(1) - expect(mockSubscribeNews).toHaveBeenCalledWith(cityCode, languageCode, true) + expect(mockSubscribeNews).toHaveBeenCalledWith({ + cityCode, + languageCode: appContext.languageCode, + allowPushNotifications: true, + skipSettingsCheck: true, + }) expect(mockUnsubscribeNews).not.toHaveBeenCalled() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith({ allowPushNotifications: true }) }) it('should open settings and return false if permissions not granted', async () => { mockedPushNotificationsEnabled.mockImplementation(() => true) - const sections = createSettings() - const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle') - // Initialize changeSetting and changeAction - pushNotificationSection?.onPress() - const newSettings = defaultSettings - newSettings.allowPushNotifications = true - - const assertedChangeAction = changeAction as (newSettings: SettingsType) => Promise + const sections = createSettings({ allowPushNotifications: false }) + const pushNotificationSection = sections.find(it => it.title === 'pushNewsTitle')! expect(mockRequestPushNotificationPermission).not.toHaveBeenCalled() expect(mockSubscribeNews).not.toHaveBeenCalled() mockRequestPushNotificationPermission.mockImplementationOnce(async () => false) - const successful = await assertedChangeAction(newSettings) - expect(successful).toBe(false) + await pushNotificationSection.onPress() + expect(mockRequestPushNotificationPermission).toHaveBeenCalledTimes(1) - expect(openSettings).toHaveBeenCalledTimes(1) expect(mockSubscribeNews).not.toHaveBeenCalled() expect(mockUnsubscribeNews).not.toHaveBeenCalled() + + expect(updateSettings).toHaveBeenCalledTimes(2) + expect(updateSettings).toHaveBeenLastCalledWith({ allowPushNotifications: false }) + expect(showSnackbar).toHaveBeenCalledTimes(1) }) }) }) diff --git a/native/src/utils/createSettingsSections.ts b/native/src/utils/createSettingsSections.ts index c526f88ff4..b41d09441b 100644 --- a/native/src/utils/createSettingsSections.ts +++ b/native/src/utils/createSettingsSections.ts @@ -9,6 +9,7 @@ import { SnackbarType } from '../components/SnackbarContainer' import NativeConstants from '../constants/NativeConstants' import { NavigationProps } from '../constants/NavigationTypes' import buildConfig from '../constants/buildConfig' +import { CityAppContext } from '../hooks/useCityAppContext' import { SettingsType } from './AppSettings' import { pushNotificationsEnabled, @@ -19,15 +20,10 @@ import { import openExternalUrl from './openExternalUrl' import { initSentry } from './sentry' -export type SetSettingFunctionType = ( - changeSetting: (settings: SettingsType) => Partial, - changeAction?: (newSettings: SettingsType) => Promise, -) => Promise - export type SettingsSectionType = { title: string description?: string - onPress: () => void + onPress: () => Promise | void bigTitle?: boolean role?: Role hasSwitch?: boolean @@ -42,24 +38,18 @@ const volatileValues = { const TRIGGER_VERSION_TAPS = 25 type CreateSettingsSectionsProps = { - setSetting: SetSettingFunctionType - t: TFunction - languageCode: string - cityCode: string | null | undefined + appContext: CityAppContext navigation: NavigationProps - settings: SettingsType showSnackbar: (snackbar: SnackbarType) => void + t: TFunction<'error'> } const createSettingsSections = ({ - setSetting, - t, - languageCode, - cityCode, + appContext: { settings, updateSettings, cityCode, languageCode }, navigation, - settings, showSnackbar, -}: CreateSettingsSectionsProps): Readonly>> => [ + t, +}: CreateSettingsSectionsProps): Readonly[]> => [ { title: null, data: [ @@ -71,34 +61,34 @@ const createSettingsSections = ({ description: t('pushNewsDescription'), hasSwitch: true, getSettingValue: (settings: SettingsType) => settings.allowPushNotifications, - onPress: () => { - setSetting( - settings => ({ - allowPushNotifications: !settings.allowPushNotifications, - }), - async (newSettings): Promise => { - if (!cityCode) { - // No city selected so nothing to do here (should not ever happen since settings are only available from city content routes) - return true - } + onPress: async () => { + const newAllowPushNotifications = !settings.allowPushNotifications + updateSettings({ allowPushNotifications: newAllowPushNotifications }) + if (!newAllowPushNotifications) { + await unsubscribeNews(cityCode, languageCode) + return + } - if (newSettings.allowPushNotifications) { - const status = await requestPushNotificationPermission() + const status = await requestPushNotificationPermission(updateSettings) - if (status) { - await subscribeNews(cityCode, languageCode, true) - } else { - // If the user has rejected the permission once, it can only be changed in the system settings - openSettings() - // Not successful, reset displayed setting in app - return false - } - } else { - await unsubscribeNews(cityCode, languageCode) - } - return true - }, - ) + if (status) { + await subscribeNews({ + cityCode, + languageCode, + allowPushNotifications: newAllowPushNotifications, + skipSettingsCheck: true, + }) + } else { + updateSettings({ allowPushNotifications: false }) + // If the user has rejected the permission once, it can only be changed in the system settings + showSnackbar({ + text: 'permissionRequired', + positiveAction: { + label: t('layout:settings'), + onPress: openSettings, + }, + }) + } }, }, ]), @@ -109,21 +99,16 @@ const createSettingsSections = ({ }), hasSwitch: true, getSettingValue: (settings: SettingsType) => settings.errorTracking, - onPress: () => { - setSetting( - settings => ({ - errorTracking: !settings.errorTracking, - }), - async newSettings => { - const client = Sentry.getCurrentHub().getClient() - if (newSettings.errorTracking && !client) { - initSentry() - } else if (client) { - client.getOptions().enabled = !!newSettings.errorTracking - } - return true - }, - ) + onPress: async () => { + const newErrorTracking = !settings.errorTracking + updateSettings({ errorTracking: newErrorTracking }) + + const client = Sentry.getClient() + if (newErrorTracking && !client) { + initSentry() + } else if (client) { + client.getOptions().enabled = newErrorTracking + } }, }, { @@ -138,19 +123,19 @@ const createSettingsSections = ({ title: t('about', { appName: buildConfig().appName, }), - onPress: () => { + onPress: async () => { const { aboutUrls } = buildConfig() const aboutUrl = aboutUrls[languageCode] || aboutUrls.default - openExternalUrl(aboutUrl, showSnackbar) + await openExternalUrl(aboutUrl, showSnackbar) }, }, { role: 'link', title: t('privacyPolicy'), - onPress: () => { + onPress: async () => { const { privacyUrls } = buildConfig() const privacyUrl = privacyUrls[languageCode] || privacyUrls.default - openExternalUrl(privacyUrl, showSnackbar) + await openExternalUrl(privacyUrl, showSnackbar) }, }, { diff --git a/native/src/utils/helpers.ts b/native/src/utils/helpers.ts index 12a42ca82c..463729647c 100644 --- a/native/src/utils/helpers.ts +++ b/native/src/utils/helpers.ts @@ -33,9 +33,9 @@ export const determineApiUrl = async (): Promise => { */ export const forEachTreeNode = ( root: T, - resolveChildren: (arg0: T) => Array, + resolveChildren: (arg0: T) => T[], depth: number, - nodeAction: (arg0: T, arg1: Array | null | undefined) => void, + nodeAction: (arg0: T, arg1: T[] | null | undefined) => void, ): void => { if (depth === 0) { nodeAction(root, null) diff --git a/native/src/utils/loadResourceCache.ts b/native/src/utils/loadResourceCache.ts index 4726fb3225..fae1bc7496 100644 --- a/native/src/utils/loadResourceCache.ts +++ b/native/src/utils/loadResourceCache.ts @@ -16,7 +16,7 @@ export type FetchMapTargetType = { urlHash: string } -export type FetchMapType = Record> +export type FetchMapType = Record const loadResourceCache = async ({ cityCode, diff --git a/release-notes/unreleased/2805-Banner-to-install-app-from-store.yml b/release-notes/2024.10.3/2805-Banner-to-install-app-from-store.yml similarity index 100% rename from release-notes/unreleased/2805-Banner-to-install-app-from-store.yml rename to release-notes/2024.10.3/2805-Banner-to-install-app-from-store.yml diff --git a/release-notes/unreleased/2897-refresh-cities.yml b/release-notes/2024.10.3/2897-refresh-cities.yml similarity index 100% rename from release-notes/unreleased/2897-refresh-cities.yml rename to release-notes/2024.10.3/2897-refresh-cities.yml diff --git a/release-notes/unreleased/2350-pn-permission-settings.yml b/release-notes/unreleased/2350-pn-permission-settings.yml new file mode 100644 index 0000000000..b1fabb1f09 --- /dev/null +++ b/release-notes/unreleased/2350-pn-permission-settings.yml @@ -0,0 +1,7 @@ +issue_key: 2350 +show_in_stores: true +platforms: + - android + - ios +en: Add explanation if push notification permissions are missing. +de: Es wird nun eine Erklärung angezeigt, wenn die Berechtigungen für Push-Benachrichtigungen fehlen. diff --git a/release-notes/unreleased/2924-security-error.yml b/release-notes/unreleased/2924-security-error.yml new file mode 100644 index 0000000000..1c5f94a204 --- /dev/null +++ b/release-notes/unreleased/2924-security-error.yml @@ -0,0 +1,5 @@ +issue_key: 2924 +show_in_stores: false +platforms: + - web +en: Fix crashes if local storage is not available diff --git a/shared/api/endpoints/__tests__/createChatMessagesEndpoint.spec.ts b/shared/api/endpoints/__tests__/createChatMessagesEndpoint.spec.ts index 38e5f6fd5a..12aa0ec449 100644 --- a/shared/api/endpoints/__tests__/createChatMessagesEndpoint.spec.ts +++ b/shared/api/endpoints/__tests__/createChatMessagesEndpoint.spec.ts @@ -23,6 +23,7 @@ describe('createChatMessagesEndpoint', () => { id: 2, body: 'Informationen zu Ihrer Frage finden Sie auf folgenden Seiten:', user_is_author: false, + automatic_answer: false, }, ], } @@ -32,6 +33,7 @@ describe('createChatMessagesEndpoint', () => { id: 2, body: 'Informationen zu Ihrer Frage finden Sie auf folgenden Seiten:', userIsAuthor: false, + automaticAnswer: false, }), ]) }) diff --git a/shared/api/endpoints/createCategoriesEndpoint.ts b/shared/api/endpoints/createCategoriesEndpoint.ts index f9c1f83a2f..918a84ccf4 100644 --- a/shared/api/endpoints/createCategoriesEndpoint.ts +++ b/shared/api/endpoints/createCategoriesEndpoint.ts @@ -18,7 +18,7 @@ export default (baseUrl: string): Endpoint => .withParamsToUrlMapper( (params: ParamsType): string => `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/pages/`, ) - .withMapper((json: Array, params: ParamsType): CategoriesMapModel => { + .withMapper((json: JsonCategoryType[], params: ParamsType): CategoriesMapModel => { const basePath = `/${params.city}/${params.language}` const categories = json.map(category => mapCategoryJson(category, basePath)) categories.push( diff --git a/shared/api/endpoints/createCategoryChildrenEndpoint.ts b/shared/api/endpoints/createCategoryChildrenEndpoint.ts index cb4f70d1d3..a520a89291 100644 --- a/shared/api/endpoints/createCategoryChildrenEndpoint.ts +++ b/shared/api/endpoints/createCategoryChildrenEndpoint.ts @@ -11,8 +11,8 @@ type ParamsType = { cityContentPath: string depth: number } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(CATEGORY_CHILDREN_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(CATEGORY_CHILDREN_ENDPOINT_NAME) .withParamsToUrlMapper((params: ParamsType): string => { const { city, language, cityContentPath, depth } = params const basePath = `/${city}/${language}` @@ -20,7 +20,7 @@ export default (baseUrl: string): Endpoint> => const query = basePath === cityContentPath ? '' : `&url=${params.cityContentPath}` return `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/children/?depth=${depth}${query}` }) - .withMapper((json: Array, params: ParamsType): Array => { + .withMapper((json: JsonCategoryType[], params: ParamsType): CategoryModel[] => { const basePath = `/${params.city}/${params.language}` return json.map(category => mapCategoryJson(category, basePath)) }) diff --git a/shared/api/endpoints/createCategoryParentsEndpoint.ts b/shared/api/endpoints/createCategoryParentsEndpoint.ts index 868ea60936..97ad4fd9cb 100644 --- a/shared/api/endpoints/createCategoryParentsEndpoint.ts +++ b/shared/api/endpoints/createCategoryParentsEndpoint.ts @@ -13,8 +13,8 @@ type ParamsType = { language: string cityContentPath: string } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(CATEGORY_PARENTS_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(CATEGORY_PARENTS_ENDPOINT_NAME) .withParamsToUrlMapper((params: ParamsType): string => { const { city, language, cityContentPath } = params const basePath = `/${city}/${language}` @@ -25,7 +25,7 @@ export default (baseUrl: string): Endpoint> => return `${baseUrl}/api/${API_VERSION}/${city}/${language}/parents/?url=${cityContentPath}` }) - .withMapper((json: Array, params: ParamsType): Array => { + .withMapper((json: JsonCategoryType[], params: ParamsType): CategoryModel[] => { const basePath = `/${params.city}/${params.language}` const parents = json.map(category => mapCategoryJson(category, basePath)) parents.push( diff --git a/shared/api/endpoints/createChatMessagesEndpoint.ts b/shared/api/endpoints/createChatMessagesEndpoint.ts index 718c803e57..bbe6f037e8 100644 --- a/shared/api/endpoints/createChatMessagesEndpoint.ts +++ b/shared/api/endpoints/createChatMessagesEndpoint.ts @@ -11,21 +11,21 @@ type ParamsType = { deviceId: string } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(CHAT_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(CHAT_ENDPOINT_NAME) .withParamsToUrlMapper( (params: ParamsType): string => `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/${CHAT_ENDPOINT_NAME}/${params.deviceId}/`, ) - .withMapper( - (json: JsonChatMessagesType): Array => - json.messages.map( - chatMessage => - new ChatMessageModel({ - id: chatMessage.id, - body: chatMessage.body, - userIsAuthor: chatMessage.user_is_author, - }), - ), + .withMapper((json: JsonChatMessagesType): ChatMessageModel[] => + json.messages.map( + chatMessage => + new ChatMessageModel({ + id: chatMessage.id, + body: chatMessage.body, + userIsAuthor: chatMessage.user_is_author, + automaticAnswer: chatMessage.automatic_answer, + }), + ), ) .build() diff --git a/shared/api/endpoints/createEventsEndpoint.ts b/shared/api/endpoints/createEventsEndpoint.ts index ce94b40e5d..11408ebcba 100644 --- a/shared/api/endpoints/createEventsEndpoint.ts +++ b/shared/api/endpoints/createEventsEndpoint.ts @@ -38,57 +38,56 @@ const eventCompare = (event1: EventModel, event2: EventModel): number => { return event1.title.localeCompare(event2.title) } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(EVENTS_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(EVENTS_ENDPOINT_NAME) .withParamsToUrlMapper( (params: ParamsType): string => `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/events/?combine_recurring=True`, ) - .withMapper( - (json: Array): Array => - json - .map((event: JsonEventType): EventModel => { - const eventData = event.event - const allDay = eventData.all_day - return new EventModel({ - path: event.path, - title: event.title, - content: event.content, - thumbnail: event.thumbnail, - date: new DateModel({ - startDate: DateTime.fromISO(eventData.start), - endDate: DateTime.fromISO(eventData.end), - allDay, - recurrenceRule: event.recurrence_rule ? rrulestr(event.recurrence_rule) : null, - }), - location: - event.location.id !== null - ? new LocationModel({ - id: event.location.id, - name: event.location.name, - address: event.location.address, - town: event.location.town, - postcode: event.location.postcode, - country: event.location.country, - latitude: event.location.latitude, - longitude: event.location.longitude, - }) - : null, - excerpt: decodeHTML(event.excerpt), - availableLanguages: mapAvailableLanguages(event.available_languages), - lastUpdate: DateTime.fromISO(event.last_updated), - featuredImage: event.featured_image - ? new FeaturedImageModel({ - description: event.featured_image.description, - thumbnail: event.featured_image.thumbnail[0], - medium: event.featured_image.medium[0], - large: event.featured_image.large[0], - full: event.featured_image.full[0], + .withMapper((json: JsonEventType[]): EventModel[] => + json + .map((event: JsonEventType): EventModel => { + const eventData = event.event + const allDay = eventData.all_day + return new EventModel({ + path: event.path, + title: event.title, + content: event.content, + thumbnail: event.thumbnail, + date: new DateModel({ + startDate: DateTime.fromISO(eventData.start), + endDate: DateTime.fromISO(eventData.end), + allDay, + recurrenceRule: event.recurrence_rule ? rrulestr(event.recurrence_rule) : null, + }), + location: + event.location.id !== null + ? new LocationModel({ + id: event.location.id, + name: event.location.name, + address: event.location.address, + town: event.location.town, + postcode: event.location.postcode, + country: event.location.country, + latitude: event.location.latitude, + longitude: event.location.longitude, }) : null, - poiPath: event.location_path, - }) + excerpt: decodeHTML(event.excerpt), + availableLanguages: mapAvailableLanguages(event.available_languages), + lastUpdate: DateTime.fromISO(event.last_updated), + featuredImage: event.featured_image + ? new FeaturedImageModel({ + description: event.featured_image.description, + thumbnail: event.featured_image.thumbnail[0], + medium: event.featured_image.medium[0], + large: event.featured_image.large[0], + full: event.featured_image.full[0], + }) + : null, + poiPath: event.location_path, }) - .sort(eventCompare), + }) + .sort(eventCompare), ) .build() diff --git a/shared/api/endpoints/createLocalNewsElementEndpoint.ts b/shared/api/endpoints/createLocalNewsElementEndpoint.ts index 3aac69916a..e4b90520b6 100644 --- a/shared/api/endpoints/createLocalNewsElementEndpoint.ts +++ b/shared/api/endpoints/createLocalNewsElementEndpoint.ts @@ -22,7 +22,7 @@ export default (baseUrl: string): Endpoint => (params: ParamsType): string => `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/fcm/?id=${params.id}`, ) - .withMapper((localNews: Array, params: ParamsType): LocalNewsModel => { + .withMapper((localNews: JsonLocalNewsType[], params: ParamsType): LocalNewsModel => { const localNewsModel = localNews[0] if (!localNewsModel) { diff --git a/shared/api/endpoints/createLocalNewsEndpoint.ts b/shared/api/endpoints/createLocalNewsEndpoint.ts index dce0cadcdd..5667c20b04 100644 --- a/shared/api/endpoints/createLocalNewsEndpoint.ts +++ b/shared/api/endpoints/createLocalNewsEndpoint.ts @@ -12,23 +12,22 @@ type ParamsType = { city: string language: string } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(LOCAL_NEWS_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(LOCAL_NEWS_ENDPOINT_NAME) .withParamsToUrlMapper( (params: ParamsType): string => `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/fcm/?channel=news`, ) - .withMapper( - (json: Array): Array => - json.map( - (localNews: JsonLocalNewsType) => - new LocalNewsModel({ - id: localNews.id, - timestamp: DateTime.fromISO(localNews.timestamp), - title: localNews.title, - content: localNews.message, - availableLanguages: mapNewsAvailableLanguages(localNews.available_languages), - }), - ), + .withMapper((json: JsonLocalNewsType[]): LocalNewsModel[] => + json.map( + (localNews: JsonLocalNewsType) => + new LocalNewsModel({ + id: localNews.id, + timestamp: DateTime.fromISO(localNews.timestamp), + title: localNews.title, + content: localNews.message, + availableLanguages: mapNewsAvailableLanguages(localNews.available_languages), + }), + ), ) .build() diff --git a/shared/api/endpoints/createPOIsEndpoint.ts b/shared/api/endpoints/createPOIsEndpoint.ts index cafcf08376..d42fa5bd76 100644 --- a/shared/api/endpoints/createPOIsEndpoint.ts +++ b/shared/api/endpoints/createPOIsEndpoint.ts @@ -15,49 +15,48 @@ type ParamsType = { city: string language: string } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(POIS_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(POIS_ENDPOINT_NAME) .withParamsToUrlMapper( (params: ParamsType): string => `${baseUrl}/api/${API_VERSION}/${params.city}/${params.language}/locations/?on_map=1`, ) - .withMapper( - (json: Array): Array => - json.map( - poi => - new PoiModel({ - path: poi.path, - title: poi.title, - content: poi.content, - thumbnail: poi.thumbnail, - availableLanguages: mapAvailableLanguages(poi.available_languages), - excerpt: poi.excerpt, - metaDescription: poi.meta_description ? poi.meta_description : null, - website: poi.website, - phoneNumber: poi.phone_number, - email: poi.email, - temporarilyClosed: poi.temporarily_closed, - openingHours: poi.opening_hours?.map(openingHour => new OpeningHoursModel(openingHour)) ?? null, - appointmentUrl: poi.appointment_url, - category: new PoiCategoryModel({ - id: poi.category.id, - name: poi.category.name, - color: poi.category.color, - iconName: poi.category.icon, - icon: poi.category.icon_url, - }), - location: new LocationModel({ - id: poi.location.id, - name: poi.location.name, - address: poi.location.address, - town: poi.location.town, - postcode: poi.location.postcode, - country: poi.location.country, - latitude: poi.location.latitude, - longitude: poi.location.longitude, - }), - lastUpdate: DateTime.fromISO(poi.last_updated), + .withMapper((json: JsonPoiType[]): PoiModel[] => + json.map( + poi => + new PoiModel({ + path: poi.path, + title: poi.title, + content: poi.content, + thumbnail: poi.thumbnail, + availableLanguages: mapAvailableLanguages(poi.available_languages), + excerpt: poi.excerpt, + metaDescription: poi.meta_description ? poi.meta_description : null, + website: poi.website, + phoneNumber: poi.phone_number, + email: poi.email, + temporarilyClosed: poi.temporarily_closed, + openingHours: poi.opening_hours?.map(openingHour => new OpeningHoursModel(openingHour)) ?? null, + appointmentUrl: poi.appointment_url, + category: new PoiCategoryModel({ + id: poi.category.id, + name: poi.category.name, + color: poi.category.color, + iconName: poi.category.icon, + icon: poi.category.icon_url, }), - ), + location: new LocationModel({ + id: poi.location.id, + name: poi.location.name, + address: poi.location.address, + town: poi.location.town, + postcode: poi.location.postcode, + country: poi.location.country, + latitude: poi.location.latitude, + longitude: poi.location.longitude, + }), + lastUpdate: DateTime.fromISO(poi.last_updated), + }), + ), ) .build() diff --git a/shared/api/endpoints/createSendChatMessageEndpoint.ts b/shared/api/endpoints/createSendChatMessageEndpoint.ts index 8ef8f56bf1..a5a669531a 100644 --- a/shared/api/endpoints/createSendChatMessageEndpoint.ts +++ b/shared/api/endpoints/createSendChatMessageEndpoint.ts @@ -30,6 +30,7 @@ export default (baseUrl: string): Endpoint => id: json.id, body: json.body, userIsAuthor: json.user_is_author, + automaticAnswer: json.automatic_answer, }), ) .build() diff --git a/shared/api/endpoints/createSprungbrettJobsEndpoint.ts b/shared/api/endpoints/createSprungbrettJobsEndpoint.ts index c016cf64a1..a53603b30e 100644 --- a/shared/api/endpoints/createSprungbrettJobsEndpoint.ts +++ b/shared/api/endpoints/createSprungbrettJobsEndpoint.ts @@ -5,21 +5,20 @@ import { JsonSprungbrettJobType } from '../types' export const SPRUNGBRETT_JOBS_ENDPOINT_NAME = 'sprungbrettJobs' -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(SPRUNGBRETT_JOBS_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(SPRUNGBRETT_JOBS_ENDPOINT_NAME) .withParamsToUrlMapper(() => baseUrl) - .withMapper( - (json: { results: Array }): Array => - json.results.map( - (job, index) => - new SprungbrettJobModel({ - id: index, - title: job.title, - location: `${job.zip} ${job.city}`, - url: job.url, - isEmployment: job.employment === '1', - isApprenticeship: job.apprenticeship === '1', - }), - ), + .withMapper((json: { results: JsonSprungbrettJobType[] }): SprungbrettJobModel[] => + json.results.map( + (job, index) => + new SprungbrettJobModel({ + id: index, + title: job.title, + location: `${job.zip} ${job.city}`, + url: job.url, + isEmployment: job.employment === '1', + isApprenticeship: job.apprenticeship === '1', + }), + ), ) .build() diff --git a/shared/api/endpoints/createTunewsElementEndpoint.ts b/shared/api/endpoints/createTunewsElementEndpoint.ts index de56282ade..3faaf0183c 100644 --- a/shared/api/endpoints/createTunewsElementEndpoint.ts +++ b/shared/api/endpoints/createTunewsElementEndpoint.ts @@ -16,7 +16,7 @@ type ParamsType = { export default (baseUrl: string): Endpoint => new EndpointBuilder(TUNEWS_ELEMENT_ENDPOINT_NAME) .withParamsToUrlMapper((params: ParamsType): string => `${baseUrl}/v1/news/${params.id}`) - .withMapper((json: JsonTunewsType | Array, params: ParamsType): TunewsModel => { + .withMapper((json: JsonTunewsType | void[], params: ParamsType): TunewsModel => { // The api is not good and returns an empty array if the tunews does not exist if (Array.isArray(json)) { throw new NotFoundError({ diff --git a/shared/api/endpoints/createTunewsEndpoint.ts b/shared/api/endpoints/createTunewsEndpoint.ts index aed043ac00..057837e682 100644 --- a/shared/api/endpoints/createTunewsEndpoint.ts +++ b/shared/api/endpoints/createTunewsEndpoint.ts @@ -12,23 +12,22 @@ type ParamsType = { page: number count: number } -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(TUNEWS_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(TUNEWS_ENDPOINT_NAME) .withParamsToUrlMapper( (params: ParamsType): string => `${baseUrl}/v1/news/${params.language}?page=${params.page}&count=${params.count}`, ) - .withMapper( - (json: Array): Array => - json.map( - (tunews: JsonTunewsType) => - new TunewsModel({ - id: tunews.id, - title: tunews.title, - tags: tunews.tags, - date: DateTime.fromJSDate(new Date(tunews.date)), - content: parseHTML(tunews.content), - eNewsNo: tunews.enewsno, - }), - ), + .withMapper((json: JsonTunewsType[]): TunewsModel[] => + json.map( + (tunews: JsonTunewsType) => + new TunewsModel({ + id: tunews.id, + title: tunews.title, + tags: tunews.tags, + date: DateTime.fromJSDate(new Date(tunews.date)), + content: parseHTML(tunews.content), + eNewsNo: tunews.enewsno, + }), + ), ) .build() diff --git a/shared/api/endpoints/createTunewsLanguagesEndpoint.ts b/shared/api/endpoints/createTunewsLanguagesEndpoint.ts index 758f0f9f58..fa70584ae0 100644 --- a/shared/api/endpoints/createTunewsLanguagesEndpoint.ts +++ b/shared/api/endpoints/createTunewsLanguagesEndpoint.ts @@ -5,10 +5,10 @@ import { JsonTunewsLanguageType } from '../types' export const TUNEWS_LANGUAGES_ENDPOINT_NAME = 'tunewsLanguages' -export default (baseUrl: string): Endpoint> => - new EndpointBuilder>(TUNEWS_LANGUAGES_ENDPOINT_NAME) +export default (baseUrl: string): Endpoint => + new EndpointBuilder(TUNEWS_LANGUAGES_ENDPOINT_NAME) .withParamsToUrlMapper(() => `${baseUrl}/v1/news/languages`) - .withMapper((json: Array) => + .withMapper((json: JsonTunewsLanguageType[]) => json .map((language: JsonTunewsLanguageType) => new LanguageModel(language.code, language.name)) .sort((lang1, lang2) => lang1.code.localeCompare(lang2.code)), diff --git a/shared/api/endpoints/testing/CategoriesMapModelBuilder.ts b/shared/api/endpoints/testing/CategoriesMapModelBuilder.ts index 65bedea698..843292edf3 100644 --- a/shared/api/endpoints/testing/CategoriesMapModelBuilder.ts +++ b/shared/api/endpoints/testing/CategoriesMapModelBuilder.ts @@ -27,7 +27,7 @@ class CategoriesMapModelBuilder { _arity: number _city: string _language: string - _categories: Array = [] + _categories: CategoryModel[] = [] _resourceCache: Record = {} _id = 0 diff --git a/shared/api/endpoints/testing/CityModelBuilder.ts b/shared/api/endpoints/testing/CityModelBuilder.ts index 9fe64f60d9..7116494dc2 100644 --- a/shared/api/endpoints/testing/CityModelBuilder.ts +++ b/shared/api/endpoints/testing/CityModelBuilder.ts @@ -128,7 +128,7 @@ class CityModelBuilder { } } - build(): Array { + build(): CityModel[] { return cities.slice(0, this._citiesCount) } } diff --git a/shared/api/endpoints/testing/EventModelBuilder.ts b/shared/api/endpoints/testing/EventModelBuilder.ts index b3e1111f58..20b27fa70e 100644 --- a/shared/api/endpoints/testing/EventModelBuilder.ts +++ b/shared/api/endpoints/testing/EventModelBuilder.ts @@ -35,7 +35,7 @@ class EventModelBuilder { return seedrandom(index + this._seed)() * max } - build(): Array { + build(): EventModel[] { return this.buildAll().map(all => all.event) } diff --git a/shared/api/endpoints/testing/LanguageModelBuilder.ts b/shared/api/endpoints/testing/LanguageModelBuilder.ts index 0f57ffb130..8ce22a0774 100644 --- a/shared/api/endpoints/testing/LanguageModelBuilder.ts +++ b/shared/api/endpoints/testing/LanguageModelBuilder.ts @@ -17,7 +17,7 @@ class LanguageModelBuilder { } } - build(): Array { + build(): LanguageModel[] { return languages.slice(0, this._languagesCount) } } diff --git a/shared/api/endpoints/testing/NewsModelBuilder.ts b/shared/api/endpoints/testing/NewsModelBuilder.ts index e9f0cef57c..8d3f5e9d68 100644 --- a/shared/api/endpoints/testing/NewsModelBuilder.ts +++ b/shared/api/endpoints/testing/NewsModelBuilder.ts @@ -15,17 +15,17 @@ class LocalNewsModelBuilder { this._language = language } - build(): Array { + build(): LocalNewsModel[] { return this.buildAll().map(all => all.newsItem) } /** * Builds the requested amount of news. Two builds with an identical seed will yield equal news. */ - buildAll(): Array<{ + buildAll(): { path: string | null | undefined newsItem: LocalNewsModel - }> { + }[] { return Array.from( { length: this._newsCount, diff --git a/shared/api/endpoints/testing/PoiModelBuilder.ts b/shared/api/endpoints/testing/PoiModelBuilder.ts index 54c8955bf1..dc9386bea0 100644 --- a/shared/api/endpoints/testing/PoiModelBuilder.ts +++ b/shared/api/endpoints/testing/PoiModelBuilder.ts @@ -141,7 +141,7 @@ class PoiModelBuilder { } } - build(): Array { + build(): PoiModel[] { return pois.slice(0, this._poisCount) } } diff --git a/shared/api/models/CategoriesMapModel.ts b/shared/api/models/CategoriesMapModel.ts index 1617a2d726..62c832c1fe 100644 --- a/shared/api/models/CategoriesMapModel.ts +++ b/shared/api/models/CategoriesMapModel.ts @@ -13,14 +13,14 @@ class CategoriesMapModel { * whose parent attributes are first changed from id to path * @param categories CategoryModel as array */ - constructor(categories: Array) { + constructor(categories: CategoryModel[]) { this._categories = new Map(categories.map(category => [category.path, category])) } /** * @return {CategoryModel[]} categories The categories as array */ - toArray(): Array { + toArray(): CategoryModel[] { return Array.from(this._categories.values()) } @@ -38,7 +38,7 @@ class CategoriesMapModel { * @param category The category * @return {CategoryModel[]} The children */ - getChildren(category: CategoryModel): Array { + getChildren(category: CategoryModel): CategoryModel[] { return this.toArray() .filter(_category => _category.parentPath === category.path) .sort((category1, category2) => category1.order - category2.order) @@ -49,8 +49,8 @@ class CategoriesMapModel { * @param category The category * @return {CategoryModel[]} The parents, with the immediate parent last */ - getAncestors(category: CategoryModel): Array { - const parents: Array = [] + getAncestors(category: CategoryModel): CategoryModel[] { + const parents: CategoryModel[] = [] let currentCategory = category while (!currentCategory.isRoot()) { diff --git a/shared/api/models/ChatMessageModel.ts b/shared/api/models/ChatMessageModel.ts index cd367bf351..19dc54a059 100644 --- a/shared/api/models/ChatMessageModel.ts +++ b/shared/api/models/ChatMessageModel.ts @@ -2,12 +2,14 @@ class ChatMessageModel { _id: number _body: string _userIsAuthor: boolean + _automaticAnswer: boolean - constructor(params: { id: number; body: string; userIsAuthor: boolean }) { - const { id, body, userIsAuthor } = params + constructor(params: { id: number; body: string; userIsAuthor: boolean; automaticAnswer: boolean }) { + const { id, body, userIsAuthor, automaticAnswer } = params this._id = id this._body = body this._userIsAuthor = userIsAuthor + this._automaticAnswer = automaticAnswer } get id(): number { @@ -21,6 +23,10 @@ class ChatMessageModel { get userIsAuthor(): boolean { return this._userIsAuthor } + + get isAutomaticAnswer(): boolean { + return this._automaticAnswer + } } export default ChatMessageModel diff --git a/shared/api/models/CityModel.ts b/shared/api/models/CityModel.ts index 694c6a0312..8c4174c823 100644 --- a/shared/api/models/CityModel.ts +++ b/shared/api/models/CityModel.ts @@ -121,7 +121,7 @@ class CityModel { return this._aliases } - static findCityName(cities: ReadonlyArray, code: string): string { + static findCityName(cities: readonly CityModel[], code: string): string { const city = cities.find(city => city.code === code) return city ? city.name : code } diff --git a/shared/api/models/TunewsModel.ts b/shared/api/models/TunewsModel.ts index bf3b4b1a1f..d42a5f1a0c 100644 --- a/shared/api/models/TunewsModel.ts +++ b/shared/api/models/TunewsModel.ts @@ -4,19 +4,12 @@ import { DateTime } from 'luxon' class TunewsModel { _id: number _title: string - _tags: Array + _tags: string[] _date: DateTime _content: string _eNewsNo: string - constructor(params: { - id: number - title: string - date: DateTime - tags: Array - content: string - eNewsNo: string - }) { + constructor(params: { id: number; title: string; date: DateTime; tags: string[]; content: string; eNewsNo: string }) { const { id, date, title, tags, content, eNewsNo } = params this._id = id this._title = decodeHTML(title) @@ -38,7 +31,7 @@ class TunewsModel { return this._date } - get tags(): Array { + get tags(): string[] { return this._tags } diff --git a/shared/api/models/__tests__/EventModel.spec.ts b/shared/api/models/__tests__/EventModel.spec.ts index 08a35035b9..f543199373 100644 --- a/shared/api/models/__tests__/EventModel.spec.ts +++ b/shared/api/models/__tests__/EventModel.spec.ts @@ -6,6 +6,8 @@ import DateModel from '../DateModel' import EventModel from '../EventModel' import LocationModel from '../LocationModel' +jest.useFakeTimers({ now: new Date('2023-10-02T15:23:57.443+02:00') }) + describe('EventModel', () => { const params = { path: '/augsburg/de/events/event0', diff --git a/shared/api/types.ts b/shared/api/types.ts index a6e5193f7d..362e1ea69f 100644 --- a/shared/api/types.ts +++ b/shared/api/types.ts @@ -77,6 +77,7 @@ export type JsonChatMessageType = { id: number body: string user_is_author: boolean + automatic_answer: boolean } export type JsonChatMessagesType = { messages: JsonChatMessageType[] @@ -121,7 +122,7 @@ export type JsonEventType = { export type JsonTunewsType = { id: number title: string - tags: Array + tags: string[] date: string content: string enewsno: string diff --git a/shared/routes/InternalPathnameParser.ts b/shared/routes/InternalPathnameParser.ts index ffe2dab6a4..84253e36b6 100644 --- a/shared/routes/InternalPathnameParser.ts +++ b/shared/routes/InternalPathnameParser.ts @@ -20,7 +20,7 @@ import { parseQueryParams } from './query' const ENTITY_ID_INDEX = 3 class InternalPathnameParser { - _parts: Array + _parts: string[] _length: number _fallbackLanguageCode: string _fixedCity: string | null diff --git a/shared/routes/pathname.ts b/shared/routes/pathname.ts index ffca183e1d..0c67f0668c 100644 --- a/shared/routes/pathname.ts +++ b/shared/routes/pathname.ts @@ -20,7 +20,7 @@ type CityContentRouteUrlType = { path?: string | null | undefined } -const constructPathname = (parts: Array) => { +const constructPathname = (parts: (string | null | undefined)[]) => { const pathname = parts .filter(Boolean) .map(part => part?.toLowerCase()) diff --git a/translations/translations.json b/translations/translations.json index 88a7d5f045..0d5b90a26f 100644 --- a/translations/translations.json +++ b/translations/translations.json @@ -2338,8 +2338,10 @@ "notSupportedByDevice": "Diese Funktion wird auf diesem Gerät nicht unterstützt.", "languageSwitchFailedTitle": "Leider ist der Sprachwechsel fehlgeschlagen.", "languageSwitchFailedMessage": "Die ausgewählte Sprache ist nicht offline verfügbar. Eine aktive Internetverbinding ist notwendig.", + "permissionRequired": "Berechtigung erforderlich", "noCalendarPermission": "Der Zugriff auf den Kalender ist nicht erlaubt.", - "noCalendarFound": "Es wurde kein geeigneter Kalender gefunden." + "noCalendarFound": "Es wurde kein geeigneter Kalender gefunden.", + "settingsError": "Fehler beim Anwenden der Einstellungen" }, "am": { "notFound": { @@ -2618,8 +2620,10 @@ "notSupportedByDevice": "This function is not supported on this device.", "languageSwitchFailedTitle": "Unfortunately, changing the language failed.", "languageSwitchFailedMessage": "The selected language is not available if you are offline. An active internet connection is required.", + "permissionRequired": "Permission required", "noCalendarPermission": "Access to the calendar is not permitted.", - "noCalendarFound": "No suitable calendar was found." + "noCalendarFound": "No suitable calendar was found.", + "settingsError": "Failed to apply settings" }, "es": { "notFound": { @@ -3617,7 +3621,6 @@ "chooseCalendar": "Kalender-Auswahl", "added": "Hinzugefügt", "goToCalendar": "Zum Kalender", - "settings": "Einstellungen", "recurring": "wiederholend", "today": "heute", "todayRecurring": "heute, wiederholend", @@ -3647,7 +3650,6 @@ "chooseCalendar": "የቀን መቁጠሪያን ይምረጡ", "added": "ታክሏል", "goToCalendar": "ወደ ቀን መቁጠሪያ ይሂዱ", - "settings": "ቅንብሮች", "recurring": "ተደጋጋሚ", "today": "ዛሬ", "todayRecurring": "ዛሬ፣ ተደጋጋሚ", @@ -3677,7 +3679,6 @@ "chooseCalendar": "اختر تقويمًا", "added": "تمت الإضافة", "goToCalendar": "إلى التقويم", - "settings": "أوضاع الضبط", "recurring": "مكرر", "today": "اليوم", "todayRecurring": "اليوم، مكرر", @@ -3707,7 +3708,6 @@ "chooseCalendar": "Избери календар", "added": "Добавен", "goToCalendar": "Към календара", - "settings": "Настройки", "recurring": "повтарящ се", "today": "днес", "todayRecurring": "днес, повтарящ се", @@ -3737,7 +3737,6 @@ "chooseCalendar": "ساڵنامەیەک هەڵبژێرە", "added": "زیادکرا", "goToCalendar": "دەسپێگەیشتن بە ساڵنامە", - "settings": "ڕێکخستنەکان", "recurring": "دووپاتە", "today": "ئەمڕۆ", "todayRecurring": "ئەمڕۆ، دووپاتە", @@ -3767,7 +3766,6 @@ "chooseCalendar": "Výběr kalendáře", "added": "Přidáno", "goToCalendar": "Do kalendáře", - "settings": "Nastavení", "recurring": "opakování", "today": "dnes", "todayRecurring": "dnes, opakování", @@ -3797,7 +3795,6 @@ "chooseCalendar": "Valg af kalender", "added": "Tilføjet", "goToCalendar": "Til kalenderen", - "settings": "Indstillinger", "recurring": "gentagende", "today": "i dag", "todayRecurring": "i dag og gentagende", @@ -3827,7 +3824,6 @@ "chooseCalendar": "Επιλέξτε ένα ημερολόγιο", "added": "Προστέθηκε", "goToCalendar": "Μετάβαση στο ημερολόγιο", - "settings": "Ρυθμίσεις", "recurring": "επαναλαμβάνεται", "today": "σήμερα", "todayRecurring": "σήμερα, επαναλαμβάνεται", @@ -3857,7 +3853,6 @@ "chooseCalendar": "Calendar choice", "added": "Added", "goToCalendar": "Go to calendar", - "settings": "Settings", "recurring": "recurring", "today": "today", "todayRecurring": "today, recurring", @@ -3887,7 +3882,6 @@ "chooseCalendar": "Elije un calendario", "added": "Se añadió", "goToCalendar": "al calendario", - "settings": "Ajustes", "recurring": "repetitivo", "today": "hoy", "todayRecurring": "hoy, repetitivo", @@ -3917,7 +3911,6 @@ "chooseCalendar": "Valitse kalenteri", "added": "Lisätty", "goToCalendar": "Kalenteriin", - "settings": "Asetukset", "recurring": "toistuva", "today": "tänään", "todayRecurring": "tänään, toistuva", @@ -3947,7 +3940,6 @@ "chooseCalendar": "Choisis un calendrier", "added": "Ajouté", "goToCalendar": "Accéder au calendrier", - "settings": "Réglages", "recurring": "répétitif", "today": "aujourd’hui", "todayRecurring": "aujourd’hui, répétitif", @@ -3977,7 +3969,6 @@ "chooseCalendar": "Izaberi kalendar", "added": "Dodano", "goToCalendar": "Na kalendar", - "settings": "Postavke", "recurring": "ponavljajući", "today": "danas", "todayRecurring": "danas, ponavljajući", @@ -4007,7 +3998,6 @@ "chooseCalendar": "Válasszon ki egy naptárat", "added": "Hozzáadva", "goToCalendar": "A naptárhoz", - "settings": "Beállítások", "recurring": "ismétlődő", "today": "ma", "todayRecurring": "ma, ismétlődve", @@ -4037,7 +4027,6 @@ "chooseCalendar": "Seleziona un calendario", "added": "Aggiunto", "goToCalendar": "Al calendario", - "settings": "Impostazioni", "recurring": "ricorrente", "today": "oggi", "todayRecurring": "oggi, ricorrente", @@ -4067,7 +4056,6 @@ "chooseCalendar": "აირჩიე კალენდარი", "added": "დამატებულია", "goToCalendar": "კალენდარზე გადასვლა", - "settings": "პარამეტრები", "recurring": "განმეორებით", "today": "დღეს", "todayRecurring": "დღეს, განმეორებით", @@ -4097,7 +4085,6 @@ "chooseCalendar": "Rojhejmêrekê bibijêre", "added": "Zêdekirin", "goToCalendar": "Li rojhejmêrê", - "settings": "Eyarkirin", "recurring": "Dubarebûyî", "today": "Îro", "todayRecurring": "Îro, dubarebûyî", @@ -4127,7 +4114,6 @@ "chooseCalendar": "Избери календар", "added": "Додадено", "goToCalendar": "Кон календарот", - "settings": "Поставки", "recurring": "повторувачки", "today": "утре", "todayRecurring": "утре, повторувачки", @@ -4157,7 +4143,6 @@ "chooseCalendar": "Selecteer een kalender", "added": "Toegevoegd", "goToCalendar": "Naar de kalender", - "settings": "Instellingen", "recurring": "herhalend", "today": "vandaag", "todayRecurring": "vandaag, herhalend", @@ -4187,7 +4172,6 @@ "chooseCalendar": "Filannoo Kaalanderii", "added": "Dabalameera", "goToCalendar": "Gara kaalanderii", - "settings": "Saajoo", "recurring": "Irra deddeebii", "today": "Hara", "todayRecurring": "Irra deebii haraa", @@ -4217,7 +4201,6 @@ "chooseCalendar": "یک تقویم را انتخاب کنید", "added": "افزوده شد", "goToCalendar": "دسترسی به تقویم", - "settings": "تنظیمات", "recurring": "تکراری", "today": "امروز", "todayRecurring": "امروز، تکراری", @@ -4247,7 +4230,6 @@ "chooseCalendar": "Wybierz kalendarz", "added": "Dodano", "goToCalendar": "Do kalendarza", - "settings": "Ustawienia", "recurring": "powtarzający się", "today": "dzisiaj", "todayRecurring": "dzisiaj, powtarzający się", @@ -4277,7 +4259,6 @@ "chooseCalendar": "یک تقویم را انتخاب کنید", "added": "اضافه شد", "goToCalendar": "دسترسی به تقویم", - "settings": "تنظیمات", "recurring": "تکراری", "today": "امروز", "todayRecurring": "امروز، تکراری", @@ -4307,7 +4288,6 @@ "chooseCalendar": "يوه جنتري غوره کړئ", "added": "اضافه شو", "goToCalendar": "جنتري ته لاسرسی", - "settings": "تنظيمات", "recurring": "تکراري", "today": "نن ورځ", "todayRecurring": "نن ورځ، تکراري", @@ -4337,7 +4317,6 @@ "chooseCalendar": "Selecione um calendário", "added": "Adicionado", "goToCalendar": "Para o calendário", - "settings": "Definições", "recurring": "repetitivo", "today": "hoje", "todayRecurring": "hoje, repetitivo", @@ -4367,7 +4346,6 @@ "chooseCalendar": "Selectați un calendar", "added": "Adăugat", "goToCalendar": "La calendar", - "settings": "Setări", "recurring": "repetarea", "today": "astăzi", "todayRecurring": "astăzi, repetând", @@ -4397,7 +4375,6 @@ "chooseCalendar": "Выбери календарь", "added": "Добавлено", "goToCalendar": "К календарю", - "settings": "Настройки", "recurring": "периодически", "today": "сегодня", "todayRecurring": "сегодня, периодически", @@ -4427,7 +4404,6 @@ "chooseCalendar": "Výber kalendára", "added": "Pridané", "goToCalendar": "Ku kalendáru", - "settings": "Nastavenia", "recurring": "opakujúce sa", "today": "dnes", "todayRecurring": "dnes, opakujúce sa", @@ -4457,7 +4433,6 @@ "chooseCalendar": "Dooro jadwalka", "added": "Ku daray", "goToCalendar": "Jadwalka", - "settings": "Dejinta", "recurring": "Soo noqnoqda", "today": "Maanta", "todayRecurring": "Maanta, soo noqnoqonaysa", @@ -4487,7 +4462,6 @@ "chooseCalendar": "Zgjidh një kalendar", "added": "U shtua", "goToCalendar": "Shko te kalendari", - "settings": "Cilësimet", "recurring": "periodik", "today": "sot", "todayRecurring": "sot, periodik", @@ -4517,7 +4491,6 @@ "chooseCalendar": "Изабери календар", "added": "Додато", "goToCalendar": "На календар", - "settings": "Подешавања", "recurring": "понављајући", "today": "данас", "todayRecurring": "данас, понављајући", @@ -4547,7 +4520,6 @@ "chooseCalendar": "Izaberi kalendar", "added": "Dodato", "goToCalendar": "Na kalendar", - "settings": "Podešavanja", "recurring": "ponavljajući", "today": "danas", "todayRecurring": "danas, ponavljajući", @@ -4577,7 +4549,6 @@ "chooseCalendar": "ዓውደ ኣዋርሕ ምረጽ", "added": "ተወሰኸ", "goToCalendar": "ናብ ዓውደ ኣዋርሕ", - "settings": "ቅጥዕታት", "recurring": "ተደጋጋሚ", "today": "ሎምዓንቲ", "todayRecurring": "ሎሚ፣ ተደጋጋሚ", @@ -4607,7 +4578,6 @@ "chooseCalendar": "Bir takvim seç", "added": "Eklendi", "goToCalendar": "Takvime git", - "settings": "Ayarlar", "recurring": "tekrarlayan", "today": "bugün", "todayRecurring": "bugün, tekrarlayan", @@ -4637,7 +4607,6 @@ "chooseCalendar": "Виберіть календар", "added": "Додано", "goToCalendar": "До календаря", - "settings": "Налаштування", "recurring": "періодичні", "today": "сьогодні", "todayRecurring": "сьогодні, періодичні", @@ -4667,7 +4636,6 @@ "chooseCalendar": "ایک کیلنڈر منتخب کریں", "added": "شامل کیا گیا", "goToCalendar": "کیلنڈر پر جائیں", - "settings": "ترتیبات", "recurring": "مکرر", "today": "آج", "todayRecurring": "آج، مکرر", @@ -4697,7 +4665,6 @@ "chooseCalendar": "选择日历", "added": "添加到日历", "goToCalendar": "转到日历", - "settings": "设置", "recurring": "重复", "today": "今天", "todayRecurring": "今天,重复", diff --git a/version.json b/version.json index a308dcc617..d0a6272a38 100644 --- a/version.json +++ b/version.json @@ -1 +1 @@ -{"versionName":"2024.10.2","versionCode":100042399} \ No newline at end of file +{"versionName":"2024.11.1","versionCode":100042405} \ No newline at end of file diff --git a/web/src/assets/index.ts b/web/src/assets/index.ts index 406ccc500c..e45ff89a45 100644 --- a/web/src/assets/index.ts +++ b/web/src/assets/index.ts @@ -12,6 +12,8 @@ import CalendarTodayRecurringIcon from '../../../assets/icons/calendar-today-rec import CalendarTodayIcon from '../../../assets/icons/calendar-today.svg' import CalendarIcon from '../../../assets/icons/calendar.svg' import CategoriesIcon from '../../../assets/icons/categories.svg' +import ChatBot from '../../../assets/icons/chat-bot.svg' +import ChatPerson from '../../../assets/icons/chat-person.svg' import ChatIcon from '../../../assets/icons/chat.svg' import ClockIcon from '../../../assets/icons/clock.svg' import CloseIcon from '../../../assets/icons/close.svg' @@ -66,6 +68,8 @@ export { CalendarTodayIcon, CalendarTodayRecurringIcon, CategoriesIcon, + ChatPerson, + ChatBot, ChatIcon, ClockIcon, CloseIcon, diff --git a/web/src/components/Breadcrumbs.tsx b/web/src/components/Breadcrumbs.tsx index 33781a3aca..0b0d42f48e 100644 --- a/web/src/components/Breadcrumbs.tsx +++ b/web/src/components/Breadcrumbs.tsx @@ -48,7 +48,7 @@ const StyledLink = styled(Link)` ` type BreadcrumbsProps = { - ancestorBreadcrumbs: Array + ancestorBreadcrumbs: BreadcrumbModel[] currentBreadcrumb: BreadcrumbModel } diff --git a/web/src/components/ChatMessage.tsx b/web/src/components/ChatMessage.tsx index 06e328a322..4b494c6d01 100644 --- a/web/src/components/ChatMessage.tsx +++ b/web/src/components/ChatMessage.tsx @@ -1,3 +1,4 @@ +import { TFunction } from 'i18next' import React, { ReactElement } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' @@ -5,7 +6,7 @@ import styled from 'styled-components' import ChatMessageModel from 'shared/api/models/ChatMessageModel' -import buildConfig from '../constants/buildConfig' +import { ChatBot, ChatPerson } from '../assets' import RemoteContent from './RemoteContent' import Icon from './base/Icon' @@ -49,17 +50,22 @@ const Circle = styled.div` type ChatMessageProps = { message: ChatMessageModel; showIcon: boolean } +const getIcon = (userIsAuthor: boolean, isAutomaticAnswer: boolean, t: TFunction<'chat'>): ReactElement => { + if (userIsAuthor) { + return {t('user')} + } + const icon = isAutomaticAnswer ? ChatBot : ChatPerson + return +} + const ChatMessage = ({ message, showIcon }: ChatMessageProps): ReactElement => { - // TODO 2799 Check if Remote content is really needed here or how external links will be delivered via api - const { icons } = buildConfig() const navigate = useNavigate() const { t } = useTranslation('chat') - const { body, userIsAuthor } = message + const { body, userIsAuthor, isAutomaticAnswer } = message + return ( - - {userIsAuthor ? {t('user')} : } - + {getIcon(userIsAuthor, isAutomaticAnswer, t)} diff --git a/web/src/components/CityContentHeader.tsx b/web/src/components/CityContentHeader.tsx index 66d3eca111..6f2903cd32 100644 --- a/web/src/components/CityContentHeader.tsx +++ b/web/src/components/CityContentHeader.tsx @@ -27,7 +27,7 @@ type CityContentHeaderProps = { cityModel: CityModel route: RouteType languageCode: string - languageChangePaths: Array<{ code: string; path: string | null; name: string }> | null + languageChangePaths: { code: string; path: string | null; name: string }[] | null } const CityContentHeader = ({ @@ -80,7 +80,7 @@ const CityContentHeader = ({ />, ] - const getNavigationItems = (): Array => { + const getNavigationItems = (): ReactElement[] => { const isNewsVisible = buildConfig().featureFlags.newsStream && (localNewsEnabled || tunewsEnabled) const isEventsVisible = eventsEnabled const isPoisVisible = buildConfig().featureFlags.pois && poisEnabled @@ -90,7 +90,7 @@ const CityContentHeader = ({ return [] } - const items: Array = [ + const items: ReactElement[] = [ | null + languageChangePaths: { code: string; path: string | null; name: string }[] | null isLoading: boolean city: CityModel languageCode: string diff --git a/web/src/components/CitySelector.tsx b/web/src/components/CitySelector.tsx index c1f8657ab1..9a91146fa0 100644 --- a/web/src/components/CitySelector.tsx +++ b/web/src/components/CitySelector.tsx @@ -30,7 +30,7 @@ const SearchCounter = styled.p` ` type CitySelectorProps = { - cities: Array + cities: CityModel[] language: string } diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx index a8fde7d5db..1a548bd57a 100644 --- a/web/src/components/Footer.tsx +++ b/web/src/components/Footer.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components' import buildConfig from '../constants/buildConfig' type FooterProps = { - children: Array | ReactNode + children: ReactNode[] | ReactNode overlay?: boolean } diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index d1dd6608bf..630a8f5ca8 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -12,9 +12,9 @@ import KebabMenu from './KebabMenu' import NavigationBarScrollContainer from './NavigationBarScrollContainer' type HeaderProps = { - navigationItems: Array> - actionItems: Array - kebabItems: Array + navigationItems: ReactElement[] + actionItems: ReactNode[] + kebabItems: ReactNode[] logoHref: string cityName?: string cityCode?: string diff --git a/web/src/components/HeaderLanguageSelectorItem.tsx b/web/src/components/HeaderLanguageSelectorItem.tsx index 83cf215125..4d191297a6 100644 --- a/web/src/components/HeaderLanguageSelectorItem.tsx +++ b/web/src/components/HeaderLanguageSelectorItem.tsx @@ -9,7 +9,7 @@ import KebabActionItemDropDown from './KebabActionItemDropDown' import Selector from './Selector' type HeaderLanguageSelectorItemProps = { - selectorItems: Array + selectorItems: SelectorItemModel[] activeItemCode: string inKebabMenu?: boolean closeSidebar?: () => void diff --git a/web/src/components/Helmet.tsx b/web/src/components/Helmet.tsx index 12a1ebda7f..3ee63116e3 100644 --- a/web/src/components/Helmet.tsx +++ b/web/src/components/Helmet.tsx @@ -8,7 +8,7 @@ import buildConfig from '../constants/buildConfig' type HelmetProps = { pageTitle: string metaDescription?: string | null - languageChangePaths?: Array<{ code: string; path: string | null; name: string }> + languageChangePaths?: { code: string; path: string | null; name: string }[] rootPage?: boolean cityModel?: CityModel } diff --git a/web/src/components/JsonLdBreadcrumbs.tsx b/web/src/components/JsonLdBreadcrumbs.tsx index d1b365309d..28668fedc8 100644 --- a/web/src/components/JsonLdBreadcrumbs.tsx +++ b/web/src/components/JsonLdBreadcrumbs.tsx @@ -5,7 +5,7 @@ import { BreadcrumbList, WithContext } from 'schema-dts' import BreadcrumbModel from '../models/BreadcrumbModel' import { urlFromPath } from '../utils/stringUtils' -export const createJsonLd = (breadcrumbs: Array): WithContext => +export const createJsonLd = (breadcrumbs: BreadcrumbModel[]): WithContext => // https://developers.google.com/search/docs/data-types/breadcrumb ({ '@context': 'https://schema.org', @@ -19,7 +19,7 @@ export const createJsonLd = (breadcrumbs: Array): WithContext + breadcrumbs: BreadcrumbModel[] } const JsonLdBreadcrumbs = ({ breadcrumbs }: JsonLdBreadcrumbsProps): ReactElement => ( diff --git a/web/src/components/KebabMenu.tsx b/web/src/components/KebabMenu.tsx index fab9e5a5f3..360f2426ff 100644 --- a/web/src/components/KebabMenu.tsx +++ b/web/src/components/KebabMenu.tsx @@ -11,7 +11,7 @@ import Button from './base/Button' import Icon from './base/Icon' type KebabMenuProps = { - items: Array + items: ReactNode[] show: boolean setShow: (show: boolean) => void Footer: ReactNode diff --git a/web/src/components/LanguageFailure.tsx b/web/src/components/LanguageFailure.tsx index 71db17862f..1d8cfae8c1 100644 --- a/web/src/components/LanguageFailure.tsx +++ b/web/src/components/LanguageFailure.tsx @@ -15,7 +15,7 @@ const ChooseLanguage = styled.p` type LanguageFailureProps = { cityModel: CityModel languageCode: string - languageChangePaths: Array<{ code: string; path: string | null; name: string }> + languageChangePaths: { code: string; path: string | null; name: string }[] } const LanguageFailure = ({ cityModel, languageCode, languageChangePaths }: LanguageFailureProps): ReactElement => { diff --git a/web/src/components/LanguageSelector.tsx b/web/src/components/LanguageSelector.tsx index 51e2e9e906..3b9c78052c 100644 --- a/web/src/components/LanguageSelector.tsx +++ b/web/src/components/LanguageSelector.tsx @@ -8,7 +8,7 @@ import HeaderLanguageSelectorItem from './HeaderLanguageSelectorItem' type LanguageSelectorProps = { languageCode: string isHeaderActionItem: boolean - languageChangePaths: Array<{ code: string; path: string | null; name: string }> | null + languageChangePaths: { code: string; path: string | null; name: string }[] | null inKebabMenu?: boolean closeSidebar?: () => void } diff --git a/web/src/components/List.tsx b/web/src/components/List.tsx index 78237a361e..51ba08a9a0 100644 --- a/web/src/components/List.tsx +++ b/web/src/components/List.tsx @@ -11,7 +11,7 @@ const NoItemsMessage = styled.div` ` type ListProps = { - items: Array + items: T[] noItemsMessage: string renderItem: (item: T) => ReactNode borderless?: boolean diff --git a/web/src/components/LocalNewsList.tsx b/web/src/components/LocalNewsList.tsx index 3eddae65f9..1d9cedd9a5 100644 --- a/web/src/components/LocalNewsList.tsx +++ b/web/src/components/LocalNewsList.tsx @@ -17,7 +17,7 @@ const Wrapper = styled.div` ` type LocalNewsListProps = { - items: Array + items: LocalNewsModel[] noItemsMessage: string renderItem: (item: LocalNewsModel, city: string) => ReactNode city: string diff --git a/web/src/components/Note.tsx b/web/src/components/Note.tsx index db1030528e..9105cae392 100644 --- a/web/src/components/Note.tsx +++ b/web/src/components/Note.tsx @@ -6,7 +6,7 @@ import Icon from './base/Icon' const NoteContainer = styled.div` display: flex; - background-color: ${props => props.theme.colors.themeColor}; + background-color: ${props => props.theme.colors.warningColor}; padding: 12px; gap: 12px; align-items: center; diff --git a/web/src/components/Selector.tsx b/web/src/components/Selector.tsx index 318822d07d..3b45961887 100644 --- a/web/src/components/Selector.tsx +++ b/web/src/components/Selector.tsx @@ -72,7 +72,7 @@ const Wrapper = styled.div<{ $vertical: boolean }>` type SelectorProps = { verticalLayout: boolean closeDropDown?: () => void - items: Array + items: SelectorItemModel[] activeItemCode?: string disabledItemTooltip: string } diff --git a/web/src/components/Tiles.tsx b/web/src/components/Tiles.tsx index f066774b25..4886cf5364 100644 --- a/web/src/components/Tiles.tsx +++ b/web/src/components/Tiles.tsx @@ -23,7 +23,7 @@ const TilesRow = styled.div` type TilesProps = { title: string | null - tiles: Array + tiles: TileModel[] } const Tiles = ({ title, tiles }: TilesProps): ReactElement => ( diff --git a/web/src/components/__tests__/ChatConversation.spec.tsx b/web/src/components/__tests__/ChatConversation.spec.tsx index 995bb8d3c6..d104569d34 100644 --- a/web/src/components/__tests__/ChatConversation.spec.tsx +++ b/web/src/components/__tests__/ChatConversation.spec.tsx @@ -17,11 +17,13 @@ describe('ChatConversation', () => { id: 1, body: 'Meine Frage lautet, warum bei Integreat eigentlich alles gelb ist. Weitere Infos', userIsAuthor: true, + automaticAnswer: false, }), new ChatMessageModel({ id: 2, body: 'Informationen zu Ihrer Frage finden Sie auf folgenden Seiten:', userIsAuthor: false, + automaticAnswer: false, }), ] diff --git a/web/src/components/base/Icon.tsx b/web/src/components/base/Icon.tsx index 71d3f1ad9b..a6dda65ca0 100644 --- a/web/src/components/base/Icon.tsx +++ b/web/src/components/base/Icon.tsx @@ -10,6 +10,8 @@ const StyledIcon = styled(SVG)<{ $directionDependent: boolean; $reverse: boolean color: ${props => props.theme.colors.textColor}; width: 24px; height: 24px; + + --theme-color: ${props => props.theme.colors.themeColor}; ` type IconProps = { diff --git a/web/src/hooks/__tests__/useLocalStorage.spec.tsx b/web/src/hooks/__tests__/useLocalStorage.spec.tsx new file mode 100644 index 0000000000..71cc7a926e --- /dev/null +++ b/web/src/hooks/__tests__/useLocalStorage.spec.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render } from '@testing-library/react' +import React from 'react' + +import Button from '../../components/base/Button' +import useLocalStorage from '../useLocalStorage' + +describe('useLocalStorage', () => { + const key = 'my_storage_key' + const MockComponent = () => { + const { value, updateLocalStorageItem } = useLocalStorage({ key, initialValue: 0 }) + return ( +
+ {value} + +
+ ) + } + + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + }) + + it('should correctly set initial value and update value', () => { + const { getByText } = render() + + expect(getByText(0)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('0') + + fireEvent.click(getByText('Increment')) + + expect(getByText(1)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('1') + + fireEvent.click(getByText('Increment')) + fireEvent.click(getByText('Increment')) + fireEvent.click(getByText('Increment')) + + expect(getByText(4)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('4') + }) + + it('should not use initial value if already set', () => { + localStorage.setItem(key, '10') + const { getByText } = render() + + expect(getByText(10)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('10') + + fireEvent.click(getByText('Increment')) + + expect(getByText(11)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('11') + }) + + it('should continue to work even if local storage is not usable', () => { + localStorage.getItem = () => { + throw new Error('SecurityError') + } + localStorage.setItem = () => { + throw new Error('SecurityError') + } + const { getByText } = render() + + expect(getByText(0)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('0') + + fireEvent.click(getByText('Increment')) + + expect(getByText(1)).toBeTruthy() + expect(localStorage.getItem(key)).toBe('1') + }) +}) diff --git a/web/src/hooks/useLocalStorage.ts b/web/src/hooks/useLocalStorage.ts index 24e861697c..cb3d20abdd 100644 --- a/web/src/hooks/useLocalStorage.ts +++ b/web/src/hooks/useLocalStorage.ts @@ -1,5 +1,7 @@ import { useState, useCallback } from 'react' +import { reportError } from '../utils/sentry' + type UseLocalStorageProps = { key: string initialValue: T @@ -12,17 +14,35 @@ type UseLocalStorageReturn = { const useLocalStorage = ({ key, initialValue }: UseLocalStorageProps): UseLocalStorageReturn => { const [value, setValue] = useState(() => { - const localStorageItem = localStorage.getItem(key) - if (localStorageItem) { - return JSON.parse(localStorageItem) + try { + const localStorageItem = localStorage.getItem(key) + if (localStorageItem) { + return JSON.parse(localStorageItem) + } + localStorage.setItem(key, JSON.stringify(initialValue)) + } catch (e) { + // Prevent the following error crashing the app if the browser blocks access to local storage (see #2924) + // SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document. + const accessDenied = e instanceof Error && e.message.includes('Access is denied for this document') + if (!accessDenied) { + reportError(e) + } } - localStorage.setItem(key, JSON.stringify(initialValue)) return initialValue }) const updateLocalStorageItem = useCallback( (newValue: T) => { - localStorage.setItem(key, JSON.stringify(newValue)) + try { + localStorage.setItem(key, JSON.stringify(newValue)) + } catch (e) { + // Prevent the following error crashing the app if the browser blocks access to local storage (see #2924) + // SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document. + const accessDenied = e instanceof Error && e.message.includes('Access is denied for this document') + if (!accessDenied) { + reportError(e) + } + } setValue(newValue) }, [key], diff --git a/yarn.lock b/yarn.lock index 3f5b3d6971..591bdd3374 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9738,9 +9738,9 @@ http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.2: debug "^4.3.4" http-proxy-middleware@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" - integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw== + version "2.0.7" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" + integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1"