refactor: remove @suite/intl dependency from @suite-common/wallet-types, use generic types

This commit is contained in:
Jiří Čermák
2026-03-04 14:10:24 +01:00
committed by Jiří Čermák
parent 6d88b0dc71
commit c2ea8cfa09
22 changed files with 156 additions and 86 deletions

View File

@@ -1,5 +1,9 @@
import { TranslationKey } from '@suite/intl';
import { testMocks } from '@suite-common/test-utils';
import { notificationsActions, notificationsReducer } from '@suite-common/toast-notifications';
import {
createNotificationsReducer,
notificationsActions,
} from '@suite-common/toast-notifications';
import {
AccountsState,
BlockchainState,
@@ -24,6 +28,8 @@ import * as fixtures from '../__fixtures__/blockchainActions';
const TrezorConnect = testMocks.getTrezorConnectMock();
const { reducer: notificationsReducer } = createNotificationsReducer<TranslationKey>();
interface Args {
accounts?: AccountsState;
blockchain?: Partial<BlockchainState>;

View File

@@ -1,7 +1,7 @@
import { ComponentType, JSX } from 'react';
import { useSelector } from 'react-redux';
import { ExtendedMessageDescriptor, Translation } from '@suite/intl';
import { ExtendedMessageDescriptor, Translation, TranslationKey } from '@suite/intl';
import { selectSelectedDeviceLabelOrName } from '@suite-common/device';
import { AUTH_DEVICE, type NotificationEntry } from '@suite-common/toast-notifications';
import { getNetworkDisplaySymbol } from '@suite-common/wallet-config';
@@ -15,11 +15,13 @@ import { ExchangeInfoRenderer } from './ExchangeInfoRenderer';
import { TransactionRenderer } from './TransactionRenderer';
import { NotificationViewProps } from '../Notifications/NotificationGroup/NotificationList/NotificationView';
type LocalizedNotificationEntry = NotificationEntry<TranslationKey>;
export type NotificationRendererProps<
T extends NotificationEntry['type'] = NotificationEntry['type'],
T extends LocalizedNotificationEntry['type'] = LocalizedNotificationEntry['type'],
> = {
render: ComponentType<{ onCancel?: () => void } & NotificationViewProps>;
notification: Extract<NotificationEntry, { type: T }>;
notification: Extract<LocalizedNotificationEntry, { type: T }>;
};
type RenderConfig = {

View File

@@ -1,8 +1,9 @@
import { TranslationKey } from '@suite/intl';
import { NotificationEntry } from '@suite-common/toast-notifications';
import { ToastNotificationView } from './ToastNotificationView';
import { NotificationRenderer } from '../NotificationRenderer/NotificationRenderer';
export const renderToast = (payload: NotificationEntry) => (
export const renderToast = (payload: NotificationEntry<TranslationKey>) => (
<NotificationRenderer notification={payload} render={ToastNotificationView} />
);

View File

@@ -1,10 +1,13 @@
import { toast } from 'react-toastify/unstyled';
import { TranslationKey } from '@suite/intl';
import type { NotificationEntry } from '@suite-common/toast-notifications';
import { renderToast } from './renderToast';
const sanitize = (payload: NotificationEntry): NotificationEntry => {
const sanitize = (
payload: NotificationEntry<TranslationKey>,
): NotificationEntry<TranslationKey> => {
const next = { ...payload };
if (typeof next.error === 'string' && next.error.includes('assetType:')) {
@@ -14,7 +17,7 @@ const sanitize = (payload: NotificationEntry): NotificationEntry => {
return next;
};
export const showToast = (payload: NotificationEntry) => {
export const showToast = (payload: NotificationEntry<TranslationKey>) => {
const entry = sanitize(payload);
toast(renderToast(entry), {

View File

@@ -3,7 +3,7 @@ import { FieldPath, UseFormReturn } from 'react-hook-form';
import { isFulfilled } from '@reduxjs/toolkit';
import { useTranslation } from '@suite/intl';
import { isTranslationKey, useTranslation } from '@suite/intl';
import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants';
import { composeSendFormTransactionFeeLevelsThunk } from '@suite-common/wallet-core';
import {
@@ -123,7 +123,7 @@ export const useCompose = <TFieldValues extends FormState>({
const values = getValues();
if (composed.type === 'error') {
const { error, errorMessage } = composed;
if (!errorMessage) {
if (!errorMessage || !isTranslationKey(errorMessage.id)) {
// composed tx doesn't have an errorMessage (Translation props)
// this error is unexpected and should be handled in sendFormActions
console.warn('Compose unexpected error', error);
@@ -176,8 +176,12 @@ export const useCompose = <TFieldValues extends FormState>({
...composedLevels,
custom: prevLevel,
} as
| (PrecomposedLevels & { custom: PrecomposedTransaction })
| (PrecomposedLevelsCardano & { custom: PrecomposedTransactionCardano });
| (PrecomposedLevels & {
custom: PrecomposedTransaction;
})
| (PrecomposedLevelsCardano & {
custom: PrecomposedTransactionCardano;
});
setComposedLevels(levels);
} else {
const currentLevel = composedLevels[current || 'normal'];

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { FieldPath, UseFormReturn } from 'react-hook-form';
import { useTranslation } from '@suite/intl';
import { isTranslationKey, useTranslation } from '@suite/intl';
import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants';
import {
ComposeActionContext,
@@ -107,7 +107,7 @@ export const useStakeCompose = <TFieldValues extends StakeFormState>({
if (composed.type === 'error') {
const { error, errorMessage } = composed;
if (!errorMessage) {
if (!errorMessage || !isTranslationKey(errorMessage.id)) {
// composed tx doesn't have an errorMessage (Translation props)
// this error is unexpected and should be handled in sendFormActions
console.warn('Compose unexpected error', error);
@@ -159,7 +159,9 @@ export const useStakeCompose = <TFieldValues extends StakeFormState>({
const levels = {
...composedLevels,
custom: prevLevel,
} as PrecomposedLevels & { custom: PrecomposedTransaction };
} as PrecomposedLevels & {
custom: PrecomposedTransaction;
};
setComposedLevels(levels);
} else {
const currentLevel = composedLevels[current || 'normal'];

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { useTranslation } from '@suite/intl';
import { isTranslationKey, useTranslation } from '@suite/intl';
import { selectSelectedDevice } from '@suite-common/device';
import {
TRADING_FORM_OUTPUT_ADDRESS,
@@ -142,7 +142,7 @@ export const useTradingComposeTransaction = <T extends TradingSellExchangeFormPr
if (!composed) return;
if (composed.type === 'error' && composed.errorMessage) {
if (composed.type === 'error' && isTranslationKey(composed.errorMessage?.id)) {
setError(TRADING_FORM_OUTPUT_AMOUNT, {
type: COMPOSE_ERROR_TYPES.COMPOSE,
message: translationString(composed.errorMessage.id, composed.errorMessage.values),

View File

@@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux';
import { isFulfilled } from '@reduxjs/toolkit';
import { TranslationKey, useTranslation } from '@suite/intl';
import { TranslationKey, isTranslationKey, useTranslation } from '@suite/intl';
import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants';
import { composeSendFormTransactionFeeLevelsThunk } from '@suite-common/wallet-core';
import {
@@ -175,7 +175,7 @@ export const useSendFormCompose = ({
if (!composed) return;
if (composed.type === 'error') {
const { error, errorMessage } = composed;
if (!errorMessage) {
if (!errorMessage || !isTranslationKey(errorMessage.id)) {
// composed tx doesn't have an errorMessage (Translation props)
// this error is unexpected and should be handled in sendFormActions
console.warn('Compose unexpected error', error);
@@ -296,8 +296,12 @@ export const useSendFormCompose = ({
...composedLevels,
custom: prevLevel,
} as
| (PrecomposedLevels & { custom: PrecomposedTransaction })
| (PrecomposedLevelsCardano & { custom: PrecomposedTransactionCardano });
| (PrecomposedLevels & {
custom: PrecomposedTransaction;
})
| (PrecomposedLevelsCardano & {
custom: PrecomposedTransactionCardano;
});
setComposedLevels(level);
} else {
const currentLevel = composedLevels[current || 'normal'];

View File

@@ -1,7 +1,8 @@
import { TranslationKey } from '@suite/intl';
import {
NotificationEntry,
createNotificationsReducer,
notificationsActions,
notificationsReducer,
} from '@suite-common/toast-notifications';
import { PROTOCOL } from 'src/actions/suite/constants';
@@ -12,6 +13,7 @@ import protocolMiddleware from '../protocolMiddleware';
const middlewares = [protocolMiddleware];
const { reducer: notificationsReducer } = createNotificationsReducer<TranslationKey>();
type ProtocolState = ReturnType<typeof protocolReducer>;
type NotificationsState = ReturnType<typeof notificationsReducer>;
@@ -38,7 +40,7 @@ const initStore = (state: State) => {
store.getState().protocol = protocolReducer(protocol, action);
store.getState().notifications = notificationsReducer(
notifications as NotificationEntry[],
notifications as NotificationEntry<TranslationKey>[],
action,
);

View File

@@ -1,5 +1,6 @@
import { TranslationKey } from '@suite/intl';
import { createMiddleware } from '@suite-common/redux-utils';
import { notificationsActions } from '@suite-common/toast-notifications';
import { NotificationEntry, notificationsActions } from '@suite-common/toast-notifications';
import { dismissToast, showToast } from 'src/components/suite';
@@ -9,7 +10,7 @@ export const toastMiddleware = createMiddleware((action, { next }) => {
}
if (notificationsActions.addToast.match(action)) {
showToast(action.payload);
showToast(action.payload as NotificationEntry<TranslationKey>);
}
return next(action);

View File

@@ -1,10 +1,11 @@
import { experimentalFeedbackReducer } from '@suite/experimental-feedback';
import { TranslationKey } from '@suite/intl';
import { metadataReducer } from '@suite/metadata';
import { prepareAnalyticsReducer } from '@suite-common/analytics-redux';
import { prepareConnectPopupReducer } from '@suite-common/connect-popup';
import { logsSlice } from '@suite-common/logger';
import { prepareMessageSystemReducer } from '@suite-common/message-system';
import { notificationsReducer } from '@suite-common/toast-notifications';
import { createNotificationsReducer } from '@suite-common/toast-notifications';
import { prepareWalletConnectReducer } from '@suite-common/walletconnect';
import { deviceSlice } from 'src/actions/device/deviceSlice';
@@ -31,7 +32,7 @@ export default {
modal,
device,
logs: logsSlice.reducer,
notifications: notificationsReducer,
notifications: createNotificationsReducer<TranslationKey>().reducer,
window,
analytics,
metadata: metadataReducer,

View File

@@ -0,0 +1,9 @@
import { isTranslationKey } from '@suite/intl';
import { SUITE_PRECOMPOSE_ERRORS } from '@suite-common/wallet-types';
it.each(Object.values(SUITE_PRECOMPOSE_ERRORS))(
'precomposed error %s should be translatable',
error => {
expect(isTranslationKey(error)).toBe(true);
},
);

View File

@@ -1,3 +1,4 @@
import { TranslationKey } from '@suite/intl';
import { NotificationEntry } from '@suite-common/toast-notifications';
import { intermediaryTheme } from '@trezor/components';
@@ -39,8 +40,8 @@ export const filterNonActivityNotifications = (notifications: AppState['notifica
notifications.filter(notification => notification.type !== 'coin-scheme-protocol');
export const getSeenAndUnseenNotifications = (notifications: AppState['notifications']) => {
const seen: Array<NotificationEntry> = [];
const unseen: Array<NotificationEntry> = [];
const seen: Array<NotificationEntry<TranslationKey>> = [];
const unseen: Array<NotificationEntry<TranslationKey>> = [];
// loop over all notifications and check which of them there were seen or not
filterNonActivityNotifications(notifications).forEach(notification => {

View File

@@ -1,39 +1,48 @@
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
import { WritableDraft, createReducer, isAnyOf } from '@reduxjs/toolkit';
import { notificationsActions } from './notificationsActions';
import { NotificationsState } from './types';
import { NotificationEntry, NotificationsState, UnknownTranslationKey } from './types';
export const notificationsInitialState: NotificationsState = [];
export function createNotificationsReducer<
TranslationKey extends UnknownTranslationKey = UnknownTranslationKey,
>() {
const notificationsInitialState: NotificationsState<TranslationKey> = [];
export const notificationsReducer = createReducer(notificationsInitialState, builder => {
builder
.addCase(notificationsActions.close, (state, { payload }) => {
const item = state.find(n => n.id === payload);
if (item) item.closed = true;
})
.addCase(notificationsActions.resetUnseen, (state, { payload }) => {
if (!payload) {
state.forEach(n => {
if (!n.seen) n.seen = true;
const notificationsReducer = createReducer(notificationsInitialState, builder => {
builder
.addCase(notificationsActions.close, (state, { payload }) => {
const item = state.find(n => n.id === payload);
if (item) item.closed = true;
})
.addCase(notificationsActions.resetUnseen, (state, { payload }) => {
if (!payload) {
state.forEach(n => {
if (!n.seen) n.seen = true;
});
} else {
payload.forEach(p => {
const item = state.find(n => n.id === p.id);
if (item) item.seen = true;
});
}
})
.addCase(notificationsActions.remove, (state, { payload }) => {
const arr = !Array.isArray(payload) ? [payload] : payload;
arr.forEach(item => {
const index = state.findIndex(n => n.id === item.id);
state.splice(index, 1);
});
} else {
payload.forEach(p => {
const item = state.find(n => n.id === p.id);
if (item) item.seen = true;
});
}
})
.addCase(notificationsActions.remove, (state, { payload }) => {
const arr = !Array.isArray(payload) ? [payload] : payload;
arr.forEach(item => {
const index = state.findIndex(n => n.id === item.id);
state.splice(index, 1);
});
})
.addMatcher(
isAnyOf(notificationsActions.addEvent, notificationsActions.addToast),
(state, { payload }) => {
state.unshift(payload);
},
);
});
})
.addMatcher(
isAnyOf(notificationsActions.addEvent, notificationsActions.addToast),
(state, { payload }) => {
state.unshift(payload as WritableDraft<NotificationEntry<TranslationKey>>);
},
);
});
return {
reducer: notificationsReducer,
initialState: notificationsInitialState,
};
}

View File

@@ -1,11 +1,13 @@
import { configureMockStore } from '@suite-common/test-utils';
import { notificationsActions } from '../notificationsActions';
import { notificationsReducer } from '../notificationsReducer';
import { createNotificationsReducer } from '../notificationsReducer';
import { selectNotifications } from '../notificationsSelectors';
import { removeAccountEventsThunk, removeTransactionEventsThunk } from '../notificationsThunks';
import { NotificationsRootState, NotificationsState } from '../types';
const { reducer: notificationsReducer } = createNotificationsReducer();
interface InitStoreArgs {
preloadedState?: NotificationsRootState;
}

View File

@@ -13,7 +13,6 @@
"@suite-common/metadata-types": "workspace:*",
"@suite-common/wallet-config": "workspace:*",
"@suite-common/wallet-constants": "workspace:*",
"@suite/intl": "workspace:*",
"@trezor/blockchain-link-types": "workspace:*",
"@trezor/connect": "workspace:*",
"@trezor/type-utils": "workspace:*",

View File

@@ -1,5 +1,3 @@
// eslint-disable-next-line local-rules/no-suite-imports-in-suite-common
import { TranslationKey } from '@suite/intl';
import { Network, NetworkSymbol } from '@suite-common/wallet-config';
import { BaseCurrencyCode } from '@trezor/blockchain-link-types';
import {
@@ -18,25 +16,52 @@ import {
StaticSessionId,
TokenInfo,
} from '@trezor/connect';
import { Branded, RequiredKey } from '@trezor/type-utils';
import { Branded, ObjectValues, RequiredKey } from '@trezor/type-utils';
import { Account, AccountDescriptor } from './account';
export type { PrecomposedTransactionFinalCardano } from '@trezor/connect';
export const COMMON_PRECOMPOSE_ERRORS = {
AMOUNT_NOT_ENOUGH_CURRENCY_FEE: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE',
AMOUNT_IS_NOT_ENOUGH: 'AMOUNT_IS_NOT_ENOUGH',
AMOUNT_IS_TOO_LOW: 'AMOUNT_IS_TOO_LOW',
AMOUNT_IS_LESS_THAN_RESERVE: 'AMOUNT_IS_LESS_THAN_RESERVE',
REMAINING_BALANCE_LESS_THAN_RENT: 'REMAINING_BALANCE_LESS_THAN_RENT',
AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT:
'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT',
} as const satisfies Record<string, string>;
/**
* @trezor/suite (packages/suite) errors
*/
export const SUITE_PRECOMPOSE_ERRORS = {
...COMMON_PRECOMPOSE_ERRORS,
TR_NOT_ENOUGH_SELECTED: 'TR_NOT_ENOUGH_SELECTED',
TR_NOT_ENOUGH_ANONYMIZED_FUNDS_WARNING: 'TR_NOT_ENOUGH_ANONYMIZED_FUNDS_WARNING',
TR_GENERIC_ERROR_TITLE: 'TR_GENERIC_ERROR_TITLE',
} as const satisfies Record<string, string>;
export type SuitePrecomposeError = ObjectValues<typeof SUITE_PRECOMPOSE_ERRORS>;
/**
* @suite-native/* errors
*/
export const SUITE_NATIVE_PRECOMPOSE_ERRORS = {
...COMMON_PRECOMPOSE_ERRORS,
TR_STAKE_NOT_ENOUGH_FUNDS: 'TR_STAKE_NOT_ENOUGH_FUNDS',
} as const satisfies Record<string, string>;
export type SuiteNativePrecomposeError = ObjectValues<typeof SUITE_NATIVE_PRECOMPOSE_ERRORS>;
export type PrecomposeError = SuiteNativePrecomposeError | SuitePrecomposeError;
// extend errors from @trezor/connect + @trezor/utxo-lib with errors from sendForm actions
type PrecomposedTransactionErrorExtended =
| PrecomposedTransactionConnectResponseError
| {
type: 'error';
error:
| 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE'
| 'AMOUNT_IS_NOT_ENOUGH'
| 'AMOUNT_IS_TOO_LOW'
| 'AMOUNT_IS_LESS_THAN_RESERVE'
| 'TR_STAKE_NOT_ENOUGH_FUNDS'
| 'REMAINING_BALANCE_LESS_THAN_RENT'
| 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT';
error: PrecomposeError;
};
export type PrecomposedTransactionCardanoNonFinal =
@@ -105,7 +130,7 @@ export type ExternalOutput = Exclude<ComposeOutput, { type: 'opreturn' } | { add
type ComposeError = {
errorMessage?: {
id: TranslationKey;
id: PrecomposeError;
values?: Record<string, string>;
};
};
@@ -187,7 +212,9 @@ export type GeneralPrecomposedTransactionFinal = Extract<
>;
export type PrecomposedLevels = Record<string, PrecomposedTransaction>;
export type PrecomposedLevelsCardano = Record<string, PrecomposedTransactionCardano>;
export type GeneralPrecomposedLevels = PrecomposedLevels | PrecomposedLevelsCardano;
export interface RbfTransactionParamsBitcoin {

View File

@@ -5,7 +5,6 @@
{ "path": "../metadata-types" },
{ "path": "../wallet-config" },
{ "path": "../wallet-constants" },
{ "path": "../../suite/intl" },
{
"path": "../../packages/blockchain-link-types"
},

View File

@@ -10,7 +10,6 @@ import {
selectTradingExchangeSelectedQuote,
} from '@suite-common/trading';
import { events } from '@suite-native/analytics';
import { TxKeyPath } from '@suite-native/intl';
import { useAnalytics } from '@suite-native/services';
import { buildTradingUrl, useBrowserAuth } from '@suite-native/trading-browser-auth';
import { selectExchangeSelectedSendAccount } from '@suite-native/trading-state';
@@ -27,7 +26,7 @@ export type TradingExchangeConfirmTradeProps = {
export type TradingExchangeSignAndSendTransactionProps = {
nextStep: () => void;
onError: (error: TradingSendRejectedProps<TxKeyPath>) => void;
onError: (error: TradingSendRejectedProps) => void;
};
export const useExchangeFlow = () => {

View File

@@ -8,7 +8,7 @@ import { messageSystemInitialState } from '@suite-common/message-system';
import { initialSuiteSyncDataState, initialSuiteSyncState } from '@suite-common/suite-sync';
import { quotaManagerInitialState } from '@suite-common/suite-sync-quota-manager';
import { initialThpState } from '@suite-common/thp';
import { notificationsInitialState } from '@suite-common/toast-notifications';
import { createNotificationsReducer } from '@suite-common/toast-notifications';
import { tokenDefinitionsInitialState } from '@suite-common/token-definitions';
import {
accountsInitialState,
@@ -30,7 +30,7 @@ import { deviceOnboardingSliceInitialState } from '@suite-native/device-onboardi
import { featureFlagsInitialState } from '@suite-native/feature-flags';
import { nativeFirmwareInitialState } from '@suite-native/firmware';
import { graphInitialState } from '@suite-native/graph';
import { localeInitialState } from '@suite-native/intl';
import { TxKeyPath, localeInitialState } from '@suite-native/intl';
import { appSettingsInitialState } from '@suite-native/settings';
import { tradingInitialState } from '@suite-native/trading-state';
import { sendFormInitialState } from '@suite-native/transaction-management';
@@ -60,7 +60,7 @@ export const mockInitialAppState = (partialState?: Partial<FullAppState>): FullA
logs: logsSliceInitialState,
messageSystem: messageSystemInitialState,
nativeFirmware: nativeFirmwareInitialState,
notifications: notificationsInitialState,
notifications: createNotificationsReducer<TxKeyPath>().initialState,
suiteSync: initialSuiteSyncState,
suiteSyncData: initialSuiteSyncDataState,
thp: initialThpState,

View File

@@ -14,7 +14,7 @@ import {
import { suiteSyncDataReducer, suiteSyncReducer } from '@suite-common/suite-sync';
import { suiteSyncQuotaManagerReducer } from '@suite-common/suite-sync-quota-manager';
import { prepareThpReducer } from '@suite-common/thp';
import { notificationsReducer } from '@suite-common/toast-notifications';
import { createNotificationsReducer } from '@suite-common/toast-notifications';
import { prepareTokenDefinitionsReducer } from '@suite-common/token-definitions';
import {
feesReducer,
@@ -40,7 +40,7 @@ import { deviceOnboardingReducer } from '@suite-native/device-onboarding';
import { featureFlagsPersistedKeys, featureFlagsReducer } from '@suite-native/feature-flags';
import { nativeFirmwareReducer } from '@suite-native/firmware';
import { graphPersistTransform, graphReducer } from '@suite-native/graph';
import { localePersistWhitelist, localeReducer } from '@suite-native/intl';
import { TxKeyPath, localePersistWhitelist, localeReducer } from '@suite-native/intl';
import { appSettingsPersistWhitelist, appSettingsReducer } from '@suite-native/settings';
import {
MMKVStorageDep,
@@ -350,7 +350,7 @@ export const prepareRootReducers = (deps: PrepareRootReducersDeps) => {
logs: logsSlice.reducer,
messageSystem: messageSystemPersistedReducer,
nativeFirmware: nativeFirmwareReducer,
notifications: notificationsReducer,
notifications: createNotificationsReducer<TxKeyPath>().reducer,
suiteSync: suiteSyncPersistedReducer,
suiteSyncData: suiteSyncDataReducer,
thp: thpPersistedReducer,

View File

@@ -11691,7 +11691,6 @@ __metadata:
"@suite-common/metadata-types": "workspace:*"
"@suite-common/wallet-config": "workspace:*"
"@suite-common/wallet-constants": "workspace:*"
"@suite/intl": "workspace:*"
"@trezor/blockchain-link-types": "workspace:*"
"@trezor/connect": "workspace:*"
"@trezor/type-utils": "workspace:*"