diff --git a/assets/icons/uiIcons/favorite.svg b/assets/icons/uiIcons/favorite.svg new file mode 100644 index 0000000000..2e4f176af0 --- /dev/null +++ b/assets/icons/uiIcons/favorite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/modules.ts b/src/app/modules.ts index 366617b8de..d6ae464652 100755 --- a/src/app/modules.ts +++ b/src/app/modules.ts @@ -9,7 +9,7 @@ import { loadModules, ModuleArray, NavigableModule, - NavigableModuleArray + NavigableModuleArray, } from '~/framework/util/moduleTool'; // We first imports all modules and their code hierarchy. Registrations are executed, @@ -26,7 +26,7 @@ export default () => { require('~/framework/modules/timeline'), require('~/framework/modules/audience').default, require('~/framework/modules/explorer').default, - + require('~/framework/modules/myapps'), // Included modules from override ...(IncludedModules || []), diff --git a/src/app/store.tsx b/src/app/store.tsx index dff2d33ed5..ea838cfb43 100755 --- a/src/app/store.tsx +++ b/src/app/store.tsx @@ -13,8 +13,8 @@ import * as React from 'react'; import { connect } from 'react-redux'; -import { Action, applyMiddleware, combineReducers, compose, createStore, Reducer, Store } from 'redux'; -import { thunk } from 'redux-thunk'; +import { Action, applyMiddleware, combineReducers, compose, createStore, Reducer, Store, UnknownAction } from 'redux'; +import { thunk, ThunkDispatch } from 'redux-thunk'; declare var window: any; @@ -64,6 +64,7 @@ export function createMainStore() { } export type IGlobalState = any; // Todo: Make any TS logic that can get composed state from module definitions IF POSSIBLE +export type AppDispatch = ThunkDispatch; /** === Store getter === */ diff --git a/src/framework/components/picture/svg/index.tsx b/src/framework/components/picture/svg/index.tsx index ffa871e1db..ad051a51aa 100755 --- a/src/framework/components/picture/svg/index.tsx +++ b/src/framework/components/picture/svg/index.tsx @@ -188,6 +188,7 @@ const imports = { 'ui-externalLink': async () => import('ASSETS/icons/uiIcons/externalLink.svg'), 'ui-eye': async () => import('ASSETS/icons/uiIcons/eye.svg'), 'ui-eyeSlash': async () => import('ASSETS/icons/uiIcons/eyeSlash.svg'), + 'ui-favorite': async () => import('ASSETS/icons/uiIcons/favorite.svg'), 'ui-filter': async () => import('ASSETS/icons/uiIcons/filter.svg'), 'ui-flag': async () => import('ASSETS/icons/uiIcons/flag.svg'), 'ui-folder': async () => import('ASSETS/icons/uiIcons/folder.svg'), diff --git a/src/framework/modules/myAppMenu/components/my-apps-card/component.tsx b/src/framework/modules/myAppMenu/components/my-apps-card/component.tsx new file mode 100644 index 0000000000..ebf3fd0356 --- /dev/null +++ b/src/framework/modules/myAppMenu/components/my-apps-card/component.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable, TouchableOpacity, View } from 'react-native'; + +import { MyAppsCardProps } from './types'; +import { useStyles } from './useStyles'; + +import { UI_SIZES } from '~/framework/components/constants'; +import { Svg } from '~/framework/components/picture'; +import { BodyText } from '~/framework/components/text'; +import { Image } from '~/framework/util/media-deprecated'; + +const HTTP_REGEX: RegExp = /^https?:\/\//; + +export const MyAppsCard = ({ app, onLongPress, onPress }: MyAppsCardProps) => { + const styles = useStyles(app); + const isImageDistant = (icon: string): boolean => HTTP_REGEX.test(icon) || icon.startsWith('/workspace/'); + const icon = app.icon; + + const svgIconName = useMemo(() => { + if (!icon) return null; + if (isImageDistant(icon)) return null; + + return icon.replace(/-large$/, ''); //might be replaced in the future + }, [icon]); + + const isImageIcon = !!icon && !svgIconName; + + const isWebApp = useMemo(() => { + if (app.type === 'connector') return true; + if (app.target === '_blank') return true; + if (!app.address) return false; + + return HTTP_REGEX.test(app.address) || app.address.includes('#') || app.address.startsWith('/pages#'); + }, [app]); + + console.debug('APP_INFOS', { + DN: app.displayName, + iconNormalized: svgIconName, + isImageIcon, + isWebApp, + ...app, + }); + + const renderIcon = useCallback(() => { + if (!icon) return null; + + if (svgIconName) { + return ; + } + + return ; + }, [icon, svgIconName, styles.image]); + + const renderFavoriteBadge = useCallback(() => { + if (!app.isFavorite) return null; + + return ( + + + + ); + }, [app.isFavorite, styles.favoriteIcon]); + + return ( + + + {renderFavoriteBadge()} + {renderIcon()} + + + + + {app.displayName} + + + {isWebApp && ( + + + + )} + + + ); +}; diff --git a/src/framework/modules/myAppMenu/components/my-apps-card/index.ts b/src/framework/modules/myAppMenu/components/my-apps-card/index.ts new file mode 100644 index 0000000000..f5bfd29d48 --- /dev/null +++ b/src/framework/modules/myAppMenu/components/my-apps-card/index.ts @@ -0,0 +1,3 @@ +import { MyAppsCard } from './component'; + +export default MyAppsCard; diff --git a/src/framework/modules/myAppMenu/components/my-apps-card/types.ts b/src/framework/modules/myAppMenu/components/my-apps-card/types.ts new file mode 100644 index 0000000000..8d518233c1 --- /dev/null +++ b/src/framework/modules/myAppMenu/components/my-apps-card/types.ts @@ -0,0 +1,7 @@ +import { AppsInfoAggregated } from '~/framework/modules/myapps/types'; + +export type MyAppsCardProps = { + app: AppsInfoAggregated; + onPress?: () => void; + onLongPress?: () => void; +}; diff --git a/src/framework/modules/myAppMenu/components/my-apps-card/useStyles.ts b/src/framework/modules/myAppMenu/components/my-apps-card/useStyles.ts new file mode 100644 index 0000000000..dc22e65a9a --- /dev/null +++ b/src/framework/modules/myAppMenu/components/my-apps-card/useStyles.ts @@ -0,0 +1,60 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import theme from '~/app/theme'; +import { getScaleWidth, UI_SIZES } from '~/framework/components/constants'; +import { AppsInfoAggregated } from '~/framework/modules/myapps/types'; + +export const useStyles = (app: AppsInfoAggregated) => { + const appColor = app.color; + const themeMainColor = theme.palette.complementary; + const backgroundColor = appColor && themeMainColor[appColor] ? themeMainColor[appColor].regular : undefined; + + const styles = React.useMemo( + () => + StyleSheet.create({ + card: { + alignItems: 'center', + backgroundColor, + borderColor: theme.palette.grey.cloudy, + borderRadius: UI_SIZES.radius.newCard, + borderWidth: getScaleWidth(0.85), + height: getScaleWidth(120), + justifyContent: 'center', + // overflow: 'visible', + position: 'relative', + width: getScaleWidth(120), + }, + favoriteIcon: { + left: -getScaleWidth(20), + padding: UI_SIZES.spacing.tiny, + position: 'absolute', + top: -UI_SIZES.spacing.medium, + zIndex: UI_SIZES.spacing.tinyExtra, + }, + image: { + borderRadius: UI_SIZES.radius.newCard, + height: '100%', + objectFit: 'fill', + width: '100%', + }, + title: { + textAlign: 'center', + }, + titleRow: { + alignContent: 'space-between', + alignItems: 'center', + flexDirection: 'row', + gap: UI_SIZES.spacing.tiny, + marginTop: UI_SIZES.spacing.small, + padding: 0, + }, + wrapper: { + alignItems: 'center', + }, + }), + [backgroundColor], + ); + + return styles; +}; diff --git a/src/framework/modules/myAppMenu/screens/MyAppsHomeScreen.tsx b/src/framework/modules/myAppMenu/screens/MyAppsHomeScreen.tsx index ba4e5dbf86..73c5f3ea34 100755 --- a/src/framework/modules/myAppMenu/screens/MyAppsHomeScreen.tsx +++ b/src/framework/modules/myAppMenu/screens/MyAppsHomeScreen.tsx @@ -3,7 +3,10 @@ import { StyleSheet, View } from 'react-native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import MyAppsCard from '../components/my-apps-card'; + import { I18n } from '~/app/i18n'; +import { getStore } from '~/app/store'; import SecondaryButton from '~/framework/components/buttons/secondary'; import { TouchableSelectorPictureCard } from '~/framework/components/card/pictureCard'; import { UI_SIZES } from '~/framework/components/constants'; @@ -14,6 +17,8 @@ import ScrollView from '~/framework/components/scrollView'; import { HeadingSText } from '~/framework/components/text'; import OtherModuleElement from '~/framework/modules/myAppMenu/components/other-module'; import { IMyAppsNavigationParams, myAppsRouteNames } from '~/framework/modules/myAppMenu/navigation'; +import { selectAggregatedApps } from '~/framework/modules/myapps/reducer'; +import { AppsInfoAggregated } from '~/framework/modules/myapps/types'; import { AnyNavigableModule, NavigableModuleArray } from '~/framework/util/moduleTool'; import { isEmpty } from '~/framework/util/object'; @@ -37,6 +42,39 @@ const styles = StyleSheet.create({ }); const MyAppsHomeScreen = (props: MyAppsHomeScreenProps) => { + const [apps, setApps] = React.useState([]); + React.useEffect(() => { + const store = getStore(); + + const updateApps = () => { + const state = store.getState(); + const aggregatedApps = selectAggregatedApps(state); + setApps(aggregatedApps); + }; + + updateApps(); + const unsubscribe = store.subscribe(updateApps); + return unsubscribe; + }, []); + + const renderNewMyAppsGrid = () => { + return ( + item.name} + gap={UI_SIZES.spacing.big} + gapOutside={UI_SIZES.spacing.big} + renderItem={({ item }) => ( + console.debug('PRESS', item.name)} + onLongPress={() => console.debug('LONG PRESS', item.name)} + /> + )} + /> + ); + }; + const renderGrid = () => { const allModules = (props.modules ?? [])?.sort((a, b) => I18n.get(a.config.displayI18n).localeCompare(I18n.get(b.config.displayI18n)), @@ -115,6 +153,8 @@ const MyAppsHomeScreen = (props: MyAppsHomeScreenProps) => { return ( + {renderNewMyAppsGrid()} + {renderGrid()} {renderOtherModules()} diff --git a/src/framework/modules/myapps/apply-apps-to-modules.ts b/src/framework/modules/myapps/apply-apps-to-modules.ts new file mode 100644 index 0000000000..54d795b2ed --- /dev/null +++ b/src/framework/modules/myapps/apply-apps-to-modules.ts @@ -0,0 +1,56 @@ +import { AppsInfo } from './types'; + +import { PictureProps } from '~/framework/components/picture'; +import { + AnyNavigableModule, + AnyNavigableModuleConfig, + dynamiclyRegisterModules, + INavigableModuleConfigDeclaration, + NavigableModuleArray, +} from '~/framework/util/moduleTool'; + +function buildDisplayPicture(app: AppsInfo): PictureProps | undefined { + if (!app.icon) return undefined; + + if (app.icon.startsWith('http') || app.icon.startsWith('/')) { + return { source: { uri: app.icon }, type: 'Image' } as const; + } + + return { name: app.icon, type: 'Icon' } as const; +} + +export function applyAppsToModules(modules: NavigableModuleArray, apps: AppsInfo[]) { + const appsByName = new Map(apps.map(a => [a.name, a])); + + modules.forEach(module => { + const app = appsByName.get(module.config.name); + const config = module.config as AnyNavigableModuleConfig; + + const navConfig = config as { + assignValues: (values: Partial>) => void; + }; + + if (!app?.display) { + navConfig.assignValues({ displayAs: undefined }); + return; + } + + let displayAs; + + if (app.type === 'connector') { + displayAs = 'myAppsConnector'; + } else if (app.isMobile) { + displayAs = 'myAppsMobileModule'; + } else { + displayAs = 'myAppsWebModule'; + } + + navConfig.assignValues({ + displayAs, + displayI18n: app.displayName, + displayPicture: buildDisplayPicture(app), + }); + }); + + dynamiclyRegisterModules(modules); +} diff --git a/src/framework/modules/myapps/build-apps-info.ts b/src/framework/modules/myapps/build-apps-info.ts new file mode 100644 index 0000000000..856a8323e9 --- /dev/null +++ b/src/framework/modules/myapps/build-apps-info.ts @@ -0,0 +1,37 @@ +import { AppBookmarks, AppsInfo, AppType } from './types'; + +import { IEntcoreApp } from '~/framework/util/moduleTool'; + +/** + * type: + * - connector: external or non-integrated apps (blank, http, no prefix, hash routing, external flag, _blank) + * - application: default + * isMobile is resolved later (needs loaded modules) + */ +export function buildAppsInfo(entcoreApps: IEntcoreApp[], favorites: AppBookmarks): Omit[] { + return entcoreApps.map(app => { + const beginsWithHttp = /^https?:\/\//i; + + const isConnector = + app.target === '_blank' || + beginsWithHttp.test(app.address) || + app.address.includes('#/') || + !app.prefix || + app.isExternal === true; + + const type: AppType = isConnector ? 'connector' : 'application'; + + return { + address: app.address, + display: app.display, + displayName: app.displayName, + icon: app.icon, + isFavorite: favorites.bookmarks.includes(app.name), + isPinned: favorites.applications.includes(app.name), + name: app.name, + prefix: app.prefix, + target: app.target ?? undefined, + type, + }; + }); +} diff --git a/src/framework/modules/myapps/index.ts b/src/framework/modules/myapps/index.ts new file mode 100644 index 0000000000..ee76435f7a --- /dev/null +++ b/src/framework/modules/myapps/index.ts @@ -0,0 +1,13 @@ +import { Action } from 'redux'; + +import './init'; +import config from './module-config'; +import reducer from './reducer/reducer'; +import { AppsInfoState } from './types'; + +import { Module } from '~/framework/util/moduleTool'; + +module.exports = new Module<'myapps', typeof config, AppsInfoState, Action>({ + config, + reducer, +}); diff --git a/src/framework/modules/myapps/init.ts b/src/framework/modules/myapps/init.ts new file mode 100644 index 0000000000..95470bcaa1 --- /dev/null +++ b/src/framework/modules/myapps/init.ts @@ -0,0 +1,11 @@ +import { initMesAppliAtLogin } from './reducer'; + +import { AppDispatch, getStore } from '~/app/store'; +import { callAtLogin } from '~/framework/modules/auth/calls-at-login'; + +callAtLogin(() => { + const store = getStore(); + const dispatch = store.dispatch as AppDispatch; + + dispatch(initMesAppliAtLogin()); +}); diff --git a/src/framework/modules/myapps/module-config.ts b/src/framework/modules/myapps/module-config.ts new file mode 100644 index 0000000000..9d8ec8f833 --- /dev/null +++ b/src/framework/modules/myapps/module-config.ts @@ -0,0 +1,13 @@ +import type { AppsInfoState } from './types'; + +import { ModuleConfig } from '~/framework/util/moduleTool'; + +export default new ModuleConfig<'myapps', AppsInfoState>({ + entcoreScope: [], + hasRight: () => true, + matchEntcoreApp: () => true, + + matchEntcoreWidget: () => false, + name: 'myapps', + storageName: 'myapps', +}); diff --git a/src/framework/modules/myapps/reducer/actions.ts b/src/framework/modules/myapps/reducer/actions.ts new file mode 100644 index 0000000000..466ee78197 --- /dev/null +++ b/src/framework/modules/myapps/reducer/actions.ts @@ -0,0 +1,81 @@ +import { Action, UnknownAction } from 'redux'; +import { ThunkAction } from 'redux-thunk'; + +import { setUpModulesAccess } from '~/app/modules'; +import { IGlobalState } from '~/app/store'; +import { assertSession, getSession } from '~/framework/modules/auth/reducer'; +import { applyAppsToModules } from '~/framework/modules/myapps/apply-apps-to-modules'; +import { buildAppsInfo } from '~/framework/modules/myapps/build-apps-info'; +import moduleConfig from '~/framework/modules/myapps/module-config'; +import { myAppsService } from '~/framework/modules/myapps/service'; +import { ApplicationsConfig, AppsInfo, AppsInfoActionPayloads } from '~/framework/modules/myapps/types'; +import { AnyNavigableModule, IEntcoreApp, NavigableModule, NavigableModuleArray } from '~/framework/util/moduleTool'; + +export interface FetchStartAction extends Action { + type: typeof appsInfoActionTypes.fetchStart; +} +export interface FetchSuccessAction extends Action { + type: typeof appsInfoActionTypes.fetchSuccess; + payload: AppsInfoActionPayloads['fetchSuccess']; +} +export interface FetchErrorAction extends Action { + type: typeof appsInfoActionTypes.fetchError; + error: string; +} + +export const appsInfoActionTypes = { + fetchError: moduleConfig.namespaceActionType('FETCH_ERROR'), + fetchStart: moduleConfig.namespaceActionType('FETCH_START'), + fetchSuccess: moduleConfig.namespaceActionType('FETCH_SUCCESS'), +}; + +export const appInfoActions = { + fetchError: (error: string) => ({ error, type: appsInfoActionTypes.fetchError }), + fetchStart: () => ({ type: appsInfoActionTypes.fetchStart }), + fetchSuccess: (payload: { appsInfo: AppsInfo[]; appsConfig: ApplicationsConfig[]; entcoreApps: IEntcoreApp[] }) => ({ + payload, + type: appsInfoActionTypes.fetchSuccess, + }), +}; + +export const afterLoginSetup = + (session): ThunkAction, IGlobalState, unknown, UnknownAction> => + async dispatch => { + dispatch(appInfoActions.fetchStart()); + + try { + const modules = setUpModulesAccess(session) as AnyNavigableModule[]; + const navigableModules = new NavigableModuleArray(...modules.filter(m => m instanceof NavigableModule)); + + const [entcoreApps, appsConfig, bookmarks] = await Promise.all([ + myAppsService.list(session), + myAppsService.config(session), + myAppsService.bookmarks(session), + ]); + + const baseAppsInfo = buildAppsInfo(entcoreApps, bookmarks); + + const appsInfo: AppsInfo[] = baseAppsInfo.map(app => { + const entcoreApp = entcoreApps.find(e => e.name === app.name); + const isMobile = + app.type === 'application' && + !!entcoreApp && + navigableModules.some(m => m.config.matchEntcoreApp?.(entcoreApp, entcoreApps)); + + return { ...app, isMobile }; + }); + + dispatch(appInfoActions.fetchSuccess({ appsConfig, appsInfo, entcoreApps })); + + applyAppsToModules(navigableModules, appsInfo); + } catch (e) { + console.error('[afterLoginSetup] ERROR', e); + dispatch(appInfoActions.fetchError('APPS_FETCH_ERROR')); + } + }; + +export const initMesAppliAtLogin = (): ThunkAction, IGlobalState, unknown, UnknownAction> => async dispatch => { + const session = getSession(); + if (!session) return; + await dispatch(afterLoginSetup(assertSession())); +}; diff --git a/src/framework/modules/myapps/reducer/index.ts b/src/framework/modules/myapps/reducer/index.ts new file mode 100644 index 0000000000..b9c396b0de --- /dev/null +++ b/src/framework/modules/myapps/reducer/index.ts @@ -0,0 +1,3 @@ +export * from './actions'; +export * from './reducer'; +export * from './selectors'; diff --git a/src/framework/modules/myapps/reducer/reducer.ts b/src/framework/modules/myapps/reducer/reducer.ts new file mode 100644 index 0000000000..227176b520 --- /dev/null +++ b/src/framework/modules/myapps/reducer/reducer.ts @@ -0,0 +1,49 @@ +import { appsInfoActionTypes, FetchErrorAction, FetchSuccessAction } from './actions'; + +import { Reducers } from '~/app/store'; +import moduleConfig from '~/framework/modules/myapps/module-config'; +import { AppsInfoState } from '~/framework/modules/myapps/types'; +import createReducer from '~/framework/util/redux/reducerFactory'; + +export const appsInfoInitialState: AppsInfoState = { + appsConfig: [], + appsInfo: [], + entcoreApps: [], + loading: false, +}; + +const reducer = createReducer(appsInfoInitialState, { + [appsInfoActionTypes.fetchStart]: state => { + return { + ...state, + error: undefined, + loading: true, + }; + }, + + [appsInfoActionTypes.fetchSuccess]: (state, action) => { + const { appsConfig, appsInfo, entcoreApps } = (action as unknown as FetchSuccessAction).payload; + + return { + ...state, + appsConfig, + appsInfo, + entcoreApps, + loading: false, + }; + }, + + [appsInfoActionTypes.fetchError]: (state, action) => { + const { error } = action as unknown as FetchErrorAction; + + return { + ...state, + error, + loading: false, + }; + }, +}); + +Reducers.register(moduleConfig.reducerName, reducer); + +export default reducer; diff --git a/src/framework/modules/myapps/reducer/selectors.ts b/src/framework/modules/myapps/reducer/selectors.ts new file mode 100644 index 0000000000..e78cba8a52 --- /dev/null +++ b/src/framework/modules/myapps/reducer/selectors.ts @@ -0,0 +1,40 @@ +import { IGlobalState } from '~/app/store'; +import moduleConfig from '~/framework/modules/myapps/module-config'; +import { appsInfoInitialState } from '~/framework/modules/myapps/reducer/reducer'; +import { AppsInfoAggregated } from '~/framework/modules/myapps/types'; + +export const selectAppsState = (state: IGlobalState) => { + return moduleConfig.getState(state) ?? appsInfoInitialState; +}; + +export const selectAggregatedApps = (state: IGlobalState): AppsInfoAggregated[] => { + const slice = selectAppsState(state); + const appsConfig = slice?.appsConfig ?? []; + const appsInfo = slice?.appsInfo ?? []; + + const configByName = new Map(appsConfig.map(c => [c.name, c])); + + return appsInfo + .map(app => { + const config = configByName.get(app.name); + + return { + ...app, + category: config?.category, + color: config?.color, + help: config?.help, + libraries: config?.libraries, + }; + }) + .sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name)) + .filter(app => app.display); +}; + +export const selectAppsRaw = (state: IGlobalState) => { + const slice = selectAppsState(state); + return { + appsConfig: slice.appsConfig, + appsInfo: slice.appsInfo, + entcoreApps: slice.entcoreApps, + }; +}; diff --git a/src/framework/modules/myapps/service/index.ts b/src/framework/modules/myapps/service/index.ts new file mode 100644 index 0000000000..0c18a8999f --- /dev/null +++ b/src/framework/modules/myapps/service/index.ts @@ -0,0 +1,36 @@ +import { AuthActiveAccount } from '~/framework/modules/auth/model'; +import { AppBookmarks, ApplicationsConfig, ApplicationsList } from '~/framework/modules/myapps/types'; +import { IEntcoreApp } from '~/framework/util/moduleTool'; +import { signedFetch } from '~/infra/fetchWithCache'; + +const adaptApplication = (app: ApplicationsList): IEntcoreApp => ({ + address: app.address, + casType: app.casType ?? undefined, + display: app.display, + displayName: app.displayName, + icon: app.icon, + isExternal: app.isExternal, + name: app.name, + prefix: app.prefix ?? undefined, + target: app.target ?? undefined, +}); + +export const myAppsService = { + bookmarks: async (session: AuthActiveAccount): Promise => { + const res = await signedFetch(`${session.platform.url}/userbook/preference/apps`); + const json = await res.json(); + return JSON.parse(json.preference); + }, + + config: async (session: AuthActiveAccount): Promise => { + const res = await signedFetch(`${session.platform.url}/myApps/config`); + return res.json(); + }, + + list: async (session: AuthActiveAccount): Promise => { + const res = await signedFetch(`${session.platform.url}/applications-list`); + const json = await res.json(); + const apps: ApplicationsList[] = Array.isArray(json) ? json : (json.applications ?? json.apps ?? []); + return apps.map(adaptApplication); + }, +}; diff --git a/src/framework/modules/myapps/types.ts b/src/framework/modules/myapps/types.ts new file mode 100644 index 0000000000..d19af418cc --- /dev/null +++ b/src/framework/modules/myapps/types.ts @@ -0,0 +1,70 @@ +import { IEntcoreApp } from '~/framework/util/moduleTool'; + +export type AppType = 'application' | 'connector'; + +export interface AppsInfo extends Partial> { + name: string; + displayName: string; + display: boolean; + address: string; + target?: string; + prefix?: string; + icon?: string; + type: AppType; + isMobile: boolean; + isFavorite: boolean; + isPinned: boolean; +} + +export interface ApplicationsConfig { + category: string; + color: string; + help: Record; + libraries: string; + name: string; +} + +export interface AppBookmarks { + applications: string[]; + bookmarks: string[]; +} + +export interface ApplicationsList { + address: string; + casType: string | null; + display: boolean; + displayName: string; + icon: string; + isExternal: boolean; + name: string; + prefix: string; + scope: string[]; + target: string | null; +} + +export interface AppsInfoState { + appsInfo: AppsInfo[]; + appsConfig: ApplicationsConfig[]; + entcoreApps: IEntcoreApp[]; + loading: boolean; + error?: string; +} + +export interface AppsInfoActionPayloads { + fetchStart: undefined; + fetchSuccess: { + appsInfo: AppsInfo[]; + appsConfig: ApplicationsConfig[]; + entcoreApps: IEntcoreApp[]; + }; + fetchError: { + error: string; + }; +} + +export interface AppsInfoAggregated extends AppsInfo { + category?: string; + color?: string; + help?: Record; + libraries?: string; +} diff --git a/src/framework/modules/timeline/screens/timeline-screen.tsx b/src/framework/modules/timeline/screens/timeline-screen.tsx index 0e2803dd33..60d06f7081 100755 --- a/src/framework/modules/timeline/screens/timeline-screen.tsx +++ b/src/framework/modules/timeline/screens/timeline-screen.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert, Image, ListRenderItemInfo, RefreshControl, TouchableOpacity, View } from 'react-native'; +import { Alert, ListRenderItemInfo, RefreshControl, TouchableOpacity, View } from 'react-native'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; import { NativeStackNavigationOptions, NativeStackScreenProps } from '@react-navigation/native-stack'; @@ -106,11 +106,12 @@ const getTimelineItems = (flashMessages: FlashMessagesStateData, notifications: const msgs = flashMessages ?? []; const notifs = notifications && notifications.data ? notifications.data : []; const ret = [] as (ITimelineItem & { key: string })[]; - for (const fm of msgs) { - if (!fm.dismiss) { - ret.push({ data: fm, key: fm.id.toString(), type: ITimelineItemType.FLASHMSG }); + if (msgs.length) + for (const fm of msgs) { + if (!fm.dismiss) { + ret.push({ data: fm, key: fm.id.toString(), type: ITimelineItemType.FLASHMSG }); + } } - } for (const n of notifs) { ret.push({ data: n, key: n.id, type: ITimelineItemType.NOTIFICATION }); }