nicolium: improve frontend config handling

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-27 18:35:10 +01:00
parent 944788f3e0
commit 92174134ac
8 changed files with 85 additions and 48 deletions

View File

@ -1,9 +1,5 @@
import { createSelector } from 'reselect';
import * as v from 'valibot';
import { getHost } from '@/actions/instance';
import { getClient, staticFetch } from '@/api';
import { frontendConfigSchema } from '@/schemas/frontend-config';
import KVStore from '@/storage/kv-store';
import { useSettingsStore } from '@/stores/settings';
@ -15,18 +11,14 @@ const FRONTEND_CONFIG_REQUEST_FAIL = 'FRONTEND_CONFIG_REQUEST_FAIL' as const;
const FRONTEND_CONFIG_REMEMBER_SUCCESS = 'FRONTEND_CONFIG_REMEMBER_SUCCESS' as const;
const getFrontendConfig = createSelector(
[
(state: RootState) => state.frontendConfig,
// Do some additional normalization with the state
],
(frontendConfig) => v.parse(frontendConfigSchema, frontendConfig),
);
const rememberFrontendConfig = (host: string | null) => (dispatch: AppDispatch) =>
KVStore.getItemOrError(`plfe_config:${host}`)
.then((frontendConfig) => {
dispatch({ type: FRONTEND_CONFIG_REMEMBER_SUCCESS, host, frontendConfig });
dispatch<FrontendConfigAction>({
type: FRONTEND_CONFIG_REMEMBER_SUCCESS,
host,
frontendConfig,
});
return true;
})
.catch(() => false);
@ -101,11 +93,20 @@ const frontendConfigFail = (error: unknown, host: string | null) => ({
// https://stackoverflow.com/a/46663081
const isObject = (o: any) => o instanceof Object && o.constructor === Object;
type FrontendConfigAction =
| ReturnType<typeof importFrontendConfig>
| ReturnType<typeof frontendConfigFail>
| {
type: typeof FRONTEND_CONFIG_REMEMBER_SUCCESS;
frontendConfig: APIEntity;
host: string | null;
};
export {
FRONTEND_CONFIG_REQUEST_SUCCESS,
FRONTEND_CONFIG_REQUEST_FAIL,
FRONTEND_CONFIG_REMEMBER_SUCCESS,
getFrontendConfig,
fetchFrontendConfig,
loadFrontendConfig,
type FrontendConfigAction,
};

View File

@ -1,8 +1,17 @@
import { getFrontendConfig } from '@/actions/frontend-config';
import { useMemo } from 'react';
import * as v from 'valibot';
import { frontendConfigSchema } from '@/schemas/frontend-config';
import { useAppSelector } from './use-app-selector';
const defaultFrontendConfig = v.parse(frontendConfigSchema, {});
/** Get the Nicolium config from the store */
const useFrontendConfig = () => useAppSelector((state) => getFrontendConfig(state));
const useFrontendConfig = () => {
const partialConfig = useAppSelector((state) => state.frontendConfig);
return useMemo(() => ({ ...defaultFrontendConfig, ...partialConfig }), [partialConfig]);
};
export { useFrontendConfig };

View File

@ -1,7 +1,6 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { getFrontendConfig } from '@/actions/frontend-config';
import Stack from '@/components/ui/stack';
import Text from '@/components/ui/text';
import { useAppSelector } from '@/hooks/use-app-selector';
@ -30,23 +29,31 @@ const renderTermsOfServiceLink = (href: string) => (
const ConfirmationStep: React.FC = () => {
const intl = useIntl();
const links = useAppSelector((state) => getFrontendConfig(state).links);
const links = useAppSelector((state) => state.frontendConfig.links);
const entity = intl.formatMessage(messages.accountEntity);
return (
<Stack space={1}>
<Text weight='semibold' tag='h1' size='xl'>
<FormattedMessage
id='report.confirmation.title'
defaultMessage='Thanks for submitting your report.'
/>
{intl.formatMessage(messages.title)}
</Text>
<Text>
{intl.formatMessage(messages.content, {
entity,
link: links.termsOfService
? renderTermsOfServiceLink(links.termsOfService)
: termsOfServiceText,
})}
<FormattedMessage
id='report.confirmation.content'
defaultMessage='If we find that this {entity} is violating the {link} we will take further action on the matter.'
values={{
entity,
link: links?.termsOfService
? renderTermsOfServiceLink(links.termsOfService)
: termsOfServiceText,
}}
/>
</Text>
</Stack>
);

View File

@ -1,19 +1,22 @@
import { ADMIN_CONFIG_UPDATE_SUCCESS } from '@/actions/admin';
import * as v from 'valibot';
import { ADMIN_CONFIG_UPDATE_SUCCESS, type AdminActions } from '@/actions/admin';
import {
FRONTEND_CONFIG_REMEMBER_SUCCESS,
FRONTEND_CONFIG_REQUEST_SUCCESS,
FRONTEND_CONFIG_REQUEST_FAIL,
type FrontendConfigAction,
} from '@/actions/frontend-config';
import { PLEROMA_PRELOAD_IMPORT } from '@/actions/preload';
import { PLEROMA_PRELOAD_IMPORT, type PreloadAction } from '@/actions/preload';
import { type PartialFrontendConfig, partialFrontendConfigSchema } from '@/schemas/frontend-config';
import KVStore from '@/storage/kv-store';
import ConfigDB from '@/utils/config-db';
import type { FrontendConfig } from '@/schemas/frontend-config';
import type { PleromaConfig } from 'pl-api';
const initialState: Partial<FrontendConfig> = {};
const initialState: PartialFrontendConfig = {};
const fallbackState: Partial<FrontendConfig> = {
const fallbackState: PartialFrontendConfig = {
brandColor: '#d80482',
};
@ -39,32 +42,42 @@ const preloadImport = (state: Record<string, any>, action: Record<string, any>)
}
};
const persistFrontendConfig = (frontendConfig: Record<string, any>, host: string) => {
const persistFrontendConfig = (frontendConfig: PartialFrontendConfig, host: string) => {
if (host) {
KVStore.setItem(`plfe_config:${host}`, frontendConfig).catch(console.error);
}
};
const importFrontendConfig = (frontendConfig: FrontendConfig, host: string) => {
persistFrontendConfig(frontendConfig, host);
return frontendConfig;
const importFrontendConfig = (frontendConfig: unknown, host: string) => {
const parsedFrontendConfig = v.parse(partialFrontendConfigSchema, frontendConfig);
persistFrontendConfig(parsedFrontendConfig, host);
return parsedFrontendConfig;
};
const parseFrontendConfig = (frontendConfig: unknown) => {
try {
return v.parse(partialFrontendConfigSchema, frontendConfig);
} catch (e) {
console.error('Failed to parse frontend config', e);
return null;
}
};
const frontendConfig = (
state = initialState,
action: Record<string, any>,
): Partial<FrontendConfig> => {
action: PreloadAction | FrontendConfigAction | AdminActions,
): PartialFrontendConfig => {
switch (action.type) {
case PLEROMA_PRELOAD_IMPORT:
return preloadImport(state, action);
return parseFrontendConfig(preloadImport(state, action)) || state;
case FRONTEND_CONFIG_REMEMBER_SUCCESS:
return action.frontendConfig;
return parseFrontendConfig(action.frontendConfig) || state;
case FRONTEND_CONFIG_REQUEST_SUCCESS:
return importFrontendConfig(action.frontendConfig ?? {}, action.host);
return importFrontendConfig(action.frontendConfig ?? {}, action.host || '') || state;
case FRONTEND_CONFIG_REQUEST_FAIL:
return { ...fallbackState, ...state };
case ADMIN_CONFIG_UPDATE_SUCCESS:
return updateFromAdmin(state, action.configs ?? []);
return parseFrontendConfig(updateFromAdmin(state, action.configs ?? [])) || state;
default:
return state;
}

View File

@ -37,7 +37,7 @@ const logOut = (state: AppState): ReturnType<typeof appReducer> => {
location.href = '/login';
}
const newState = rootReducer(undefined, { type: '' });
const newState = rootReducer(undefined, { type: '' } as any);
const { instance, frontendConfig, auth } = state;
return { ...newState, instance, frontendConfig, auth };

View File

@ -41,7 +41,7 @@ const cryptoAddressSchema = v.pipe(
type CryptoAddress = v.InferOutput<typeof cryptoAddressSchema>;
const frontendConfigSchema = coerceObject({
const frontendConfigSchemaShape = {
appleAppId: v.fallback(v.nullable(v.string()), null),
logo: v.fallback(v.string(), ''),
logoDarkMode: v.fallback(v.nullable(v.string()), null),
@ -117,18 +117,26 @@ const frontendConfigSchema = coerceObject({
*/
mediaPreview: v.fallback(v.boolean(), false),
sentryDsn: v.fallback(v.optional(v.string()), undefined),
});
};
const frontendConfigSchema = coerceObject(frontendConfigSchemaShape);
type FrontendConfig = v.InferOutput<typeof frontendConfigSchema>;
const partialFrontendConfigSchema = v.partial(v.object(frontendConfigSchemaShape));
type PartialFrontendConfig = v.InferOutput<typeof partialFrontendConfigSchema>;
export {
promoPanelItemSchema,
footerItemSchema,
cryptoAddressSchema,
frontendConfigSchema,
partialFrontendConfigSchema,
type PromoPanelItem,
type PromoPanel,
type FooterItem,
type CryptoAddress,
type FrontendConfig,
type PartialFrontendConfig,
};

View File

@ -50,8 +50,8 @@ type State = {
settings: Settings;
actions: {
loadDefaultSettings: (settings: APIEntity) => void;
loadUserSettings: (settings: APIEntity) => void;
loadDefaultSettings: (settings: unknown) => void;
loadUserSettings: (settings: unknown) => void;
userSettingsSaving: () => void;
changeSetting: (path: string[], value: any) => void;
rememberEmojiUse: (emoji: Emoji) => void;
@ -146,7 +146,7 @@ const useSettingsStore = create<State>()(
settings: v.parse(settingsSchema, { locale: navigator.language }),
actions: {
loadDefaultSettings: (settings: APIEntity) => {
loadDefaultSettings: (settings: unknown) => {
set((state: State) => {
if (typeof settings !== 'object') return;
@ -155,7 +155,7 @@ const useSettingsStore = create<State>()(
});
},
loadUserSettings: (settings?: APIEntity) => {
loadUserSettings: (settings?: unknown) => {
set((state: State) => {
if (typeof settings !== 'object') return;

View File

@ -3,7 +3,6 @@
* @module @/utils/state
*/
import { getFrontendConfig } from '@/actions/frontend-config';
import * as BuildConfig from '@/build-config';
import { isPrerendered } from '@/precheck';
import { selectOwnAccount } from '@/queries/accounts/selectors';
@ -12,7 +11,7 @@ import { isURL } from '@/utils/auth';
import type { RootState } from '@/store';
/** Whether to display the fqn instead of the acct. */
const displayFqn = (state: RootState): boolean => getFrontendConfig(state).displayFqn;
const displayFqn = (state: RootState): boolean => state.frontendConfig.displayFqn ?? true;
/** Whether the instance exposes instance blocks through the API. */
const federationRestrictionsDisclosed = (state: RootState): boolean =>