pl-fe: wip migrate pl-fe config

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-11-14 21:18:56 +01:00
parent e8c89a4415
commit 44a4116a75
28 changed files with 237 additions and 304 deletions

View file

@ -1,7 +1,8 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import * as v from 'valibot';
import { getHost } from 'pl-fe/actions/instance'; import { getHost } from 'pl-fe/actions/instance';
import { normalizePlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config'; import { plFeConfigSchema } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import KVStore from 'pl-fe/storage/kv-store'; import KVStore from 'pl-fe/storage/kv-store';
import { useSettingsStore } from 'pl-fe/stores/settings'; import { useSettingsStore } from 'pl-fe/stores/settings';
@ -17,10 +18,8 @@ const PLFE_CONFIG_REMEMBER_SUCCESS = 'PLFE_CONFIG_REMEMBER_SUCCESS' as const;
const getPlFeConfig = createSelector([ const getPlFeConfig = createSelector([
(state: RootState) => state.plfe, (state: RootState) => state.plfe,
], (plfe) => { // Do some additional normalization with the state
// Do some additional normalization with the state ], (plfe) => v.parse(plFeConfigSchema, plfe));
return normalizePlFeConfig(plfe);
});
const rememberPlFeConfig = (host: string | null) => const rememberPlFeConfig = (host: string | null) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {

View file

@ -28,7 +28,7 @@ const processTimelineUpdate = (timeline: string, status: BaseStatus) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const me = getState().me; const me = getState().me;
const ownStatus = status.account?.id === me; const ownStatus = status.account?.id === me;
const hasPendingStatuses = !getState().pending_statuses.isEmpty(); const hasPendingStatuses = !!getState().pending_statuses.length;
const columnSettings = useSettingsStore.getState().settings.timelines[timeline]; const columnSettings = useSettingsStore.getState().settings.timelines[timeline];
const shouldSkipQueue = shouldFilter({ const shouldSkipQueue = shouldFilter({

View file

@ -16,7 +16,7 @@ const Navlinks: React.FC<INavlinks> = ({ type }) => {
return ( return (
<footer className='relative mx-auto mt-auto max-w-7xl py-8'> <footer className='relative mx-auto mt-auto max-w-7xl py-8'>
<div className='flex flex-wrap justify-center'> <div className='flex flex-wrap justify-center'>
{navlinks.get(type)?.map((link, idx) => { {navlinks[type]?.map((link, idx) => {
const url = link.url; const url = link.url;
const isExternal = url.startsWith('http'); const isExternal = url.startsWith('http');
const Comp = (isExternal ? 'a' : Link) as 'a'; const Comp = (isExternal ? 'a' : Link) as 'a';
@ -26,7 +26,7 @@ const Navlinks: React.FC<INavlinks> = ({ type }) => {
<div key={idx} className='px-5 py-2'> <div key={idx} className='px-5 py-2'>
<Comp {...compProps} className='text-primary-600 hover:underline dark:text-primary-400'> <Comp {...compProps} className='text-primary-600 hover:underline dark:text-primary-400'>
<Text tag='span' theme='inherit' size='sm'> <Text tag='span' theme='inherit' size='sm'>
{(link.getIn(['titleLocales', locale]) || link.get('title')) as string} {link.titleLocales[locale] || link.title}
</Text> </Text>
</Comp> </Comp>
</div> </div>

View file

@ -158,20 +158,20 @@ const SiteErrorBoundary: React.FC<ISiteErrorBoundary> = ({ children }) => {
<footer className='mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8'> <footer className='mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8'>
<HStack justifyContent='center' space={4} element='nav'> <HStack justifyContent='center' space={4} element='nav'>
{links.get('status') && ( {links.status && (
<SiteErrorBoundaryLink href={links.get('status')!}> <SiteErrorBoundaryLink href={links.status}>
<FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' /> <FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
</SiteErrorBoundaryLink> </SiteErrorBoundaryLink>
)} )}
{links.get('help') && ( {links.help && (
<SiteErrorBoundaryLink href={links.get('help')!}> <SiteErrorBoundaryLink href={links.help}>
<FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' /> <FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
</SiteErrorBoundaryLink> </SiteErrorBoundaryLink>
)} )}
{links.get('support') && ( {links.support && (
<SiteErrorBoundaryLink href={links.get('support')!}> <SiteErrorBoundaryLink href={links.support}>
<FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' /> <FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
</SiteErrorBoundaryLink> </SiteErrorBoundaryLink>
)} )}

View file

@ -24,9 +24,9 @@ const AboutPage: React.FC = () => {
const { aboutPages } = plFeConfig; const { aboutPages } = plFeConfig;
const page = aboutPages.get(slug || 'about'); const page = aboutPages[slug || 'about'];
const defaultLocale = page?.get('default') as string | undefined; const defaultLocale = page?.defaultLocale;
const pageLocales = page?.get('locales', []) as string[]; const pageLocales = page?.locales || [];
useEffect(() => { useEffect(() => {
const fetchLocale = Boolean(page && locale !== defaultLocale && pageLocales.includes(locale)); const fetchLocale = Boolean(page && locale !== defaultLocale && pageLocales.includes(locale));

View file

@ -22,9 +22,9 @@ const CryptoDonatePanel: React.FC<ICryptoDonatePanel> = ({ limit = 3 }): JSX.Ele
const history = useHistory(); const history = useHistory();
const instance = useInstance(); const instance = useInstance();
const addresses = usePlFeConfig().get('cryptoAddresses'); const addresses = usePlFeConfig().cryptoAddresses;
if (limit === 0 || addresses.size === 0) { if (limit === 0 || addresses.length === 0) {
return null; return null;
} }
@ -36,7 +36,7 @@ const CryptoDonatePanel: React.FC<ICryptoDonatePanel> = ({ limit = 3 }): JSX.Ele
<Widget <Widget
title={<FormattedMessage id='crypto_donate_panel.heading' defaultMessage='Donate Cryptocurrency' />} title={<FormattedMessage id='crypto_donate_panel.heading' defaultMessage='Donate Cryptocurrency' />}
onActionClick={handleAction} onActionClick={handleAction}
actionTitle={intl.formatMessage(messages.actionTitle, { count: addresses.size })} actionTitle={intl.formatMessage(messages.actionTitle, { count: addresses.length })}
> >
<Text> <Text>
<FormattedMessage <FormattedMessage

View file

@ -11,7 +11,7 @@ interface ISiteWallet {
const SiteWallet: React.FC<ISiteWallet> = ({ limit }): JSX.Element => { const SiteWallet: React.FC<ISiteWallet> = ({ limit }): JSX.Element => {
const { cryptoAddresses } = usePlFeConfig(); const { cryptoAddresses } = usePlFeConfig();
const addresses = typeof limit === 'number' ? cryptoAddresses.take(limit) : cryptoAddresses; const addresses = typeof limit === 'number' ? cryptoAddresses.slice(0, limit) : cryptoAddresses;
return ( return (
<Stack space={4}> <Stack space={4}>

View file

@ -5,7 +5,7 @@ import HStack from 'pl-fe/components/ui/hstack';
import Input from 'pl-fe/components/ui/input'; import Input from 'pl-fe/components/ui/input';
import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield'; import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield';
import type { CryptoAddress } from 'pl-fe/types/pl-fe'; import type { CryptoAddress } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
const messages = defineMessages({ const messages = defineMessages({
ticker: { id: 'plfe_config.crypto_address.meta_fields.ticker_placeholder', defaultMessage: 'Ticker' }, ticker: { id: 'plfe_config.crypto_address.meta_fields.ticker_placeholder', defaultMessage: 'Ticker' },

View file

@ -5,7 +5,7 @@ import HStack from 'pl-fe/components/ui/hstack';
import Input from 'pl-fe/components/ui/input'; import Input from 'pl-fe/components/ui/input';
import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield'; import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield';
import type { FooterItem } from 'pl-fe/types/pl-fe'; import type { FooterItem } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
const messages = defineMessages({ const messages = defineMessages({
label: { id: 'plfe_config.home_footer.meta_fields.label_placeholder', defaultMessage: 'Label' }, label: { id: 'plfe_config.home_footer.meta_fields.label_placeholder', defaultMessage: 'Label' },

View file

@ -7,7 +7,7 @@ import Input from 'pl-fe/components/ui/input';
import IconPicker from './icon-picker'; import IconPicker from './icon-picker';
import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield'; import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield';
import type { PromoPanelItem } from 'pl-fe/types/pl-fe'; import type { PromoPanelItem } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
const messages = defineMessages({ const messages = defineMessages({
icon: { id: 'plfe_config.promo_panel.meta_fields.icon_placeholder', defaultMessage: 'Icon' }, icon: { id: 'plfe_config.promo_panel.meta_fields.icon_placeholder', defaultMessage: 'Icon' },

View file

@ -1,10 +1,11 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import * as v from 'valibot';
import BackgroundShapes from 'pl-fe/features/ui/components/background-shapes'; import BackgroundShapes from 'pl-fe/features/ui/components/background-shapes';
import { useSystemTheme } from 'pl-fe/hooks/use-system-theme'; import { useSystemTheme } from 'pl-fe/hooks/use-system-theme';
import { normalizePlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config'; import { plFeConfigSchema } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import { useSettingsStore } from 'pl-fe/stores/settings'; import { useSettingsStore } from 'pl-fe/stores/settings';
import { generateThemeCss } from 'pl-fe/utils/theme'; import { generateThemeCss } from 'pl-fe/utils/theme';
@ -15,7 +16,7 @@ interface ISitePreview {
/** Renders a preview of the website's style with the configuration applied. */ /** Renders a preview of the website's style with the configuration applied. */
const SitePreview: React.FC<ISitePreview> = ({ plFe }) => { const SitePreview: React.FC<ISitePreview> = ({ plFe }) => {
const plFeConfig = useMemo(() => normalizePlFeConfig(plFe), [plFe]); const plFeConfig = useMemo(() => v.parse(plFeConfigSchema, plFe), [plFe]);
const { defaultSettings } = useSettingsStore(); const { defaultSettings } = useSettingsStore();
const userTheme = defaultSettings.themeMode; const userTheme = defaultSettings.themeMode;

View file

@ -1,6 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import * as v from 'valibot';
import { updatePlFeConfig } from 'pl-fe/actions/admin'; import { updatePlFeConfig } from 'pl-fe/actions/admin';
import { uploadMedia } from 'pl-fe/actions/media'; import { uploadMedia } from 'pl-fe/actions/media';
@ -21,7 +22,7 @@ import ThemeSelector from 'pl-fe/features/ui/components/theme-selector';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features'; import { useFeatures } from 'pl-fe/hooks/use-features';
import { normalizePlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config'; import { plFeConfigSchema } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import toast from 'pl-fe/toast'; import toast from 'pl-fe/toast';
import CryptoAddressInput from './components/crypto-address-input'; import CryptoAddressInput from './components/crypto-address-input';
@ -34,13 +35,11 @@ const messages = defineMessages({
saved: { id: 'plfe_config.saved', defaultMessage: 'pl-fe config saved!' }, saved: { id: 'plfe_config.saved', defaultMessage: 'pl-fe config saved!' },
copyrightFooterLabel: { id: 'plfe_config.copyright_footer.meta_fields.label_placeholder', defaultMessage: 'Copyright footer' }, copyrightFooterLabel: { id: 'plfe_config.copyright_footer.meta_fields.label_placeholder', defaultMessage: 'Copyright footer' },
cryptoDonatePanelLimitLabel: { id: 'plfe_config.crypto_donate_panel_limit.meta_fields.limit_placeholder', defaultMessage: 'Number of items to display in the crypto homepage widget' }, cryptoDonatePanelLimitLabel: { id: 'plfe_config.crypto_donate_panel_limit.meta_fields.limit_placeholder', defaultMessage: 'Number of items to display in the crypto homepage widget' },
customCssLabel: { id: 'plfe_config.custom_css.meta_fields.url_placeholder', defaultMessage: 'URL' },
rawJSONLabel: { id: 'plfe_config.raw_json_label', defaultMessage: 'Advanced: Edit raw JSON data' }, rawJSONLabel: { id: 'plfe_config.raw_json_label', defaultMessage: 'Advanced: Edit raw JSON data' },
rawJSONHint: { id: 'plfe_config.raw_json_hint', defaultMessage: 'Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click "Save" to apply your changes.' }, rawJSONHint: { id: 'plfe_config.raw_json_hint', defaultMessage: 'Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click "Save" to apply your changes.' },
rawJSONInvalid: { id: 'plfe_config.raw_json_invalid', defaultMessage: 'is invalid' }, rawJSONInvalid: { id: 'plfe_config.raw_json_invalid', defaultMessage: 'is invalid' },
displayFqnLabel: { id: 'plfe_config.display_fqn_label', defaultMessage: 'Display domain (eg @user@domain) for local accounts.' }, displayFqnLabel: { id: 'plfe_config.display_fqn_label', defaultMessage: 'Display domain (eg @user@domain) for local accounts.' },
greentextLabel: { id: 'plfe_config.greentext_label', defaultMessage: 'Enable greentext support' }, greentextLabel: { id: 'plfe_config.greentext_label', defaultMessage: 'Enable greentext support' },
promoPanelIconsLink: { id: 'plfe_config.hints.promo_panel_icons.link', defaultMessage: 'pl-fe Icons List' },
authenticatedProfileLabel: { id: 'plfe_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' }, authenticatedProfileLabel: { id: 'plfe_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' },
authenticatedProfileHint: { id: 'plfe_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' }, authenticatedProfileHint: { id: 'plfe_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' },
displayCtaLabel: { id: 'plfe_config.cta_label', defaultMessage: 'Display call to action panels if not authenticated' }, displayCtaLabel: { id: 'plfe_config.cta_label', defaultMessage: 'Display call to action panels if not authenticated' },
@ -81,7 +80,7 @@ const PlFeConfig: React.FC = () => {
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2)); const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2));
const [jsonValid, setJsonValid] = useState(true); const [jsonValid, setJsonValid] = useState(true);
const plFe = useMemo(() => normalizePlFeConfig(data), [data]); const plFe = useMemo(() => v.parse(plFeConfigSchema, data), [data]);
const setConfig = (path: ConfigPath, value: any) => { const setConfig = (path: ConfigPath, value: any) => {
const newData = data.setIn(path, value); const newData = data.setIn(path, value);
@ -284,7 +283,7 @@ const PlFeConfig: React.FC = () => {
label={<FormattedMessage id='plfe_config.fields.promo_panel_fields_label' defaultMessage='Promo panel items' />} label={<FormattedMessage id='plfe_config.fields.promo_panel_fields_label' defaultMessage='Promo panel items' />}
hint={<FormattedMessage id='plfe_config.hints.promo_panel_fields' defaultMessage='You can have custom defined links displayed on the right panel of the timelines page.' />} hint={<FormattedMessage id='plfe_config.hints.promo_panel_fields' defaultMessage='You can have custom defined links displayed on the right panel of the timelines page.' />}
component={PromoPanelInput} component={PromoPanelInput}
values={plFe.promoPanel.items.toArray()} values={plFe.promoPanel.items}
onChange={handleStreamItemChange(['promoPanel', 'items'])} onChange={handleStreamItemChange(['promoPanel', 'items'])}
onAddItem={addStreamItem(['promoPanel', 'items'], templates.promoPanel)} onAddItem={addStreamItem(['promoPanel', 'items'], templates.promoPanel)}
onRemoveItem={deleteStreamItem(['promoPanel', 'items'])} onRemoveItem={deleteStreamItem(['promoPanel', 'items'])}
@ -295,7 +294,7 @@ const PlFeConfig: React.FC = () => {
label={<FormattedMessage id='plfe_config.fields.home_footer_fields_label' defaultMessage='Home footer items' />} label={<FormattedMessage id='plfe_config.fields.home_footer_fields_label' defaultMessage='Home footer items' />}
hint={<FormattedMessage id='plfe_config.hints.home_footer_fields' defaultMessage='You can have custom defined links displayed on the footer of your static pages' />} hint={<FormattedMessage id='plfe_config.hints.home_footer_fields' defaultMessage='You can have custom defined links displayed on the footer of your static pages' />}
component={FooterLinkInput} component={FooterLinkInput}
values={plFe.navlinks.get('homeFooter')?.toArray() || []} values={plFe.navlinks.homeFooter || []}
onChange={handleStreamItemChange(['navlinks', 'homeFooter'])} onChange={handleStreamItemChange(['navlinks', 'homeFooter'])}
onAddItem={addStreamItem(['navlinks', 'homeFooter'], templates.footerItem)} onAddItem={addStreamItem(['navlinks', 'homeFooter'], templates.footerItem)}
onRemoveItem={deleteStreamItem(['navlinks', 'homeFooter'])} onRemoveItem={deleteStreamItem(['navlinks', 'homeFooter'])}
@ -345,7 +344,7 @@ const PlFeConfig: React.FC = () => {
label={<FormattedMessage id='plfe_config.fields.crypto_addresses_label' defaultMessage='Cryptocurrency addresses' />} label={<FormattedMessage id='plfe_config.fields.crypto_addresses_label' defaultMessage='Cryptocurrency addresses' />}
hint={<FormattedMessage id='plfe_config.hints.crypto_addresses' defaultMessage='Add cryptocurrency addresses so users of your site can donate to you. Order matters, and you must use lowercase ticker values.' />} hint={<FormattedMessage id='plfe_config.hints.crypto_addresses' defaultMessage='Add cryptocurrency addresses so users of your site can donate to you. Order matters, and you must use lowercase ticker values.' />}
component={CryptoAddressInput} component={CryptoAddressInput}
values={plFe.cryptoAddresses.toArray()} values={plFe.cryptoAddresses}
onChange={handleStreamItemChange(['cryptoAddresses'])} onChange={handleStreamItemChange(['cryptoAddresses'])}
onAddItem={addStreamItem(['cryptoAddresses'], templates.cryptoAddress)} onAddItem={addStreamItem(['cryptoAddresses'], templates.cryptoAddress)}
onRemoveItem={deleteStreamItem(['cryptoAddresses'])} onRemoveItem={deleteStreamItem(['cryptoAddresses'])}
@ -358,7 +357,7 @@ const PlFeConfig: React.FC = () => {
min={0} min={0}
pattern='[0-9]+' pattern='[0-9]+'
placeholder={intl.formatMessage(messages.cryptoDonatePanelLimitLabel)} placeholder={intl.formatMessage(messages.cryptoDonatePanelLimitLabel)}
value={plFe.cryptoDonatePanel.get('limit')} value={plFe.cryptoDonatePanel.limit}
onChange={handleChange(['cryptoDonatePanel', 'limit'], (e) => Number(e.target.value))} onChange={handleChange(['cryptoDonatePanel', 'limit'], (e) => Number(e.target.value))}
/> />
</FormGroup> </FormGroup>

View file

@ -1,5 +1,6 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import * as v from 'valibot';
import { updatePlFeConfig } from 'pl-fe/actions/admin'; import { updatePlFeConfig } from 'pl-fe/actions/admin';
import { getHost } from 'pl-fe/actions/instance'; import { getHost } from 'pl-fe/actions/instance';
@ -14,7 +15,7 @@ import ColorPicker from 'pl-fe/features/pl-fe-config/components/color-picker';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config'; import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { normalizePlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config'; import { plFeConfigSchema } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import toast from 'pl-fe/toast'; import toast from 'pl-fe/toast';
import { download } from 'pl-fe/utils/download'; import { download } from 'pl-fe/utils/download';
@ -53,7 +54,7 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
const host = useAppSelector(state => getHost(state)); const host = useAppSelector(state => getHost(state));
const rawConfig = useAppSelector(state => state.plfe); const rawConfig = useAppSelector(state => state.plfe);
const [colors, setColors] = useState(plFe.colors.toJS() as any); const [colors, setColors] = useState(plFe.colors);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [resetKey, setResetKey] = useState(crypto.randomUUID()); const [resetKey, setResetKey] = useState(crypto.randomUUID());
@ -82,16 +83,16 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
}; };
const resetTheme = () => { const resetTheme = () => {
setTheme(plFe.colors.toJS() as any); setTheme(plFe.colors);
}; };
const updateTheme = async () => { const updateTheme = async () => {
const params = rawConfig.set('colors', colors).toJS(); const params = { ...rawConfig, colors };
await dispatch(updatePlFeConfig(params)); await dispatch(updatePlFeConfig(params));
}; };
const restoreDefaultTheme = () => { const restoreDefaultTheme = () => {
const colors = normalizePlFeConfig({ brandColor: '#d80482' }).colors.toJS(); const colors = v.parse(plFeConfigSchema, { brandColor: '#d80482' }).colors;
setTheme(colors); setTheme(colors);
}; };
@ -110,7 +111,7 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
if (file) { if (file) {
const text = await file.text(); const text = await file.text();
const json = JSON.parse(text); const json = JSON.parse(text);
const colors = normalizePlFeConfig({ colors: json }).colors.toJS(); const colors = v.parse(plFeConfigSchema, { colors: json }).colors;
setTheme(colors); setTheme(colors);
toast.success(intl.formatMessage(messages.importSuccess)); toast.success(intl.formatMessage(messages.importSuccess));
@ -243,13 +244,13 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
interface IPaletteListItem { interface IPaletteListItem {
label: React.ReactNode; label: React.ReactNode;
palette: ColorGroup; palette: ColorGroup | string;
onChange: (palette: ColorGroup) => void; onChange: (palette: ColorGroup) => void;
resetKey?: string; resetKey?: string;
} }
/** Palette editor inside a ListItem. */ /** Palette editor inside a ListItem. */
const PaletteListItem: React.FC<IPaletteListItem> = ({ label, palette, onChange, resetKey }) => ( const PaletteListItem: React.FC<IPaletteListItem> = ({ label, palette, onChange, resetKey }) => typeof palette === 'string' ? null : (
<ListItem label={<div className='w-20'>{label}</div>}> <ListItem label={<div className='w-20'>{label}</div>}>
<Palette palette={palette} onChange={onChange} resetKey={resetKey} /> <Palette palette={palette} onChange={onChange} resetKey={resetKey} />
</ListItem> </ListItem>
@ -257,12 +258,14 @@ const PaletteListItem: React.FC<IPaletteListItem> = ({ label, palette, onChange,
interface IColorListItem { interface IColorListItem {
label: React.ReactNode; label: React.ReactNode;
value: string; value: string | Record<string, string>;
onChange: (hex: string) => void; onChange: (hex: string) => void;
} }
/** Single-color picker. */ /** Single-color picker. */
const ColorListItem: React.FC<IColorListItem> = ({ label, value, onChange }) => { const ColorListItem: React.FC<IColorListItem> = ({ label, value, onChange }) => {
if (typeof value !== 'string') return null;
const handleChange: ColorChangeHandler = (color, _e) => { const handleChange: ColorChangeHandler = (color, _e) => {
onChange(color.hex); onChange(color.hex);
}; };

View file

@ -29,7 +29,7 @@ const renderTermsOfServiceLink = (href: string) => (
const ConfirmationStep: React.FC = () => { const ConfirmationStep: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const links = useAppSelector((state) => getPlFeConfig(state).get('links') as any); const links = useAppSelector((state) => getPlFeConfig(state).links);
const entity = intl.formatMessage(messages.accountEntity); const entity = intl.formatMessage(messages.accountEntity);
@ -42,8 +42,8 @@ const ConfirmationStep: React.FC = () => {
<Text> <Text>
{intl.formatMessage(messages.content, { {intl.formatMessage(messages.content, {
entity, entity,
link: links.get('termsOfService') ? link: links.termsOfService ?
renderTermsOfServiceLink(links.get('termsOfService')) : renderTermsOfServiceLink(links.termsOfService) :
termsOfServiceText, termsOfServiceText,
})} })}
</Text> </Text>

View file

@ -13,9 +13,9 @@ const PromoPanel: React.FC = () => {
const { promoPanel } = usePlFeConfig(); const { promoPanel } = usePlFeConfig();
const { locale } = useSettings(); const { locale } = useSettings();
const promoItems = promoPanel.get('items'); const promoItems = promoPanel.items;
if (!promoItems || promoItems.isEmpty()) return null; if (!promoItems || !promoItems.length) return null;
return ( return (
<Widget title={instance.title}> <Widget title={instance.title}>
@ -27,7 +27,7 @@ const PromoPanel: React.FC = () => {
label={ label={
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
<ForkAwesomeIcon id={item.icon} className='flex-none text-lg' fixedWidth /> <ForkAwesomeIcon id={item.icon} className='flex-none text-lg' fixedWidth />
<span>{item.textLocales.get(locale) || item.text}</span> <span>{item.textLocales[locale] || item.text}</span>
</HStack> </HStack>
} }
size='sm' size='sm'

View file

@ -47,7 +47,7 @@ const PendingStatusMedia: React.FC<IPendingStatusMedia> = ({ status }) => {
const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, muted, variant = 'rounded' }) => { const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, muted, variant = 'rounded' }) => {
const status = useAppSelector((state) => { const status = useAppSelector((state) => {
const pendingStatus = state.pending_statuses.get(idempotencyKey); const pendingStatus = state.pending_statuses[idempotencyKey];
return pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null; return pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null;
}); });

View file

@ -159,7 +159,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
const standalone = useAppSelector(isStandalone); const standalone = useAppSelector(isStandalone);
const { authenticatedProfile, cryptoAddresses } = usePlFeConfig(); const { authenticatedProfile, cryptoAddresses } = usePlFeConfig();
const hasCrypto = cryptoAddresses.size > 0; const hasCrypto = cryptoAddresses.length > 0;
// NOTE: Mastodon and Pleroma route some basenames to the backend. // NOTE: Mastodon and Pleroma route some basenames to the backend.
// When adding new routes, use a basename that does NOT conflict // When adding new routes, use a basename that does NOT conflict

View file

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { create } from 'mutative';
import { statusSchema } from 'pl-api'; import { statusSchema } from 'pl-api';
import * as v from 'valibot'; import * as v from 'valibot';
@ -12,17 +12,18 @@ const getAccount = makeGetAccount();
const buildMentions = (pendingStatus: PendingStatus) => { const buildMentions = (pendingStatus: PendingStatus) => {
if (pendingStatus.in_reply_to_id) { if (pendingStatus.in_reply_to_id) {
return ImmutableList(pendingStatus.to || []).map(acct => ImmutableMap({ acct })); return (pendingStatus.to || []).map(acct => ({ acct }));
} else { } else {
return ImmutableList(); return [];
} }
}; };
const buildPoll = (pendingStatus: PendingStatus) => { const buildPoll = (pendingStatus: PendingStatus) => {
if (pendingStatus.hasIn(['poll', 'options'])) { if (pendingStatus.poll?.options) {
return pendingStatus.poll!.update('options', (options: ImmutableMap<string, any>) => return create(pendingStatus.poll, (draft) => {
options.map((title: string) => ImmutableMap({ title })), // @ts-ignore
); draft.options = draft.options.map((title) => ({ title }));
});
} else { } else {
return null; return null;
} }
@ -39,7 +40,7 @@ const buildStatus = (state: RootState, pendingStatus: PendingStatus, idempotency
id: `末pending-${idempotencyKey}`, id: `末pending-${idempotencyKey}`,
in_reply_to_account_id: state.statuses[inReplyToId || '']?.account_id || null, in_reply_to_account_id: state.statuses[inReplyToId || '']?.account_id || null,
in_reply_to_id: inReplyToId, in_reply_to_id: inReplyToId,
media_attachments: (pendingStatus.media_ids || ImmutableList()).map((id: string) => ({ id })), media_attachments: (pendingStatus.media_ids || []).map((id: string) => ({ id })),
mentions: buildMentions(pendingStatus), mentions: buildMentions(pendingStatus),
poll: buildPoll(pendingStatus), poll: buildPoll(pendingStatus),
quote: pendingStatus.quote_id ? state.statuses[pendingStatus.quote_id] : null, quote: pendingStatus.quote_id ? state.statuses[pendingStatus.quote_id] : null,

View file

@ -2,9 +2,7 @@ import { getPlFeConfig } from 'pl-fe/actions/pl-fe';
import { useAppSelector } from './use-app-selector'; import { useAppSelector } from './use-app-selector';
import type { PlFeConfig } from 'pl-fe/types/pl-fe';
/** Get the pl-fe config from the store */ /** Get the pl-fe config from the store */
const usePlFeConfig = (): PlFeConfig => useAppSelector((state) => getPlFeConfig(state)); const usePlFeConfig = () => useAppSelector((state) => getPlFeConfig(state));
export { usePlFeConfig }; export { usePlFeConfig };

View file

@ -1,11 +1,12 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import * as v from 'valibot';
import { useLocale } from 'pl-fe/hooks/use-locale'; import { useLocale } from 'pl-fe/hooks/use-locale';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config'; import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { useSettings } from 'pl-fe/hooks/use-settings'; import { useSettings } from 'pl-fe/hooks/use-settings';
import { useTheme } from 'pl-fe/hooks/use-theme'; import { useTheme } from 'pl-fe/hooks/use-theme';
import { normalizePlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config'; import { plFeConfigSchema } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import { startSentry } from 'pl-fe/sentry'; import { startSentry } from 'pl-fe/sentry';
import { useModalsStore } from 'pl-fe/stores/modals'; import { useModalsStore } from 'pl-fe/stores/modals';
import { generateThemeCss } from 'pl-fe/utils/theme'; import { generateThemeCss } from 'pl-fe/utils/theme';
@ -21,7 +22,7 @@ const PlFeHead = () => {
const withModals = useModalsStore().modals.length > 0; const withModals = useModalsStore().modals.length > 0;
const themeCss = generateThemeCss(demo ? normalizePlFeConfig({ brandColor: '#d80482' }) : plFeConfig); const themeCss = generateThemeCss(demo ? v.parse(plFeConfigSchema, { brandColor: '#d80482' }) : plFeConfig);
const dsn = plFeConfig.sentryDsn; const dsn = plFeConfig.sentryDsn;
const bodyClass = clsx('h-full bg-white text-base antialiased black:bg-black dark:bg-gray-800', { const bodyClass = clsx('h-full bg-white text-base antialiased black:bg-black dark:bg-gray-800', {

View file

@ -2,7 +2,6 @@ import { configureMockStore } from '@jedmao/redux-mock-store';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react'; import { render, RenderOptions } from '@testing-library/react';
import { renderHook, RenderHookOptions } from '@testing-library/react-hooks'; import { renderHook, RenderHookOptions } from '@testing-library/react-hooks';
import { merge } from 'immutable';
import React, { FC, ReactElement } from 'react'; import React, { FC, ReactElement } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
@ -37,7 +36,7 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
if (storeProps && typeof storeProps.getState !== 'undefined') { // storeProps is a store if (storeProps && typeof storeProps.getState !== 'undefined') { // storeProps is a store
store = storeProps; store = storeProps;
} else if (storeProps) { // storeProps is state } else if (storeProps) { // storeProps is state
appState = merge(rootState, storeProps); appState = { ...rootState, ...storeProps };
store = createTestStore(appState); store = createTestStore(appState);
} else { } else {
store = createTestStore(appState); store = createTestStore(appState);

View file

@ -44,8 +44,8 @@ const HomeLayout: React.FC<IHomeLayout> = ({ children }) => {
const composeBlock = useRef<HTMLDivElement>(null); const composeBlock = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const hasCrypto = typeof plFeConfig.cryptoAddresses.getIn([0, 'ticker']) === 'string'; const hasCrypto = typeof plFeConfig.cryptoAddresses[0]?.ticker === 'string';
const cryptoLimit = plFeConfig.cryptoDonatePanel.get('limit', 0); const cryptoLimit = plFeConfig.cryptoDonatePanel.limit;
const { isDragging, isDraggedOver } = useDraggedFiles(composeBlock, (files) => { const { isDragging, isDraggedOver } = useDraggedFiles(composeBlock, (files) => {
dispatch(uploadCompose(composeId, files, intl)); dispatch(uploadCompose(composeId, files, intl));

View file

@ -1,23 +1,12 @@
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import trimStart from 'lodash/trimStart'; import trimStart from 'lodash/trimStart';
import * as v from 'valibot';
import { normalizeUsername } from 'pl-fe/utils/input'; import { coerceObject, filteredArray } from 'pl-fe/schemas/utils';
import { toTailwind } from 'pl-fe/utils/tailwind'; import { toTailwind } from 'pl-fe/utils/tailwind';
import { generateAccent } from 'pl-fe/utils/theme'; import { generateAccent } from 'pl-fe/utils/theme';
import type { const DEFAULT_COLORS = {
PromoPanelItem, success: {
FooterItem,
CryptoAddress,
} from 'pl-fe/types/pl-fe';
const DEFAULT_COLORS = ImmutableMap<string, any>({
success: ImmutableMap({
50: '#f0fdf4', 50: '#f0fdf4',
100: '#dcfce7', 100: '#dcfce7',
200: '#bbf7d0', 200: '#bbf7d0',
@ -28,8 +17,8 @@ const DEFAULT_COLORS = ImmutableMap<string, any>({
700: '#15803d', 700: '#15803d',
800: '#166534', 800: '#166534',
900: '#14532d', 900: '#14532d',
}), },
danger: ImmutableMap({ danger: {
50: '#fef2f2', 50: '#fef2f2',
100: '#fee2e2', 100: '#fee2e2',
200: '#fecaca', 200: '#fecaca',
@ -40,189 +29,131 @@ const DEFAULT_COLORS = ImmutableMap<string, any>({
700: '#b91c1c', 700: '#b91c1c',
800: '#991b1b', 800: '#991b1b',
900: '#7f1d1d', 900: '#7f1d1d',
}), },
'greentext': '#789922', 'greentext': '#789922',
};
const promoPanelItemSchema = coerceObject({
icon: v.fallback(v.string(), ''),
text: v.fallback(v.string(), ''),
url: v.fallback(v.string(), ''),
textLocales: v.fallback(v.record(v.string(), v.string()), {}),
}); });
const PromoPanelItemRecord = ImmutableRecord({ type PromoPanelItem = v.InferOutput<typeof promoPanelItemSchema>;
icon: '',
text: '', const promoPanelSchema = coerceObject({
url: '', items: filteredArray(promoPanelItemSchema),
textLocales: ImmutableMap<string, string>(),
}); });
const PromoPanelRecord = ImmutableRecord({ type PromoPanel = v.InferOutput<typeof promoPanelSchema>;
items: ImmutableList<PromoPanelItem>(),
const footerItemSchema = coerceObject({
title: v.fallback(v.string(), ''),
url: v.fallback(v.string(), ''),
titleLocales: v.fallback(v.record(v.string(), v.string()), {})
}); });
const FooterItemRecord = ImmutableRecord({ type FooterItem = v.InferOutput<typeof footerItemSchema>;
title: '',
url: '',
});
const CryptoAddressRecord = ImmutableRecord({ const cryptoAddressSchema = v.pipe(coerceObject({
address: '', address: v.fallback(v.string(), ''),
note: '', note: v.fallback(v.string(), ''),
ticker: '', ticker: v.fallback(v.string(), ''),
}); }), v.transform((address) => {
address.ticker = trimStart(address.ticker, '$').toLowerCase();
return address;
}));
const PlFeConfigRecord = ImmutableRecord({ type CryptoAddress = v.InferOutput<typeof cryptoAddressSchema>;
appleAppId: null,
authProvider: '', const plFeConfigSchema = v.pipe(coerceObject({
logo: '', appleAppId: v.fallback(v.nullable(v.string()), null),
logoDarkMode: null, logo: v.fallback(v.string(), ''),
banner: '', logoDarkMode: v.fallback(v.nullable(v.string()), null),
brandColor: '', // Empty brandColor: v.fallback(v.string(), ''),
accentColor: '', accentColor: v.fallback(v.string(), ''),
colors: ImmutableMap(), colors: v.any(),
copyright: `${new Date().getFullYear()}. Copying is an act of love. Please copy and share.`, copyright: v.fallback(v.string(), `${new Date().getFullYear()}. Copying is an act of love. Please copy and share.`),
customCss: ImmutableList<string>(), defaultSettings: v.fallback(v.record(v.string(), v.any()), {}),
defaultSettings: ImmutableMap<string, any>(), gdpr: v.fallback(v.boolean(), false),
extensions: ImmutableMap(), gdprUrl: v.fallback(v.string(), ''),
gdpr: false, greentext: v.fallback(v.boolean(), false),
gdprUrl: '', promoPanel: promoPanelSchema,
greentext: false, navlinks: v.fallback(v.record(v.string(), filteredArray(footerItemSchema)), {}),
promoPanel: PromoPanelRecord(), verifiedIcon: v.fallback(v.string(), ''),
navlinks: ImmutableMap({ displayFqn: v.fallback(v.boolean(), true),
homeFooter: ImmutableList<FooterItem>(), cryptoAddresses: filteredArray(cryptoAddressSchema),
cryptoDonatePanel: coerceObject({
limit: v.fallback(v.number(), 1),
}), }),
verifiedIcon: '', aboutPages: v.fallback(v.record(v.string(), coerceObject({
displayFqn: true, defaultLocale: v.fallback(v.string(), ''), // v.fallback(v.optional(v.string()), undefined),
cryptoAddresses: ImmutableList<CryptoAddress>(), locales: filteredArray(v.string()),
cryptoDonatePanel: ImmutableMap({ })), {}),
limit: 1, authenticatedProfile: v.fallback(v.boolean(), false),
}), linkFooterMessage: v.fallback(v.string(), ''),
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(), links: v.fallback(v.record(v.string(), v.string()), {}),
authenticatedProfile: false, displayCta: v.fallback(v.boolean(), false),
linkFooterMessage: '',
links: ImmutableMap<string, string>(),
displayCta: false,
/** Whether to inject suggested profiles into the Home feed. */ /** Whether to inject suggested profiles into the Home feed. */
feedInjection: true, feedInjection: v.fallback(v.boolean(), true),
tileServer: '', tileServer: v.fallback(v.string(), ''),
tileServerAttribution: '', tileServerAttribution: v.fallback(v.string(), ''),
redirectRootNoLogin: '', redirectRootNoLogin: v.fallback(v.pipe(v.string(), v.transform((url: string) => {
if (!url) return '';
try {
// Basically just get the pathname with a leading slash.
const normalized = new URL(url, 'http://a').pathname;
if (normalized !== '/') {
return normalized;
} else {
// Prevent infinite redirect(?)
return '';
}
} catch (e) {
console.error('You have configured an invalid redirect in pl-fe Config.');
console.error(e);
return '';
}
})), ''),
/** /**
* Whether to use the preview URL for media thumbnails. * Whether to use the preview URL for media thumbnails.
* On some platforms this can be too blurry without additional configuration. * On some platforms this can be too blurry without additional configuration.
*/ */
mediaPreview: false, mediaPreview: v.fallback(v.boolean(), false),
sentryDsn: undefined as string | undefined, sentryDsn: v.fallback(v.optional(v.string()), undefined),
}, 'PlFeConfig'); }), v.transform((config) => {
const brandColor: string = config.brandColor || config.colors?.primary?.['500'] || '';
const accentColor: string = config.accentColor || config.colors?.accent?.['500'] || '' || generateAccent(brandColor);
type PlFeConfigMap = ImmutableMap<string, any>; const colors = {
...config.colors,
...Object.fromEntries(Object.entries(DEFAULT_COLORS).map(([key, value]) => [key, typeof value === 'string' ? value : { ...value, ...config.colors?.[key] }])),
};
const normalizeCryptoAddress = (address: unknown): CryptoAddress => const normalizedColors = toTailwind({
CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker => brandColor,
trimStart(ticker, '$').toLowerCase(), accentColor,
); colors,
const normalizeCryptoAddresses = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const addresses = ImmutableList(plFeConfig.get('cryptoAddresses'));
return plFeConfig.set('cryptoAddresses', addresses.map(normalizeCryptoAddress));
};
const normalizeBrandColor = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const brandColor = plFeConfig.get('brandColor') || plFeConfig.getIn(['colors', 'primary', '500']) || '';
return plFeConfig.set('brandColor', brandColor);
};
const normalizeAccentColor = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const brandColor = plFeConfig.get('brandColor');
const accentColor = plFeConfig.get('accentColor')
|| plFeConfig.getIn(['colors', 'accent', '500'])
|| (brandColor ? generateAccent(brandColor) : '');
return plFeConfig.set('accentColor', accentColor);
};
const normalizeColors = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const colors = DEFAULT_COLORS.mergeDeep(plFeConfig.get('colors'));
return toTailwind(plFeConfig.set('colors', colors));
};
const maybeAddMissingColors = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const colors = plFeConfig.get('colors');
const missing = ImmutableMap({
'gradient-start': colors.getIn(['primary', '500']),
'gradient-end': colors.getIn(['accent', '500']),
'accent-blue': colors.getIn(['primary', '600']),
}); });
return plFeConfig.set('colors', missing.mergeDeep(colors)); return {
}; ...config,
brandColor,
accentColor,
colors: normalizedColors,
};
}));
const normalizePromoPanel = (plFeConfig: PlFeConfigMap): PlFeConfigMap => { type PlFeConfig = v.InferOutput<typeof plFeConfigSchema>;
const promoPanel = PromoPanelRecord(plFeConfig.get('promoPanel'));
const items = promoPanel.items.map(PromoPanelItemRecord);
return plFeConfig.set('promoPanel', promoPanel.set('items', items));
};
const normalizeFooterLinks = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const path = ['navlinks', 'homeFooter'];
const items = (plFeConfig.getIn(path, ImmutableList()) as ImmutableList<any>).map(FooterItemRecord);
return plFeConfig.setIn(path, items);
};
/** Single user mode is now managed by `redirectRootNoLogin`. */
const upgradeSingleUserMode = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const singleUserMode = plFeConfig.get('singleUserMode') as boolean | undefined;
const singleUserModeProfile = plFeConfig.get('singleUserModeProfile') as string | undefined;
const redirectRootNoLogin = plFeConfig.get('redirectRootNoLogin') as string | undefined;
if (!redirectRootNoLogin && singleUserMode && singleUserModeProfile) {
return plFeConfig
.set('redirectRootNoLogin', `/@${normalizeUsername(singleUserModeProfile)}`)
.deleteAll(['singleUserMode', 'singleUserModeProfile']);
} else {
return plFeConfig
.deleteAll(['singleUserMode', 'singleUserModeProfile']);
}
};
/** Ensure a valid path is used. */
const normalizeRedirectRootNoLogin = (plFeConfig: PlFeConfigMap): PlFeConfigMap => {
const redirectRootNoLogin = plFeConfig.get('redirectRootNoLogin');
if (!redirectRootNoLogin) return plFeConfig;
try {
// Basically just get the pathname with a leading slash.
const normalized = new URL(redirectRootNoLogin, 'http://a').pathname;
if (normalized !== '/') {
return plFeConfig.set('redirectRootNoLogin', normalized);
} else {
// Prevent infinite redirect(?)
return plFeConfig.delete('redirectRootNoLogin');
}
} catch (e) {
console.error('You have configured an invalid redirect in pl-fe Config.');
console.error(e);
return plFeConfig.delete('redirectRootNoLogin');
}
};
const normalizePlFeConfig = (plFeConfig: Record<string, any>) => PlFeConfigRecord(
ImmutableMap(fromJS(plFeConfig)).withMutations(plFeConfig => {
normalizeBrandColor(plFeConfig);
normalizeAccentColor(plFeConfig);
normalizeColors(plFeConfig);
normalizePromoPanel(plFeConfig);
normalizeFooterLinks(plFeConfig);
maybeAddMissingColors(plFeConfig);
normalizeCryptoAddresses(plFeConfig);
upgradeSingleUserMode(plFeConfig);
normalizeRedirectRootNoLogin(plFeConfig);
}),
);
export { export {
PromoPanelItemRecord, plFeConfigSchema,
FooterItemRecord, type PromoPanelItem,
CryptoAddressRecord, type PromoPanel,
PlFeConfigRecord, type FooterItem,
normalizePlFeConfig, type CryptoAddress,
type PlFeConfig,
}; };

View file

@ -1,4 +1,5 @@
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { create } from 'mutative';
import { CreateStatusParams } from 'pl-api';
import { import {
STATUS_CREATE_FAIL, STATUS_CREATE_FAIL,
@ -9,36 +10,53 @@ import {
import type { StatusVisibility } from 'pl-fe/normalizers/status'; import type { StatusVisibility } from 'pl-fe/normalizers/status';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
const PendingStatusRecord = ImmutableRecord({ interface PendingStatus {
content_type: string;
in_reply_to_id: string | null;
media_ids: Array<string> | null;
quote_id: string | null;
poll: Exclude<CreateStatusParams['poll'], undefined> | null;
sensitive: boolean;
spoiler_text: string;
status: string;
to: Array<string> | null;
visibility: StatusVisibility;
}
const newPendingStatus = (props: Partial<PendingStatus> = {}): PendingStatus => ({
content_type: '', content_type: '',
in_reply_to_id: null as string | null, in_reply_to_id: null,
media_ids: null as ImmutableList<string> | null, media_ids: null,
quote_id: null as string | null, quote_id: null,
poll: null as ImmutableMap<string, any> | null, poll: null,
sensitive: false, sensitive: false,
spoiler_text: '', spoiler_text: '',
status: '', status: '',
to: null as ImmutableList<string> | null, to: null,
visibility: 'public' as StatusVisibility, visibility: 'public',
...props,
}); });
type PendingStatus = ReturnType<typeof PendingStatusRecord>; type State = Record<string, PendingStatus>;
type State = ImmutableMap<string, PendingStatus>;
const initialState: State = ImmutableMap(); const initialState: State = {};
const importStatus = (state: State, params: ImmutableMap<string, any>, idempotencyKey: string) => const importStatus = (state: State, params: Record<string, any>, idempotencyKey: string) => {
state.set(idempotencyKey, PendingStatusRecord(params)); state[idempotencyKey] = newPendingStatus(params);
};
const deleteStatus = (state: State, idempotencyKey: string) => state.delete(idempotencyKey); const deleteStatus = (state: State, idempotencyKey: string) => {
delete state[idempotencyKey];
};
const pending_statuses = (state = initialState, action: AnyAction) => { const pending_statuses = (state = initialState, action: AnyAction): State => {
switch (action.type) { switch (action.type) {
case STATUS_CREATE_REQUEST: case STATUS_CREATE_REQUEST:
return action.editing ? state : importStatus(state, ImmutableMap(fromJS(action.params)), action.idempotencyKey); if (action.editing) return state;
return create(state, (draft) => importStatus(draft, action.params, action.idempotencyKey));
case STATUS_CREATE_FAIL: case STATUS_CREATE_FAIL:
case STATUS_CREATE_SUCCESS: case STATUS_CREATE_SUCCESS:
return deleteStatus(state, action.idempotencyKey); return create(state, (draft) => deleteStatus(draft, action.idempotencyKey));
default: default:
return state; return state;
} }

View file

@ -6,7 +6,7 @@ type TailwindColorObject = {
}; };
type TailwindColorPalette = { type TailwindColorPalette = {
[key: string]: TailwindColorObject | string; [key: string]: TailwindColorObject | string | null;
} }
export type { Rgb, Hsl, TailwindColorObject, TailwindColorPalette }; export type { Rgb, Hsl, TailwindColorObject, TailwindColorPalette };

View file

@ -1,21 +1,3 @@
import {
PromoPanelItemRecord,
FooterItemRecord,
CryptoAddressRecord,
PlFeConfigRecord,
} from 'pl-fe/normalizers/pl-fe/pl-fe-config';
type Me = string | null | false; type Me = string | null | false;
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>; export { Me };
type FooterItem = ReturnType<typeof FooterItemRecord>;
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
type PlFeConfig = ReturnType<typeof PlFeConfigRecord>;
export {
Me,
PromoPanelItem,
FooterItem,
CryptoAddress,
PlFeConfig,
};

View file

@ -1,20 +1,17 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import tintify from 'pl-fe/utils/colors'; import tintify from 'pl-fe/utils/colors';
import { generateAccent, generateNeutral } from 'pl-fe/utils/theme'; import { generateAccent, generateNeutral } from 'pl-fe/utils/theme';
import type { TailwindColorPalette } from 'pl-fe/types/colors'; import type { TailwindColorPalette } from 'pl-fe/types/colors';
type PlFeConfig = ImmutableMap<string, any>; type PlFeColors = Record<string, Record<string, string>>;
type PlFeColors = ImmutableMap<string, any>;
/** Check if the value is a valid hex color */ /** Check if the value is a valid hex color */
const isHex = (value: any): boolean => /^#([0-9A-F]{3}){1,2}$/i.test(value); const isHex = (value: any): boolean => /^#([0-9A-F]{3}){1,2}$/i.test(value);
/** Expand hex colors into tints */ /** Expand hex colors into tints */
const expandPalette = (palette: TailwindColorPalette): TailwindColorPalette => { const expandPalette = (palette: TailwindColorPalette): TailwindColorPalette =>
// Generate palette only for present colors // Generate palette only for present colors
return Object.entries(palette).reduce((result: TailwindColorPalette, colorData) => { Object.entries(palette).reduce((result: TailwindColorPalette, colorData) => {
const [colorName, color] = colorData; const [colorName, color] = colorData;
// Conditionally handle hex color and Tailwind color object // Conditionally handle hex color and Tailwind color object
@ -26,32 +23,36 @@ const expandPalette = (palette: TailwindColorPalette): TailwindColorPalette => {
return result; return result;
}, {}); }, {});
};
// Generate accent color only if brandColor is present // Generate accent color only if brandColor is present
const maybeGenerateAccentColor = (brandColor: any): string | null => const maybeGenerateAccentColor = (brandColor: string): string | null =>
isHex(brandColor) ? generateAccent(brandColor) : null; isHex(brandColor) ? generateAccent(brandColor) : null;
/** Build a color object from legacy colors */ /** Build a color object from legacy colors */
const fromLegacyColors = (plFeConfig: PlFeConfig): TailwindColorPalette => { const fromLegacyColors = ({ brandColor, accentColor }: {
const brandColor = plFeConfig.get('brandColor'); brandColor: string;
const accentColor = plFeConfig.get('accentColor'); accentColor: string | null;
const accent = isHex(accentColor) ? accentColor : maybeGenerateAccentColor(brandColor); }): TailwindColorPalette => {
const accent = typeof accentColor === 'string' && isHex(accentColor) ? accentColor : maybeGenerateAccentColor(brandColor);
return expandPalette({ return expandPalette({
primary: isHex(brandColor) ? brandColor : null, primary: isHex(brandColor) ? brandColor : null,
secondary: accent, secondary: accent,
accent, accent,
gray: (isHex(brandColor) ? generateNeutral(brandColor) : null) as any, gray: (isHex(brandColor) ? generateNeutral(brandColor) : null),
}); });
}; };
/** Convert pl-fe Config into Tailwind colors */ /** Convert pl-fe Config into Tailwind colors */
const toTailwind = (plFeConfig: PlFeConfig): PlFeConfig => { const toTailwind = (config: {
const colors: PlFeColors = ImmutableMap(plFeConfig.get('colors')); brandColor: string;
const legacyColors = ImmutableMap(fromJS(fromLegacyColors(plFeConfig))) as PlFeColors; accentColor: string | null;
colors: Record<string, Record<string, string>>;
}): Record<string, Record<string, string> | string> => {
const colors: PlFeColors = config.colors;
const legacyColors = fromLegacyColors(config);
return plFeConfig.set('colors', legacyColors.mergeDeep(colors)); return Object.fromEntries(Object.entries(legacyColors).map(([key, value]) => [key, typeof value === 'string' ? colors[key] || value : { ...value, ...config.colors[key] }]));
}; };
export { export {

View file

@ -1,7 +1,7 @@
import { hexToRgb } from './colors'; import { hexToRgb } from './colors';
import type { PlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import type { Rgb, Hsl, TailwindColorPalette, TailwindColorObject } from 'pl-fe/types/colors'; import type { Rgb, Hsl, TailwindColorPalette, TailwindColorObject } from 'pl-fe/types/colors';
import type { PlFeConfig } from 'pl-fe/types/pl-fe';
// Taken from chromatism.js // Taken from chromatism.js
// https://github.com/graypegg/chromatism/blob/master/src/conversions/rgb.js // https://github.com/graypegg/chromatism/blob/master/src/conversions/rgb.js
@ -111,7 +111,7 @@ const colorsToCss = (colors: TailwindColorPalette): string => {
}; };
const generateThemeCss = (plFeConfig: PlFeConfig): string => const generateThemeCss = (plFeConfig: PlFeConfig): string =>
colorsToCss(plFeConfig.colors.toJS() as TailwindColorPalette); colorsToCss(plFeConfig.colors);
const hexToHsl = (hex: string): Hsl | null => { const hexToHsl = (hex: string): Hsl | null => {
const rgb = hexToRgb(hex); const rgb = hexToRgb(hex);