Localization: Better platform support (#5136)

This commit is contained in:
zubiden 2024-11-09 15:40:11 +04:00 committed by Alexander Zinchuk
parent 6babbae9f9
commit e678824a10
64 changed files with 1105 additions and 886 deletions

View File

@ -3,38 +3,42 @@ import { readFileSync, writeFileSync } from 'fs';
import readStrings from '../src/util/data/readStrings';
const TOP_COMMENT = '// This file is generated by dev/generateLangTypes.ts. Do not edit it manually.\n';
const LANG_KEY_TYPE = 'export type LangKey = keyof LangPair;';
const LANG_KEY_TYPE = `
export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;
export type PluralLangKey = keyof LangPairPlural;
export type PluralLangKeyWithVariables = keyof LangPairPluralWithVariables;
export type LangKey = RegularLangKey | RegularLangKeyWithVariables | PluralLangKey | PluralLangKeyWithVariables;
type LangVariable = string | number | undefined;
`.trim();
const data = readFileSync('./src/assets/localization/fallback.strings', 'utf8');
const parsed = readStrings(data);
const keysWithVars = Object.entries(parsed).reduce((acc, [keyWithSuffix, value]) => {
const key = keyWithSuffix.split('_')[0];
const regularKeysWithVars: Record<string, string[]> = {};
const pluralKeysWithVars: Record<string, string[]> = {};
Object.entries(parsed).forEach(([keyWithSuffix, value]) => {
const [key, pluralSuffix] = keyWithSuffix.split('_');
const variables = extractVariables(value);
const acc = pluralSuffix ? pluralKeysWithVars : regularKeysWithVars;
const previousVariables = acc[key] || [];
if (!previousVariables.length) {
acc[key] = variables;
} else {
acc[key] = Array.from(new Set([...previousVariables, ...variables]));
}
return acc;
}, {} as Record<string, string[]>);
let entries = '';
Object.entries(keysWithVars).forEach(([key, variables]) => {
const varString = variables.length
? `{\n ${variables.map((v) => `${wrapInQuotes(v)}: string | number;`).join('\n ')}\n };\n`
: 'undefined;\n';
entries += ` ${wrapInQuotes(key)}: ${varString}`;
previousVariables.push(...variables);
acc[key] = previousVariables;
});
const langPair = `export interface LangPair {\n${entries}\n}\n`;
const text = `${TOP_COMMENT}\n${langPair}\n${LANG_KEY_TYPE}\n`;
const regularTypes = formatKeyWithVariables(false, regularKeysWithVars);
const pluralTypes = formatKeyWithVariables(true, pluralKeysWithVars);
const text = `${TOP_COMMENT}\n${regularTypes}\n${pluralTypes}${LANG_KEY_TYPE}\n`;
writeFileSync('./src/types/language.d.ts', text, 'utf8');
// eslint-disable-next-line no-console
console.log(`Language types generated: ${Object.keys(keysWithVars).length} keys`);
console.log(`Language types generated
${Object.keys(regularKeysWithVars).length} simple keys
${Object.keys(pluralKeysWithVars).length} plural keys
`);
function extractVariables(value: string) {
const matches = value.match(/(?<!\\){[^{}]+}/g);
@ -45,3 +49,23 @@ function extractVariables(value: string) {
function wrapInQuotes(value: string) {
return `'${value}'`;
}
function formatKeyWithVariables(isPlural: boolean, keysWithVars: Record<string, string[]>) {
let entries = '';
let variableEntries = '';
Object.entries(keysWithVars).forEach(([key, variables]) => {
const uniqueVariables = variables.length ? Array.from(new Set(variables)) : undefined;
if (uniqueVariables) {
const type = `{\n ${uniqueVariables.map((v) => `${wrapInQuotes(v)}: V;`).join('\n ')}\n };\n`;
variableEntries += ` ${wrapInQuotes(key)}: ${type}`;
} else {
entries += ` ${wrapInQuotes(key)}: undefined;\n`;
}
});
const typeName = isPlural ? 'LangPairPlural' : 'LangPair';
return `export interface ${typeName} {\n${entries}}\n
export interface ${typeName}WithVariables<V extends unknown = LangVariable> {\n${variableEntries}}\n`;
}

View File

@ -16,7 +16,7 @@ import type {
import {
APP_CODE_NAME,
DEBUG, DEBUG_GRAMJS, IS_TEST, UPLOAD_WORKERS,
DEBUG, DEBUG_GRAMJS, IS_TEST, LANG_PACK, UPLOAD_WORKERS,
} from '../../../config';
import { pause } from '../../../util/schedulers';
import {
@ -95,7 +95,9 @@ export async function init(initialArgs: ApiInitialArgs) {
shouldForceHttpTransport,
shouldAllowHttpTransport,
dcId,
langPack: LANG_PACK,
langCode,
systemLangCode: navigator.language,
isTestServerRequested,
} as any,
);

View File

@ -2,7 +2,7 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type { LANG_PACKS } from '../../../config';
import type { ApiInputPrivacyRules, ApiPrivacyKey, LangCode } from '../../../types';
import type { ApiInputPrivacyRules, ApiPrivacyKey } from '../../../types';
import type {
ApiAppConfig,
ApiConfig,
@ -14,7 +14,10 @@ import type {
} from '../../types';
import {
ACCEPTABLE_USERNAME_ERRORS, BLOCKED_LIST_LIMIT, DEFAULT_LANG_PACK, MAX_INT_32,
ACCEPTABLE_USERNAME_ERRORS,
BLOCKED_LIST_LIMIT,
MAX_INT_32,
OLD_DEFAULT_LANG_PACK,
} from '../../../config';
import { buildCollectionByKey } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
@ -466,7 +469,7 @@ export async function fetchLangDifference({
export async function fetchLanguages(): Promise<ApiLanguage[] | undefined> {
const result = await invokeRequest(new GramJs.langpack.GetLanguages({
langPack: DEFAULT_LANG_PACK,
langPack: OLD_DEFAULT_LANG_PACK,
}));
if (!result) {
return undefined;
@ -646,7 +649,7 @@ export async function fetchTimezones(hash?: number) {
};
}
export async function fetchCountryList({ langCode = 'en' }: { langCode?: LangCode }) {
export async function fetchCountryList({ langCode = 'en' }: { langCode?: string }) {
const countryList = await invokeRequest(new GramJs.help.GetCountriesList({
langCode,
}));

View File

@ -47,6 +47,7 @@ import {
import {
buildApiNotifyException,
buildApiNotifyExceptionTopic,
buildLangStrings,
buildPrivacyKey,
} from '../apiBuilders/misc';
import { buildApiEmojiStatus, buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
@ -1050,7 +1051,20 @@ export function updater(update: Update) {
'@type': 'updatePaidReactionPrivacy',
isPrivate: update.private,
});
} else if (update instanceof LocalUpdatePremiumFloodWait) {
} else if (update instanceof GramJs.UpdateLangPackTooLong) {
sendApiUpdate({
'@type': 'updateLangPackTooLong',
langCode: update.langCode,
});
} else if (update instanceof GramJs.UpdateLangPack) {
const { strings, keysToRemove } = buildLangStrings(update.difference.strings);
sendApiUpdate({
'@type': 'updateLangPack',
version: update.difference.version,
strings,
keysToRemove,
});
} else if (update instanceof LocalUpdatePremiumFloodWait) { // Local updates
sendApiUpdate({
'@type': 'updatePremiumFloodWait',
isUpload: update.isUpload,

View File

@ -1,5 +1,8 @@
import type { TeactNode } from '../../lib/teact/teact';
import type { ApiLimitType, ApiPremiumSection, CallbackAction } from '../../global/types';
import type { IconName } from '../../types/icons';
import type { LangFnParameters } from '../../util/localization';
import type { ApiDocument, ApiPhoto, ApiReaction } from './messages';
import type { ApiUser } from './users';
@ -111,10 +114,11 @@ export type ApiNotifyException = {
export type ApiNotification = {
localId: string;
title?: string;
message: string;
containerSelector?: string;
title?: string | LangFnParameters;
message: TeactNode | LangFnParameters;
cacheBreaker?: string;
actionText?: string;
actionText?: string | LangFnParameters;
action?: CallbackAction | CallbackAction[];
className?: string;
duration?: number;

View File

@ -34,6 +34,7 @@ import type {
import type {
ApiEmojiInteraction, ApiError, ApiNotifyException, ApiSessionData,
} from './misc';
import type { LangPackStringValue } from './settings';
import type { ApiStealthMode, ApiStory, ApiStorySkipped } from './stories';
import type {
ApiEmojiStatus, ApiUser, ApiUserFullInfo, ApiUserStatus,
@ -774,6 +775,18 @@ export type ApiUpdatePaidReactionPrivacy = {
isPrivate: boolean;
};
export type ApiUpdateLangPackTooLong = {
'@type': 'updateLangPackTooLong';
langCode: string;
};
export type ApiUpdateLangPack = {
'@type': 'updateLangPack';
version: number;
strings: Record<string, LangPackStringValue>;
keysToRemove: string[];
};
export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -806,7 +819,8 @@ export type ApiUpdate = (
ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage |
ApiUpdateDeleteSavedHistory | ApiUpdatePremiumFloodWait | ApiUpdateStarsBalance |
ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies | ApiDeleteQuickReply | ApiUpdateDeleteQuickReplyMessages |
ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities | ApiUpdatePaidReactionPrivacy
ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities | ApiUpdatePaidReactionPrivacy |
ApiUpdateLangPackTooLong | ApiUpdateLangPack
);
export type OnApiUpdate = (update: ApiUpdate) => void;

View File

@ -668,7 +668,6 @@
"DiscussChannel" = "channel";
"ForwardedMessage" = "Forwarded message";
"ContextForwardMsg" = "Forward";
"ShareLinkCopied" = "Copied to Clipboard";
"MessageScheduleSend" = "Send Now";
"MessageScheduleEditTime" = "Reschedule";
"Reply" = "Reply";

View File

@ -7,7 +7,6 @@ import { getActions, withGlobal } from '../../global';
import type { ApiCountryCode } from '../../api/types';
import type { GlobalState } from '../../global/types';
import type { LangCode } from '../../types';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import { preloadImage } from '../../util/files';
@ -36,7 +35,7 @@ type StateProps = Pick<GlobalState, (
'authIsLoadingQrCode' | 'authError' |
'authRememberMe' | 'authNearestCountry'
)> & {
language?: LangCode;
language?: string;
phoneCodeList: ApiCountryCode[];
};

View File

@ -5,7 +5,6 @@ import React, {
import { getActions, withGlobal } from '../../global';
import type { GlobalState } from '../../global/types';
import type { LangCode } from '../../types';
import { DEFAULT_LANG_CODE, STRICTERDOM_ENABLED } from '../../config';
import { disableStrict, enableStrict } from '../../lib/fasterdom/stricterdom';
@ -29,7 +28,7 @@ import blankUrl from '../../assets/blank.png';
type StateProps =
Pick<GlobalState, 'connectionState' | 'authState' | 'authQrCode'>
& { language?: LangCode };
& { language?: string };
const DATA_PREFIX = 'tg://login?token=';
const QR_SIZE = 280;

View File

@ -6,7 +6,7 @@ import { getActions } from '../../global';
import type { ApiAudio, ApiMessage, ApiVoice } from '../../api/types';
import type { BufferedRange } from '../../hooks/useBuffering';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import type { ISettings } from '../../types';
import { ApiMediaFormat } from '../../api/types';
import { AudioOrigin } from '../../types';
@ -495,7 +495,7 @@ function getSeeklineSpikeAmounts(isMobile?: boolean, withAvatar?: boolean) {
}
function renderAudio(
lang: LangFn,
lang: OldLangFn,
audio: ApiAudio,
duration: number,
isPlaying: boolean,

View File

@ -3,7 +3,7 @@ import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../lib/teact/teact';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import { MAX_INT_32 } from '../../config';
import buildClassName from '../../util/buildClassName';
@ -401,7 +401,7 @@ function formatDay(year: number, month: number, day: number) {
return `${year}-${month + 1}-${day}`;
}
function formatSubmitLabel(lang: LangFn, date: Date) {
function formatSubmitLabel(lang: OldLangFn, date: Date) {
const day = formatDateToString(date, lang.code);
const today = formatDateToString(new Date(), lang.code);

View File

@ -43,7 +43,9 @@ const InviteLink: FC<OwnProps> = ({
const copyLink = useLastCallback(() => {
copyTextToClipboard(link);
showNotification({
message: lang('LinkCopied'),
message: {
key: 'LinkCopied',
},
});
});

View File

@ -4,7 +4,7 @@ import type {
ApiChat, ApiGroupCall, ApiMessage, ApiTopic, ApiUser,
} from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { LangFn } from '../../../hooks/useOldLang';
import type { OldLangFn } from '../../../hooks/useOldLang';
import type { TextPart } from '../../../types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
@ -36,7 +36,7 @@ const MAX_LENGTH = 32;
const NBSP = '\u00A0';
export function renderActionMessageText(
oldLang: LangFn,
oldLang: OldLangFn,
message: ApiMessage,
actionOriginUser?: ApiUser,
actionOriginChat?: ApiChat,
@ -269,7 +269,7 @@ function renderProductContent(message: ApiMessage) {
}
function renderMessageContent(
lang: LangFn,
lang: OldLangFn,
message: ApiMessage,
options: RenderOptions = {},
observeIntersectionForLoading?: ObserveFn,
@ -318,7 +318,7 @@ function renderUserContent(sender: ApiUser, noLinks?: boolean): string | TextPar
return <UserLink className="action-link" sender={sender}>{sender && renderText(text!)}</UserLink>;
}
function renderChatContent(lang: LangFn, chat: ApiChat, noLinks?: boolean): string | TextPart | undefined {
function renderChatContent(lang: OldLangFn, chat: ApiChat, noLinks?: boolean): string | TextPart | undefined {
const text = trimText(getChatTitle(lang, chat), MAX_LENGTH);
if (noLinks) {

View File

@ -1,7 +1,7 @@
import { getGlobal } from '../../../global';
import type { ApiMessage, ApiSponsoredMessage } from '../../../api/types';
import type { LangFn } from '../../../hooks/useOldLang';
import type { OldLangFn } from '../../../hooks/useOldLang';
import type { TextPart } from '../../../types';
import { ApiMessageEntityTypes } from '../../../api/types';
@ -65,7 +65,7 @@ export function renderMessageText({
// TODO Use Message Summary component instead
export function renderMessageSummary(
lang: LangFn,
lang: OldLangFn,
message: ApiMessage,
noEmoji = false,
highlight?: string,

View File

@ -62,6 +62,7 @@ type OwnProps = {
noScrollRestore?: boolean;
isViewOnly?: boolean;
withDefaultPadding?: boolean;
forceRenderAllItems?: boolean;
onFilterChange?: (value: string) => void;
onDisabledClick?: (value: string, isSelected: boolean) => void;
onLoadMore?: () => void;
@ -86,6 +87,7 @@ const ItemPicker = ({
itemInputType,
itemClassName,
withDefaultPadding,
forceRenderAllItems,
onFilterChange,
onDisabledClick,
onLoadMore,
@ -163,7 +165,7 @@ const ItemPicker = ({
});
const [viewportValuesList, getMore] = useInfiniteScroll(
onLoadMore, sortedItemValuesList, Boolean(filterValue),
onLoadMore, sortedItemValuesList, Boolean(forceRenderAllItems || filterValue),
);
const handleFilterChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {

View File

@ -6,7 +6,7 @@ import type {
ApiChat, ApiMessage, ApiMessageOutgoingStatus,
ApiUser,
} from '../../../api/types';
import type { LangFn } from '../../../hooks/useOldLang';
import type { OldLangFn } from '../../../hooks/useOldLang';
import {
getMessageIsSpoiler,
@ -111,7 +111,7 @@ const ChatMessage: FC<OwnProps & StateProps> = ({
};
function renderSummary(
lang: LangFn, message: ApiMessage, blobUrl?: string, searchQuery?: string, isRoundVideo?: boolean,
lang: OldLangFn, message: ApiMessage, blobUrl?: string, searchQuery?: string, isRoundVideo?: boolean,
) {
if (!blobUrl) {
return renderMessageSummary(lang, message, undefined, searchQuery);

View File

@ -1,5 +1,5 @@
import type { ApiChat, ApiMessage, ApiUser } from '../../../../api/types';
import type { LangFn } from '../../../../hooks/useOldLang';
import type { OldLangFn } from '../../../../hooks/useOldLang';
import {
getChatTitle,
@ -9,7 +9,7 @@ import {
} from '../../../../global/helpers';
export function getSenderName(
lang: LangFn, message: ApiMessage, chatsById: Record<string, ApiChat>, usersById: Record<string, ApiUser>,
lang: OldLangFn, message: ApiMessage, chatsById: Record<string, ApiChat>, usersById: Record<string, ApiUser>,
) {
const { senderId } = message;
if (!senderId) {

View File

@ -4,6 +4,7 @@ import React, {
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiLanguage } from '../../../api/types';
import type { ISettings, LangCode } from '../../../types';
import { SettingsScreens } from '../../../types';
@ -29,7 +30,8 @@ type OwnProps = {
type StateProps = {
isCurrentUserPremium: boolean;
} & Pick<ISettings, 'languages' | 'language' | 'canTranslate' | 'canTranslateChats' | 'doNotTranslate'>;
languages?: ApiLanguage[];
} & Pick<ISettings, | 'language' | 'canTranslate' | 'canTranslateChats' | 'doNotTranslate'>;
const SettingsLanguage: FC<OwnProps & StateProps> = ({
isActive,
@ -44,7 +46,6 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
}) => {
const {
loadLanguages,
loadAttachBots,
setSettingOption,
openPremiumModal,
} = getActions();
@ -70,8 +71,6 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
unmarkIsLoading();
setSettingOption({ language: langCode as LangCode });
loadAttachBots(); // Should be refetched every language change
});
});
@ -167,6 +166,7 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
<ItemPicker
items={options}
selectedValue={selectedLanguage}
forceRenderAllItems
onSelectedValueChange={handleChange}
itemInputType="radio"
className="settings-picker"
@ -182,8 +182,9 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const {
language, languages, canTranslate, canTranslateChats, doNotTranslate,
language, canTranslate, canTranslateChats, doNotTranslate,
} = global.settings.byKey;
const languages = global.settings.languages;
const isCurrentUserPremium = selectIsCurrentUserPremium(global);

View File

@ -10,7 +10,6 @@ import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiChatFolder, ApiMessage, ApiUser } from '../../api/types';
import type { ApiLimitTypeWithModal, TabState } from '../../global/types';
import type { LangCode } from '../../types';
import { ElectronEvent } from '../../types/electron';
import { BASE_EMOJI_KEYWORD_LANG, DEBUG, INACTIVE_MARKER } from '../../config';
@ -43,6 +42,7 @@ import useInterval from '../../hooks/schedulers/useInterval';
import useTimeout from '../../hooks/schedulers/useTimeout';
import useAppLayout from '../../hooks/useAppLayout';
import useForceUpdate from '../../hooks/useForceUpdate';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import usePreventPinchZoomGesture from '../../hooks/usePreventPinchZoomGesture';
import useShowTransition from '../../hooks/useShowTransition';
@ -115,7 +115,6 @@ type StateProps = {
openedCustomEmojiSetIds?: string[];
activeGroupCallId?: string;
isServiceChatReady?: boolean;
language?: LangCode;
wasTimeFormatSetManually?: boolean;
isPhoneCallActive?: boolean;
addedSetIds?: string[];
@ -170,7 +169,6 @@ const Main = ({
openedCustomEmojiSetIds,
isServiceChatReady,
withInterfaceAnimations,
language,
wasTimeFormatSetManually,
addedSetIds,
addedCustomEmojiIds,
@ -257,6 +255,8 @@ const Main = ({
console.log('>>> RENDER MAIN');
}
const lang = useLang();
// Preload Calls bundle to initialize sounds for iOS
useTimeout(() => {
void loadBundle(Bundles.Calls);
@ -349,13 +349,15 @@ const Main = ({
// Language-based API calls
useEffect(() => {
if (isMasterTab) {
if (language !== BASE_EMOJI_KEYWORD_LANG) {
loadEmojiKeywords({ language: language! });
if (lang.code !== BASE_EMOJI_KEYWORD_LANG) {
loadEmojiKeywords({ language: lang.code });
}
loadCountryList({ langCode: language });
loadCountryList({ langCode: lang.code });
loadAttachBots();
}
}, [language, isMasterTab]);
}, [lang, isMasterTab]);
// Re-fetch cached saved emoji for `localDb`
useEffect(() => {
@ -596,7 +598,7 @@ export default memo(withGlobal<OwnProps>(
const {
settings: {
byKey: {
language, wasTimeFormatSetManually,
wasTimeFormatSetManually,
},
},
currentUserId,
@ -660,7 +662,6 @@ export default memo(withGlobal<OwnProps>(
isServiceChatReady: selectIsServiceChatReady(global),
activeGroupCallId: isMasterTab ? global.groupCalls.activeGroupCallId : undefined,
withInterfaceAnimations: selectCanAnimateInterface(global),
language,
wasTimeFormatSetManually,
isPhoneCallActive: isMasterTab ? Boolean(global.phoneCall) : undefined,
addedSetIds: global.stickers.added.setIds,

View File

@ -1,12 +1,11 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import { withGlobal } from '../../global';
import type { ApiNotification } from '../../api/types';
import { selectTabState } from '../../global/selectors';
import { pick } from '../../util/iteratees';
import renderText from '../common/helpers/renderText';
import Notification from '../ui/Notification';
@ -15,8 +14,6 @@ type StateProps = {
};
const Notifications: FC<StateProps> = ({ notifications }) => {
const { dismissNotification } = getActions();
if (!notifications.length) {
return undefined;
}
@ -24,23 +21,7 @@ const Notifications: FC<StateProps> = ({ notifications }) => {
return (
<div id="Notifications">
{notifications.map((notification) => (
<Notification
key={notification.localId}
title={notification.title
? renderText(notification.title, ['simple_markdown', 'emoji', 'br', 'links']) : undefined}
action={notification.action}
actionText={notification.actionText}
className={notification.className}
duration={notification.duration}
icon={notification.icon}
cacheBreaker={notification.cacheBreaker}
message={renderText(notification.message, ['simple_markdown', 'emoji', 'br', 'links'])}
shouldDisableClickDismiss={notification.disableClickDismiss}
dismissAction={notification.dismissAction}
shouldShowTimer={notification.shouldShowTimer}
// eslint-disable-next-line react/jsx-no-bind
onDismiss={() => dismissNotification({ localId: notification.localId })}
/>
<Notification notification={notification} />
))}
</div>
);

View File

@ -3,7 +3,7 @@ import React, { memo, useCallback, useEffect } from '../../../../lib/teact/teact
import { getActions, withGlobal } from '../../../../global';
import type { ApiLimitTypeWithModal } from '../../../../global/types';
import type { LangFn } from '../../../../hooks/useOldLang';
import type { OldLangFn } from '../../../../hooks/useOldLang';
import type { IconName } from '../../../../types/icons';
import { MAX_UPLOAD_FILEPART_SIZE } from '../../../../config';
@ -71,7 +71,7 @@ const LIMIT_ICON: Record<ApiLimitTypeWithModal, IconName> = {
};
const LIMIT_VALUE_FORMATTER: Partial<Record<ApiLimitTypeWithModal, (...args: any[]) => string>> = {
uploadMaxFileparts: (lang: LangFn, value: number) => {
uploadMaxFileparts: (lang: OldLangFn, value: number) => {
// The real size is not exactly 4gb, so we need to round it
if (value === 8000) return lang('FileSize.GB', '4');
if (value === 4000) return lang('FileSize.GB', '2');
@ -88,7 +88,7 @@ function getLimiterDescription({
premiumValue,
valueFormatter,
}: {
lang: LangFn;
lang: OldLangFn;
limitType?: ApiLimitTypeWithModal;
isPremium?: boolean;
canBuyPremium?: boolean;

View File

@ -3,7 +3,7 @@ import React, { memo } from '../../lib/teact/teact';
import type { ApiTopic } from '../../api/types';
import type { MessageListType } from '../../global/types';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import { REM } from '../common/helpers/mediaDimensions';
import renderText from '../common/helpers/renderText';
@ -53,7 +53,7 @@ const NoMessages: FC<OwnProps> = ({
);
};
function renderTopic(lang: LangFn, topic: ApiTopic) {
function renderTopic(lang: OldLangFn, topic: ApiTopic) {
return (
<div className="NoMessages">
<div className="wrapper">
@ -69,13 +69,13 @@ function renderTopic(lang: LangFn, topic: ApiTopic) {
);
}
function renderScheduled(lang: LangFn) {
function renderScheduled(lang: OldLangFn) {
return (
<div className="empty"><span>{lang('ScheduledMessages.EmptyPlaceholder')}</span></div>
);
}
function renderSavedMessages(lang: LangFn) {
function renderSavedMessages(lang: OldLangFn) {
return (
<div className="NoMessages">
<div className="wrapper">
@ -92,7 +92,7 @@ function renderSavedMessages(lang: LangFn) {
);
}
function renderGroup(lang: LangFn) {
function renderGroup(lang: OldLangFn) {
return (
<div className="NoMessages">
<div className="wrapper" dir={lang.isRtl ? 'rtl' : undefined}>

View File

@ -117,7 +117,7 @@ const DropArea: FC<OwnProps> = ({
);
return (
<Portal containerId="#middle-column-portals">
<Portal containerSelector="#middle-column-portals">
<div
className={className}
onDragLeave={handleDragLeave}

View File

@ -6,11 +6,11 @@ import { STARS_ICON_PLACEHOLDER } from '../../../../config';
import { replaceWithTeact } from '../../../../util/replaceWithTeact';
import renderText from '../../../common/helpers/renderText';
import { type LangFn } from '../../../../hooks/useOldLang';
import { type OldLangFn } from '../../../../hooks/useOldLang';
import Icon from '../../../common/icons/Icon';
export default function renderKeyboardButtonText(lang: LangFn, button: ApiKeyboardButton): TeactNode {
export default function renderKeyboardButtonText(lang: OldLangFn, button: ApiKeyboardButton): TeactNode {
if (button.type === 'receipt') {
return lang('PaymentReceipt');
}

View File

@ -13,10 +13,11 @@ import type {
ApiMessage, ApiPeer, ApiPoll, ApiPollAnswer,
} from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { LangFn } from '../../../hooks/useOldLang';
import type { OldLangFn } from '../../../hooks/useOldLang';
import { selectPeer } from '../../../global/selectors';
import { formatMediaDuration } from '../../../util/dates/dateFormat';
import { getMessageKey } from '../../../util/keys/messageKey';
import { getServerTime } from '../../../util/serverTime';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
@ -26,7 +27,6 @@ import useOldLang from '../../../hooks/useOldLang';
import AvatarList from '../../common/AvatarList';
import Button from '../../ui/Button';
import CheckboxGroup from '../../ui/CheckboxGroup';
import Notification from '../../ui/Notification';
import RadioGroup from '../../ui/RadioGroup';
import PollOption from './PollOption';
@ -54,13 +54,14 @@ const Poll: FC<OwnProps> = ({
observeIntersectionForPlaying,
onSendVote,
}) => {
const { loadMessage, openPollResults, requestConfetti } = getActions();
const {
loadMessage, openPollResults, requestConfetti, showNotification,
} = getActions();
const { id: messageId, chatId } = message;
const { summary, results } = poll;
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [chosenOptions, setChosenOptions] = useState<string[]>([]);
const [isSolutionShown, setIsSolutionShown] = useState<boolean>(false);
const [wasSubmitted, setWasSubmitted] = useState<boolean>(false);
const [closePeriod, setClosePeriod] = useState<number>(
!summary.closed && summary.closeDate && summary.closeDate > 0
@ -176,13 +177,13 @@ const Poll: FC<OwnProps> = ({
openPollResults({ chatId, messageId });
});
const handleSolutionShow = useLastCallback(() => {
setIsSolutionShown(true);
});
const handleSolutionHide = useLastCallback(() => {
setIsSolutionShown(false);
setWasSubmitted(false);
const showSolution = useLastCallback(() => {
showNotification({
localId: getMessageKey(message),
message: renderTextWithEntities({ text: poll.results.solution!, entities: poll.results.solutionEntities }),
duration: SOLUTION_DURATION,
containerSelector: SOLUTION_CONTAINER_ID,
});
});
// Show the solution to quiz if the answer was incorrect
@ -190,7 +191,7 @@ const Poll: FC<OwnProps> = ({
if (wasSubmitted && hasVoted && summary.quiz && results.results && poll.results.solution) {
const correctResult = results.results.find((r) => r.isChosen && r.isCorrect);
if (!correctResult) {
setIsSolutionShown(true);
showSolution();
}
}
}, [hasVoted, wasSubmitted, results.results, summary.quiz, poll.results.solution]);
@ -224,22 +225,8 @@ const Poll: FC<OwnProps> = ({
);
}
function renderSolution() {
return (
isSolutionShown && poll.results.solution && (
<Notification
message={renderTextWithEntities({ text: poll.results.solution, entities: poll.results.solutionEntities })}
duration={SOLUTION_DURATION}
onDismiss={handleSolutionHide}
containerId={SOLUTION_CONTAINER_ID}
/>
)
);
}
return (
<div className="Poll" dir={lang.isRtl ? 'auto' : 'ltr'}>
{renderSolution()}
<div className="poll-question">
{renderTextWithEntities({
text: summary.question.text,
@ -274,8 +261,7 @@ const Poll: FC<OwnProps> = ({
size="tiny"
color="translucent"
className="poll-quiz-help"
disabled={isSolutionShown}
onClick={handleSolutionShow}
onClick={showSolution}
ariaLabel="Show Solution"
>
<i className="icon icon-lamp" />
@ -353,7 +339,7 @@ function getPollTypeString(summary: ApiPoll['summary']) {
return summary.isPublic ? 'PublicPoll' : 'AnonymousPoll';
}
function getReadableVotersCount(lang: LangFn, isQuiz: true | undefined, count?: number) {
function getReadableVotersCount(lang: OldLangFn, isQuiz: true | undefined, count?: number) {
if (!count) {
return lang(isQuiz ? 'Chat.Quiz.TotalVotesEmpty' : 'Chat.Poll.TotalVotesResultEmpty');
}

View File

@ -4,7 +4,7 @@ import { getActions } from '../../../../global';
import type {
ApiMessage, ApiPeer, ApiStory, ApiTopic, ApiUser,
} from '../../../../api/types';
import type { LangFn } from '../../../../hooks/useOldLang';
import type { OldLangFn } from '../../../../hooks/useOldLang';
import type { IAlbum, ThreadId } from '../../../../types';
import { MAIN_THREAD_ID } from '../../../../api/types';
import { MediaViewerOrigin } from '../../../../types';
@ -33,7 +33,7 @@ export default function useInnerHandlers({
isRepliesChat,
isSavedMessages,
}: {
lang: LangFn;
lang: OldLangFn;
selectMessage: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void;
message: ApiMessage;
chatId: string;

View File

@ -7,17 +7,15 @@ import { getActions, withGlobal } from '../../../global';
import type { ApiMessage, ApiUser } from '../../../api/types';
import type { GiftOption } from './GiftModal';
import { STARS_CURRENCY_CODE, STARS_ICON_PLACEHOLDER } from '../../../config';
import { STARS_CURRENCY_CODE } from '../../../config';
import { getUserFullName } from '../../../global/helpers';
import { selectTabState, selectTheme, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatInteger } from '../../../util/textFormat';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/icons/Icon';
import PremiumProgress from '../../common/PremiumProgress';
import ActionMessage from '../../middle/ActionMessage';
import Button from '../../ui/Button';
@ -175,16 +173,9 @@ function GiftComposer({
function renderFooter() {
const userFullName = getUserFullName(user)!;
const amount = isStarGift ? (
lang('StarsAmount', {
amount: formatInteger(gift.stars),
}, {
withNodes: true,
specialReplacement: {
[STARS_ICON_PLACEHOLDER]: <Icon className="star-amount-icon" name="star" />,
},
})
) : formatCurrency(gift.amount, gift.currency);
const amount = isStarGift
? formatCurrency(gift.stars, STARS_CURRENCY_CODE, lang.code, { iconClassName: 'star-amount-icon' })
: formatCurrency(gift.amount, gift.currency);
return (
<div className={styles.footer}>
@ -201,9 +192,9 @@ function GiftComposer({
isPrimary
progress={gift.availabilityRemains / gift.availabilityTotal!}
rightText={lang('GiftSoldCount', {
count: formatInteger(gift.availabilityTotal! - gift.availabilityRemains),
count: gift.availabilityTotal! - gift.availabilityRemains,
})}
leftText={lang('GiftLeftCount', { count: formatInteger(gift.availabilityRemains) })}
leftText={lang('GiftLeftCount', { count: gift.availabilityRemains })}
className={styles.limited}
/>
)}

View File

@ -52,7 +52,9 @@ function GiftItemPremium({
: undefined;
function renderMonths() {
const caption = months === 12 ? lang('Years', { count: 1 }) : lang('Months', { count: months });
const caption = months === 12
? lang('Years', { count: 1 }, { pluralValue: 1 })
: lang('Months', { count: months }, { pluralValue: months });
return (
<div className={styles.monthsDescription}>
{caption}

View File

@ -4,11 +4,11 @@ import { getActions, withGlobal } from '../../../../global';
import type { ApiSticker, ApiUser } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import { STARS_ICON_PLACEHOLDER } from '../../../../config';
import { getUserFullName } from '../../../../global/helpers';
import { selectStarGiftSticker, selectUser } from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format';
import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer';
import { formatInteger } from '../../../../util/textFormat';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
@ -69,6 +69,7 @@ const GiftInfoModal = ({
const canConvertDifference = (userGift && starGiftMaxConvertPeriod && (
userGift.date + starGiftMaxConvertPeriod - Date.now() / 1000
)) || 0;
const conversionLeft = Math.ceil(canConvertDifference / 60 / 60 / 24);
const handleClose = useLastCallback(() => {
closeGiftInfoModal();
@ -131,17 +132,19 @@ const GiftInfoModal = ({
return canUpdate
? lang('GiftInfoDescription', {
amount: formatInteger(starsToConvert!),
amount: starsToConvert,
}, {
withNodes: true,
withMarkdown: true,
pluralValue: starsToConvert,
})
: lang('GiftInfoDescriptionOut', {
amount: formatInteger(starsToConvert!),
amount: starsToConvert,
user: getUserFullName(targetUser)!,
}, {
withNodes: true,
withMarkdown: true,
pluralValue: starsToConvert,
});
})();
@ -205,14 +208,7 @@ const GiftInfoModal = ({
tableData.push([
lang('GiftInfoValue'),
<div className={styles.giftValue}>
{lang('StarsAmount', {
amount: formatInteger(gift.stars),
}, {
withNodes: true,
specialReplacement: {
[STARS_ICON_PLACEHOLDER]: <StarIcon type="gold" size="small" />,
},
})}
{formatStarsAsIcon(lang, gift.stars)}
{canUpdate && canConvertDifference > 0 && Boolean(starsToConvert) && (
<BadgeButton onClick={openConvertConfirm}>
{lang('GiftInfoConvert', { amount: starsToConvert }, { pluralValue: starsToConvert })}
@ -225,8 +221,10 @@ const GiftInfoModal = ({
tableData.push([
lang('GiftInfoAvailability'),
lang('GiftInfoAvailabilityValue', {
count: formatInteger(gift.availabilityRemains!),
total: formatInteger(gift.availabilityTotal),
count: gift.availabilityRemains || 0,
total: gift.availabilityTotal,
}, {
pluralValue: gift.availabilityRemains || 0,
}),
]);
}
@ -295,7 +293,7 @@ const GiftInfoModal = ({
>
<div>
{lang('GiftInfoConvertDescription1', {
amount: lang('StarsAmountText', { amount: formatInteger(userGift.starsToConvert!) }),
amount: formatStarsAsText(lang, userGift.starsToConvert!),
user: getUserFullName(userFrom)!,
}, {
withNodes: true,
@ -305,10 +303,11 @@ const GiftInfoModal = ({
{canConvertDifference > 0 && (
<div>
{lang('GiftInfoConvertDescriptionPeriod', {
count: formatInteger(Math.ceil(canConvertDifference / 60 / 60 / 24)),
count: conversionLeft,
}, {
withNodes: true,
withMarkdown: true,
pluralValue: conversionLeft,
})}
</div>
)}

View File

@ -94,11 +94,11 @@ const StarPaymentModal = ({
if (subscriptionInfo) {
return lang('StarsSubscribeText', {
chat: subscriptionInfo.title,
amount: amount!,
amount,
}, {
withNodes: true,
withMarkdown: true,
pluralValue: amount,
pluralValue: amount!,
});
}

View File

@ -1,9 +1,9 @@
import type { ApiStarsTransaction } from '../../../../api/types';
import type { LangFn } from '../../../../hooks/useOldLang';
import type { OldLangFn } from '../../../../hooks/useOldLang';
import { buildStarsTransactionCustomPeer } from '../../../../global/helpers/payments';
export function getTransactionTitle(lang: LangFn, transaction: ApiStarsTransaction) {
export function getTransactionTitle(lang: OldLangFn, transaction: ApiStarsTransaction) {
if (transaction.extendedMedia) return lang('StarMediaPurchase');
if (transaction.subscriptionPeriod) return lang('StarSubscriptionPurchase');
if (transaction.isReaction) return lang('StarsReactionsSent');

View File

@ -73,6 +73,9 @@ const MinimizedWebAppModal = ({
{
botName: activeTabName,
count: openedTabsCount - 1,
},
{
pluralValue: openedTabsCount - 1,
})}`
: activeTabName;

View File

@ -182,10 +182,6 @@ const useWebAppFrame = (
data: null,
},
});
// showNotification({
// message: 'Clipboard access is not supported in this client yet',
// });
}
if (eventType === 'web_app_open_scan_qr_popup') {

View File

@ -9,7 +9,6 @@ import type {
ApiWebDocument,
} from '../../api/types';
import type { FormEditDispatch } from '../../hooks/reducers/usePaymentReducer';
import type { LangCode } from '../../types';
import type { IconName } from '../../types/icons';
import { PaymentStep } from '../../types';
@ -238,7 +237,7 @@ const Checkout: FC<OwnProps> = ({
export default memo(Checkout);
function renderPaymentItem(
langCode: LangCode | undefined, title: string, value: number, currency: string, main = false,
langCode: string | undefined, title: string, value: number, currency: string, main = false,
) {
return (
<div className={buildClassName(styles.priceInfoItem, main && styles.priceInfoItemMain)}>

View File

@ -3,7 +3,7 @@ import React, { memo, useCallback } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiMessage, StatisticsMessageInteractionCounter } from '../../../api/types';
import type { LangFn } from '../../../hooks/useOldLang';
import type { OldLangFn } from '../../../hooks/useOldLang';
import {
getMessageMediaHash,
@ -67,7 +67,7 @@ const StatisticsRecentMessage: FC<OwnProps> = ({ postStatistic, message }) => {
);
};
function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) {
function renderSummary(lang: OldLangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) {
if (!blobUrl) {
return renderMessageSummary(lang, message);
}

View File

@ -6,7 +6,7 @@ import type {
ApiTypeStory,
StatisticsStoryInteractionCounter,
} from '../../../api/types';
import type { LangFn } from '../../../hooks/useOldLang';
import type { OldLangFn } from '../../../hooks/useOldLang';
import { getStoryMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
@ -65,7 +65,7 @@ function StatisticsRecentStory({ chat, story, postStatistic }: OwnProps) {
);
}
function renderSummary(lang: LangFn, chat: ApiChat, blobUrl?: string) {
function renderSummary(lang: OldLangFn, chat: ApiChat, blobUrl?: string) {
return (
<span>
{blobUrl ? (

View File

@ -9,6 +9,9 @@ const storedParameter: LangFnParameters = {
variables: {
count: 42,
},
options: {
pluralValue: 42,
},
};
const storedAdvancedParameter: LangFnParameters = {
@ -39,7 +42,7 @@ const TestLocale = () => {
withMarkdown: true,
})}
</p>
<p>{lang('Participants', { count: 42 })}</p>
<p>{lang('Participants', { count: 42 }, { pluralValue: 42 })}</p>
<p>
{lang('ChatServiceGroupUpdatedPinnedMessage1', {
message: 'Some message',

View File

@ -1,19 +1,21 @@
import type { FC, TeactNode } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { CallbackAction } from '../../global/types';
import type { IconName } from '../../types/icons';
import type { ApiNotification } from '../../api/types';
import { isLangFnParam } from '../../util/localization/types';
import { ANIMATION_END_DELAY } from '../../config';
import buildClassName from '../../util/buildClassName';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated';
@ -25,57 +27,55 @@ import RoundTimer from './RoundTimer';
import './Notification.scss';
type OwnProps = {
title?: TeactNode;
containerId?: string;
message: TeactNode;
duration?: number;
action?: CallbackAction | CallbackAction[];
actionText?: string;
className?: string;
icon?: IconName;
shouldDisableClickDismiss?: boolean;
dismissAction?: CallbackAction;
shouldShowTimer?: boolean;
cacheBreaker?: string;
onDismiss: NoneToVoidFunction;
notification: ApiNotification;
};
const DEFAULT_DURATION = 3000;
const ANIMATION_DURATION = 150;
const Notification: FC<OwnProps> = ({
title,
className,
message,
duration = DEFAULT_DURATION,
containerId,
icon,
action,
actionText,
shouldDisableClickDismiss,
dismissAction,
shouldShowTimer,
cacheBreaker,
onDismiss,
notification,
}) => {
const actions = getActions();
const lang = useLang();
const {
localId,
message,
action,
actionText,
cacheBreaker,
className,
disableClickDismiss,
dismissAction,
duration = DEFAULT_DURATION,
icon,
shouldShowTimer,
title,
containerSelector,
} = notification;
const [isOpen, setIsOpen] = useState(true);
// eslint-disable-next-line no-null/no-null
const timerRef = useRef<number | undefined>(null);
const { transitionClassNames } = useShowTransitionDeprecated(isOpen);
const handleDismiss = useLastCallback(() => {
actions.dismissNotification({ localId });
});
const closeAndDismiss = useLastCallback((force?: boolean) => {
if (!force && shouldDisableClickDismiss) return;
if (!force && disableClickDismiss) return;
setIsOpen(false);
setTimeout(onDismiss, ANIMATION_DURATION + ANIMATION_END_DELAY);
setTimeout(handleDismiss, ANIMATION_DURATION + ANIMATION_END_DELAY);
if (dismissAction) {
// @ts-ignore
actions[dismissAction.action](dismissAction.payload);
}
});
const handleClick = useCallback(() => {
const handleClick = useLastCallback(() => {
if (action) {
if (Array.isArray(action)) {
// @ts-ignore
@ -86,7 +86,7 @@ const Notification: FC<OwnProps> = ({
}
}
closeAndDismiss();
}, [action, actions, closeAndDismiss]);
});
useEffect(() => (isOpen ? captureEscKeyListener(closeAndDismiss) : undefined), [isOpen, closeAndDismiss]);
@ -102,7 +102,7 @@ const Notification: FC<OwnProps> = ({
}, [duration, cacheBreaker]); // Reset timer if `cacheBreaker` changes
const handleMouseEnter = useLastCallback(() => {
if (shouldDisableClickDismiss) return;
if (disableClickDismiss) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
@ -110,15 +110,45 @@ const Notification: FC<OwnProps> = ({
});
const handleMouseLeave = useLastCallback(() => {
if (shouldDisableClickDismiss) return;
if (disableClickDismiss) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(closeAndDismiss, duration);
});
const renderedTitle = useMemo(() => {
if (!title) return undefined;
if (isLangFnParam(title)) {
return lang.with(title);
}
return renderText(title, ['simple_markdown', 'emoji', 'br', 'links']);
}, [lang, title]);
const renderedMessage = useMemo(() => {
if (isLangFnParam(message)) {
return lang.with(message);
}
if (typeof message === 'string') {
return renderText(message, ['simple_markdown', 'emoji', 'br', 'links']);
}
return message;
}, [lang, message]);
const renderedActionText = useMemo(() => {
if (!actionText) return undefined;
if (isLangFnParam(actionText)) {
return lang.with(actionText);
}
return actionText;
}, [lang, actionText]);
return (
<Portal className="Notification-container" containerId={containerId}>
<Portal className="Notification-container" containerSelector={containerSelector}>
<div
className={buildClassName('Notification', transitionClassNames, className)}
onClick={handleClick}
@ -127,16 +157,18 @@ const Notification: FC<OwnProps> = ({
>
<Icon name={icon || 'info-filled'} className="notification-icon" />
<div className="content">
{title && <div className="notification-title">{title}</div>}
{message}
{renderedTitle && (
<div className="notification-title">{renderedTitle}</div>
)}
{renderedMessage}
</div>
{action && actionText && (
{action && renderedActionText && (
<Button
color="translucent-white"
onClick={handleClick}
className="notification-button"
>
{actionText}
{renderedActionText}
</Button>
)}
{shouldShowTimer && (

View File

@ -3,19 +3,19 @@ import { useLayoutEffect, useRef } from '../../lib/teact/teact';
import TeactDOM from '../../lib/teact/teact-dom';
type OwnProps = {
containerId?: string;
containerSelector?: string;
className?: string;
children: VirtualElement;
};
const Portal: FC<OwnProps> = ({ containerId, className, children }) => {
const Portal: FC<OwnProps> = ({ containerSelector, className, children }) => {
const elementRef = useRef<HTMLDivElement>();
if (!elementRef.current) {
elementRef.current = document.createElement('div');
}
useLayoutEffect(() => {
const container = document.querySelector<HTMLDivElement>(containerId || '#portals');
const container = document.querySelector<HTMLDivElement>(containerSelector || '#portals');
if (!container) {
return undefined;
}
@ -31,7 +31,7 @@ const Portal: FC<OwnProps> = ({ containerId, className, children }) => {
TeactDOM.render(undefined, element);
container.removeChild(element);
};
}, [className, containerId]);
}, [className, containerSelector]);
return TeactDOM.render(children, elementRef.current);
};

View File

@ -294,6 +294,7 @@ export const PURCHASE_USERNAME = 'auction';
export const ACCEPTABLE_USERNAME_ERRORS = new Set([USERNAME_PURCHASE_ERROR, 'USERNAME_INVALID']);
export const TME_WEB_DOMAINS = new Set(['t.me', 'web.t.me', 'a.t.me', 'k.t.me', 'z.t.me']);
export const WEB_APP_PLATFORM = 'weba';
export const LANG_PACK = 'weba';
// eslint-disable-next-line max-len
export const COUNTRIES_WITH_12H_TIME_FORMAT = new Set(['AU', 'BD', 'CA', 'CO', 'EG', 'HN', 'IE', 'IN', 'JO', 'MX', 'MY', 'NI', 'NZ', 'PH', 'PK', 'SA', 'SV', 'US']);
@ -321,7 +322,7 @@ export const MAX_MEDIA_FILES_FOR_ALBUM = 10;
export const MAX_ACTIVE_PINNED_CHATS = 5;
export const SCHEDULED_WHEN_ONLINE = 0x7FFFFFFE;
export const DEFAULT_LANG_CODE = 'en';
export const DEFAULT_LANG_PACK = 'android';
export const OLD_DEFAULT_LANG_PACK = 'android';
export const LANG_PACKS = ['android', 'ios', 'tdesktop', 'macos'] as const;
export const FEEDBACK_URL = 'https://bugs.telegram.org/?tag_ids=41&sort=time';
export const FAQ_URL = 'https://telegram.org/faq';

View File

@ -373,7 +373,13 @@ addActionHandler('loadLanguages', async (global): Promise<void> => {
}
global = getGlobal();
global = replaceSettings(global, { languages: result });
global = {
...global,
settings: {
...global.settings,
languages: result,
},
};
setGlobal(global);
});

View File

@ -6,6 +6,7 @@ import type {
ApiUpdateServerTimeOffset,
ApiUpdateSession,
} from '../../../api/types';
import type { LangCode } from '../../../types';
import type { RequiredGlobalActions } from '../../index';
import type { ActionReturnType, GlobalState } from '../../types';
@ -97,7 +98,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
});
function onUpdateApiReady<T extends GlobalState>(global: T) {
void oldSetLanguage(global.settings.byKey.language);
void oldSetLanguage(global.settings.byKey.language as LangCode);
}
function onUpdateAuthorizationState<T extends GlobalState>(global: T, update: ApiUpdateAuthorizationState) {

View File

@ -1,6 +1,7 @@
import type { ActionReturnType } from '../../types';
import { PaymentStep } from '../../../types';
import { applyLangPackDifference, requestLangPackDifference } from '../../../util/localization';
import { addActionHandler, setGlobal } from '../../index';
import {
addBlockedUser,
@ -194,6 +195,15 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
setGlobal(global);
break;
}
case 'updateLangPackTooLong': {
requestLangPackDifference(update.langCode);
break;
}
case 'updateLangPack': {
applyLangPackDifference(update.version, update.strings, update.keysToRemove);
}
}
return undefined;

View File

@ -199,7 +199,9 @@ addActionHandler('createGroupCallInviteLink', async (global, actions, payload):
copyTextToClipboard(inviteLink);
actions.showNotification({
message: 'Link copied to clipboard',
message: {
key: 'LinkCopied',
},
tabId,
});
});

View File

@ -1,5 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn';
import type { LangCode } from '../../../types';
import type { ActionReturnType, GlobalState } from '../../types';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
@ -134,7 +135,7 @@ addCallback((global: GlobalState) => {
const performanceType = selectPerformanceSettings(global);
void oldSetLanguage(language, undefined, true);
void oldSetLanguage(language as LangCode, undefined, true);
requestMutation(() => {
document.documentElement.style.setProperty(

View File

@ -1,7 +1,7 @@
import { addCallback } from '../../../lib/teact/teactn';
import type { ActionReturnType, GlobalState } from '../../types';
import { SettingsScreens } from '../../../types';
import { type LangCode, SettingsScreens } from '../../../types';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { disableDebugConsole, initDebugConsole } from '../../../util/debugConsole';
@ -53,7 +53,7 @@ addCallback((global: GlobalState) => {
}
if (settings.language !== prevSettings.language) {
oldSetLanguage(settings.language);
oldSetLanguage(settings.language as LangCode);
}
if (settings.timeFormat !== prevSettings.timeFormat) {

View File

@ -9,7 +9,7 @@ import type {
ApiTopic,
ApiUser,
} from '../../api/types';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import type {
CustomPeer, NotifyException, NotifySettings, ThreadId,
} from '../../types';
@ -101,7 +101,7 @@ export function getPrivateChatUserId(chat: ApiChat) {
return chat.id;
}
export function getChatTitle(lang: LangFn, chat: ApiChat, isSelf = false) {
export function getChatTitle(lang: OldLangFn, chat: ApiChat, isSelf = false) {
if (isSelf) {
return lang('SavedMessages');
}
@ -247,7 +247,7 @@ export function getAllowedAttachmentOptions(
}
export function getMessageSendingRestrictionReason(
lang: LangFn,
lang: OldLangFn,
currentUserBannedRights?: ApiChatBannedRights,
defaultBannedRights?: ApiChatBannedRights,
) {
@ -272,7 +272,7 @@ export function getMessageSendingRestrictionReason(
}
export function getForumComposerPlaceholder(
lang: LangFn,
lang: OldLangFn,
chat?: ApiChat,
threadId: ThreadId = MAIN_THREAD_ID,
topics?: Record<number, ApiTopic>,
@ -341,7 +341,7 @@ export function getCanDeleteChat(chat: ApiChat) {
return isChatBasicGroup(chat) || ((isChatSuperGroup(chat) || isChatChannel(chat)) && chat.isCreator);
}
export function getFolderDescriptionText(lang: LangFn, folder: ApiChatFolder, chatsCount?: number) {
export function getFolderDescriptionText(lang: OldLangFn, folder: ApiChatFolder, chatsCount?: number) {
const {
excludedChatIds, includedChatIds,
bots, groups, contacts, nonContacts, channels,
@ -376,7 +376,7 @@ export function getFolderDescriptionText(lang: LangFn, folder: ApiChatFolder, ch
}
}
export function getMessageSenderName(lang: LangFn, chatId: string, sender?: ApiPeer) {
export function getMessageSenderName(lang: OldLangFn, chatId: string, sender?: ApiPeer) {
if (!sender || isUserId(chatId)) {
return undefined;
}
@ -395,7 +395,7 @@ export function getMessageSenderName(lang: LangFn, chatId: string, sender?: ApiP
}
export function filterChatsByName(
lang: LangFn,
lang: OldLangFn,
chatIds: string[],
chatsById: Record<string, ApiChat>,
query?: string,
@ -475,7 +475,7 @@ export function getIsSavedDialog(chatId: string, threadId: ThreadId | undefined,
return chatId === currentUserId && threadId !== MAIN_THREAD_ID;
}
export function getGroupStatus(lang: LangFn, chat: ApiChat) {
export function getGroupStatus(lang: OldLangFn, chat: ApiChat) {
const chatTypeString = lang(getChatTypeString(chat));
const { membersCount } = chat;

View File

@ -3,7 +3,7 @@ import type { TeactNode } from '../../lib/teact/teact';
import type {
ApiMediaExtendedPreview, ApiMessage, MediaContent, StatefulMediaContent,
} from '../../api/types';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import { ApiMessageEntityTypes } from '../../api/types';
import { CONTENT_NOT_SUPPORTED } from '../../config';
@ -17,7 +17,7 @@ const SPOILER_CHARS = ['⠺', '⠵', '⠞', '⠟'];
export const TRUNCATED_SUMMARY_LENGTH = 80;
export function getMessageSummaryText(
lang: LangFn,
lang: OldLangFn,
message: ApiMessage,
statefulContent: StatefulMediaContent | undefined,
noEmoji = false,
@ -106,12 +106,12 @@ export function getMessageSummaryEmoji(message: ApiMessage) {
}
export function getMediaContentTypeDescription(
lang: LangFn, content: MediaContent, statefulContent: StatefulMediaContent | undefined,
lang: OldLangFn, content: MediaContent, statefulContent: StatefulMediaContent | undefined,
) {
return getSummaryDescription(lang, content, statefulContent);
}
export function getMessageSummaryDescription(
lang: LangFn,
lang: OldLangFn,
message: ApiMessage,
statefulContent: StatefulMediaContent | undefined,
truncatedText?: string | TeactNode,
@ -120,7 +120,7 @@ export function getMessageSummaryDescription(
return getSummaryDescription(lang, message.content, statefulContent, message, truncatedText, isExtended);
}
function getSummaryDescription(
lang: LangFn,
lang: OldLangFn,
mediaContent: MediaContent,
statefulContent: StatefulMediaContent | undefined,
message?: ApiMessage,

View File

@ -9,7 +9,7 @@ import type {
import type {
ApiPoll, MediaContainer, MediaContent, StatefulMediaContent,
} from '../../api/types/messages';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import type { ThreadId } from '../../types';
import type { GlobalState } from '../types';
import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types';
@ -208,7 +208,7 @@ export function isAnonymousOwnMessage(message: ApiMessage) {
return Boolean(message.senderId) && !isUserId(message.senderId) && isOwnMessage(message);
}
export function getSenderTitle(lang: LangFn, sender: ApiPeer) {
export function getSenderTitle(lang: OldLangFn, sender: ApiPeer) {
return isPeerUser(sender) ? getUserFullName(sender) : getChatTitle(lang, sender);
}
@ -344,10 +344,10 @@ export function extractMessageText(message: ApiMessage | ApiStory, inChatList =
return { text, entities };
}
export function getExpiredMessageDescription(langFn: LangFn, message: ApiMessage): string | undefined {
export function getExpiredMessageDescription(langFn: OldLangFn, message: ApiMessage): string | undefined {
return getExpiredMessageContentDescription(langFn, message.content);
}
export function getExpiredMessageContentDescription(langFn: LangFn, mediaContent: MediaContent): string | undefined {
export function getExpiredMessageContentDescription(langFn: OldLangFn, mediaContent: MediaContent): string | undefined {
const { isExpiredVoice, isExpiredRoundVideo } = mediaContent;
if (isExpiredVoice) {
return langFn('Message.VoiceMessageExpired');

View File

@ -1,5 +1,5 @@
import type { ApiMessage } from '../../api/types';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import { renderMessageText } from '../../components/common/helpers/renderMessageText';
import { getGlobal } from '..';
@ -7,7 +7,7 @@ import { getMessageStatefulContent } from './messages';
import { getMessageSummaryDescription, getMessageSummaryEmoji } from './messageSummary';
export function renderMessageSummaryHtml(
lang: LangFn,
lang: OldLangFn,
message: ApiMessage,
) {
const global = getGlobal();

View File

@ -1,5 +1,5 @@
import type { ApiPeer, ApiUser, ApiUserStatus } from '../../api/types';
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import { ANONYMOUS_USER_ID, SERVICE_NOTIFICATIONS_USER_ID } from '../../config';
import { formatFullDate, formatTime } from '../../util/dates/dateFormat';
@ -66,7 +66,7 @@ export function getUserFullName(user?: ApiUser) {
}
export function getUserStatus(
lang: LangFn, user: ApiUser, userStatus: ApiUserStatus | undefined,
lang: OldLangFn, user: ApiUser, userStatus: ApiUserStatus | undefined,
) {
if (user.id === SERVICE_NOTIFICATIONS_USER_ID) {
return lang('ServiceNotifications');

View File

@ -36,6 +36,7 @@ import type {
ApiInputInvoiceStarGift,
ApiInputMessageReplyInfo,
ApiKeyboardButton,
ApiLanguage,
ApiMediaFormat,
ApiMessage,
ApiMessageEntity,
@ -115,7 +116,6 @@ import type {
InlineBotSettings,
ISettings,
IThemeSettings,
LangCode,
LoadMoreDirection,
ManagementProgress,
ManagementScreens,
@ -1199,7 +1199,7 @@ export type GlobalState = {
defaultTopicIconsId?: string;
defaultStatusIconsId?: string;
premiumGifts?: ApiStickerSet;
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
emojiKeywords: Record<string, EmojiKeywords | undefined>;
gifs: {
saved: {
@ -1243,6 +1243,7 @@ export type GlobalState = {
notifyExceptions?: Record<number, NotifyException>;
lastPremiumBandwithNotificationDate?: number;
paidReactionPrivacy?: boolean;
languages?: ApiLanguage[];
};
push?: {
@ -1377,7 +1378,7 @@ export interface ActionPayloads {
faveSticker: { sticker: ApiSticker } & WithTabId;
unfaveSticker: { sticker: ApiSticker };
toggleStickerSet: { stickerSetId: string };
loadEmojiKeywords: { language: LangCode };
loadEmojiKeywords: { language: string };
// groups
togglePreHistoryHidden: {
@ -1484,7 +1485,7 @@ export interface ActionPayloads {
updateContentSettings: boolean;
loadCountryList: {
langCode?: LangCode;
langCode?: string;
};
ensureTimeFormat: WithTabId | undefined;

View File

@ -1,5 +1,5 @@
import type { GlobalState } from '../global/types';
import type { LangFn } from './useOldLang';
import type { OldLangFn } from './useOldLang';
import useBrowserOnline from './window/useBrowserOnline';
@ -16,7 +16,7 @@ type ConnectionStatusPosition =
| 'none';
export default function useConnectionStatus(
lang: LangFn,
lang: OldLangFn,
connectionState: GlobalState['connectionState'],
isSyncing: boolean | undefined,
hasMiddleHeader: boolean,

View File

@ -2,11 +2,11 @@ import * as langProvider from '../util/oldLangProvider';
import useEffectOnce from './useEffectOnce';
import useForceUpdate from './useForceUpdate';
export type LangFn = langProvider.LangFn;
export type OldLangFn = langProvider.LangFn;
/**
* @deprecated
*/
const useOldLang = (): LangFn => {
const useOldLang = (): OldLangFn => {
const forceUpdate = useForceUpdate();
useEffectOnce(() => {

View File

@ -73,6 +73,7 @@ class TelegramClient {
systemVersion: undefined,
appVersion: undefined,
langCode: 'en',
langPack: 'weba',
systemLangCode: 'en',
baseLogger: 'gramjs',
useWSS: false,
@ -158,7 +159,7 @@ class TelegramClient {
.toString() || '1.0',
appVersion: args.appVersion || '1.0',
langCode: args.langCode,
langPack: 'weba',
langPack: args.langPack,
systemLangCode: args.systemLangCode,
query: x,
proxy: undefined, // no proxies yet.

View File

@ -11,7 +11,6 @@ import type {
ApiExportedInvite,
ApiFakeType,
ApiLabeledPrice,
ApiLanguage,
ApiMessage,
ApiPhoto,
ApiReaction,
@ -113,8 +112,7 @@ export interface ISettings extends NotifySettings, Record<string, any> {
shouldSuggestCustomEmoji: boolean;
shouldUpdateStickerSetOrder: boolean;
hasPassword?: boolean;
languages?: ApiLanguage[];
language: LangCode;
language: string;
isSensitiveEnabled?: boolean;
canChangeSensitive?: boolean;
timeFormat: TimeFormat;
@ -481,8 +479,8 @@ export type NotifyException = {
export type EmojiKeywords = {
isLoading?: boolean;
version: number;
keywords: Record<string, string[]>;
version?: number;
keywords?: Record<string, string[]>;
};
export type InlineBotSettings = {

1123
src/types/language.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import type { LangFn } from '../../hooks/useOldLang';
import type { OldLangFn } from '../../hooks/useOldLang';
import type { TimeFormat } from '../../types';
import withCache from '../withCache';
@ -39,7 +39,7 @@ function toIsoString(date: Date) {
}
// @optimization `toLocaleTimeString` is avoided because of bad performance
export function formatTime(lang: LangFn, datetime: number | Date) {
export function formatTime(lang: OldLangFn, datetime: number | Date) {
const date = typeof datetime === 'number' ? new Date(datetime) : datetime;
const timeFormat = lang.timeFormat || '24h';
@ -53,7 +53,7 @@ export function formatTime(lang: LangFn, datetime: number | Date) {
return `${String(hours).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}${marker}`;
}
export function formatPastTimeShort(lang: LangFn, datetime: number | Date, alwaysShowTime = false) {
export function formatPastTimeShort(lang: OldLangFn, datetime: number | Date, alwaysShowTime = false) {
const date = typeof datetime === 'number' ? new Date(datetime) : datetime;
const time = formatTime(lang, date);
@ -76,16 +76,16 @@ export function formatPastTimeShort(lang: LangFn, datetime: number | Date, alway
return alwaysShowTime ? lang('FullDateTimeFormat', [formattedDate, time]) : formattedDate;
}
export function formatFullDate(lang: LangFn, datetime: number | Date) {
export function formatFullDate(lang: OldLangFn, datetime: number | Date) {
return formatDateToString(datetime, lang.code, false, 'numeric');
}
export function formatMonthAndYear(lang: LangFn, date: Date, isShort = false) {
export function formatMonthAndYear(lang: OldLangFn, date: Date, isShort = false) {
return formatDateToString(date, lang.code, false, isShort ? 'short' : 'long', true);
}
export function formatCountdown(
lang: LangFn,
lang: OldLangFn,
msLeft: number,
) {
const days = Math.floor(msLeft / MILLISECONDS_IN_DAY);
@ -104,7 +104,7 @@ export function formatCountdown(
}
}
export function formatCountdownShort(lang: LangFn, msLeft: number): string {
export function formatCountdownShort(lang: OldLangFn, msLeft: number): string {
if (msLeft < 60 * 1000) {
return Math.ceil(msLeft / 1000).toString();
} else if (msLeft < 60 * 60 * 1000) {
@ -116,7 +116,7 @@ export function formatCountdownShort(lang: LangFn, msLeft: number): string {
}
}
export function formatLastUpdated(lang: LangFn, currentTime: number, lastUpdated = currentTime) {
export function formatLastUpdated(lang: OldLangFn, currentTime: number, lastUpdated = currentTime) {
const seconds = currentTime - lastUpdated;
if (seconds < 60) {
return lang('LiveLocationUpdated.JustNow');
@ -127,7 +127,7 @@ export function formatLastUpdated(lang: LangFn, currentTime: number, lastUpdated
}
}
export function formatRelativeTime(lang: LangFn, currentTime: number, lastUpdated = currentTime) {
export function formatRelativeTime(lang: OldLangFn, currentTime: number, lastUpdated = currentTime) {
const seconds = currentTime - lastUpdated;
if (seconds < 60) {
@ -156,7 +156,7 @@ export function formatRelativeTime(lang: LangFn, currentTime: number, lastUpdate
type DurationType = 'Seconds' | 'Minutes' | 'Hours' | 'Days' | 'Weeks';
export function formatTimeDuration(lang: LangFn, duration: number, showLast = 2) {
export function formatTimeDuration(lang: OldLangFn, duration: number, showLast = 2) {
if (!duration) {
return undefined;
}
@ -196,7 +196,7 @@ export function formatTimeDuration(lang: LangFn, duration: number, showLast = 2)
}
export function formatHumanDate(
lang: LangFn,
lang: OldLangFn,
datetime: number | Date,
isShort = false,
noWeekdays = false,
@ -237,13 +237,13 @@ export function formatHumanDate(
* Returns weekday name
* @param day 0 - Sunday, 1 - Monday, ...
*/
export function formatWeekday(lang: LangFn, day: number, isShort = false) {
export function formatWeekday(lang: OldLangFn, day: number, isShort = false) {
const weekDay = WEEKDAYS_FULL[day];
return isShort ? lang(`Weekday.Short${weekDay}`) : lang(`Weekday.${weekDay}`);
}
export function formatMediaDateTime(
lang: LangFn,
lang: OldLangFn,
datetime: number | Date,
isUpperFirst?: boolean,
) {
@ -350,7 +350,7 @@ export function formatDateTimeToString(
}
export function formatDateAtTime(
lang: LangFn,
lang: OldLangFn,
datetime: number | Date,
) {
const date = typeof datetime === 'number' ? new Date(datetime) : datetime;
@ -375,7 +375,7 @@ export function formatDateAtTime(
}
export function formatDateInFuture(
lang: LangFn,
lang: OldLangFn,
currentTime: number,
datetime: number,
) {

View File

@ -1,7 +1,5 @@
import React, { type TeactNode } from '../lib/teact/teact';
import type { LangCode } from '../types';
import { STARS_CURRENCY_CODE } from '../config';
import StarIcon from '../components/common/icons/StarIcon';
@ -9,7 +7,7 @@ import StarIcon from '../components/common/icons/StarIcon';
export function formatCurrency(
totalPrice: number,
currency: string,
locale: LangCode = 'en',
locale: string = 'en',
options?: {
shouldOmitFractions?: boolean;
iconClassName?: string;
@ -27,7 +25,7 @@ export function formatCurrency(
export function formatCurrencyAsString(
totalPrice: number,
currency: string,
locale: LangCode = 'en',
locale: string = 'en',
options?: {
shouldOmitFractions?: boolean;
},

View File

@ -0,0 +1,20 @@
import React from '../../lib/teact/teact';
import type { LangFn } from './types';
import { STARS_ICON_PLACEHOLDER } from '../../config';
import StarIcon from '../../components/common/icons/StarIcon';
export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount });
}
export function formatStarsAsIcon(lang: LangFn, amount: number) {
return lang('StarsAmount', { amount }, {
withNodes: true,
specialReplacement: {
[STARS_ICON_PLACEHOLDER]: <StarIcon type="gold" className="star-amount-icon" size="adaptive" />,
},
});
}

View File

@ -4,21 +4,23 @@ import type {
ApiLanguage,
CachedLangData,
LangPack,
LangPackStringValue,
} from '../../api/types';
import type { LangKey } from '../../types/language';
import type { LangKey, LangVariable } from '../../types/language';
import {
type AdvancedLangFnOptions,
type AdvancedLangFnOptionsWithPlural,
areAdvancedLangFnOptions,
isDeletedLangString,
isPluralLangString,
type LangFn,
type LangFnOptions,
type LangFnOptionsWithPlural,
type LangFnParameters,
type LangFnWithFunction,
type LangFormatters,
} from './types';
import { DEBUG } from '../../config';
import { DEBUG, LANG_PACK } from '../../config';
import { callApi } from '../../api/gramjs';
import renderText, { type TextFilter } from '../../components/common/helpers/renderText';
import { MAIN_IDB_STORE } from '../browser/idb';
@ -37,7 +39,6 @@ import LimitedMap from '../primitives/LimitedMap';
import initialStrings from '../../assets/localization/initialStrings';
const LANG_PACK = 'weba';
const LANGPACK_STORE_PREFIX = 'langpack-';
const FORMATTERS_FALLBACK_LANG = 'en';
@ -115,14 +116,22 @@ async function fetchDifference() {
langCode: langPack.langCode,
fromVersion: langPack.version,
});
if (!result || result.version === langPack.version) return;
if (!result) return;
applyLangPackDifference(result.version, result.strings, result.keysToRemove);
}
export function applyLangPackDifference(
version: number, strings: Record<string, LangPackStringValue>, keysToRemove: string[],
) {
if (!langPack || !language || version === langPack.version) return;
const newLangPack = {
...langPack,
version: result.version,
version,
strings: {
...omit(langPack.strings, result.keysToRemove),
...result.strings,
...omit(langPack.strings, keysToRemove),
...strings,
},
};
updateLangPack(newLangPack);
@ -242,6 +251,11 @@ export async function loadAndChangeLanguage(langCode: string, shouldCheckCache?:
return changeLanguage(remoteLanguage);
}
export function requestLangPackDifference(langCode: string) {
if (language?.langCode !== langCode) return;
fetchDifference();
}
export async function changeLanguage(newLanguage: ApiLanguage) {
if (langPack && language?.langCode === newLanguage.langCode) return;
@ -300,10 +314,10 @@ function createTranslationFn(): LangFn {
fn.pluralCode = language?.pluralCode || FORMATTERS_FALLBACK_LANG;
fn.with = (({ key, variables, options }: LangFnParameters) => {
if (options && areAdvancedLangFnOptions(options)) {
return processTranslationAdvanced(key, variables as Record<string, TeactNode>, options);
return processTranslationAdvanced(key, variables as Record<string, TeactNode | undefined>, options);
}
return processTranslation(key, variables as Record<string, string | number>, options);
}) as LangFnWithFunction;
return processTranslation(key, variables as Record<string, LangVariable>, options);
});
fn.region = (code: string) => formatters?.region.of(code);
fn.conjunction = (list: string[]) => formatters?.conjunction.format(list) || list.join(', ');
fn.disjunction = (list: string[]) => formatters?.disjunction.format(list) || list.join(', ');
@ -315,7 +329,7 @@ export function getTranslationFn(): LangFn {
return translationFn;
}
function getString(langKey: LangKey, count: number, options?: Pick<LangFnOptions, 'pluralValue'>) {
function getString(langKey: LangKey, count: number) {
let langPackStringValue = langPack?.strings[langKey];
if (!langPackStringValue && !fallbackLangPack) {
@ -327,7 +341,7 @@ function getString(langKey: LangKey, count: number, options?: Pick<LangFnOptions
if (!langPackStringValue || isDeletedLangString(langPackStringValue)) return undefined;
const pluralSuffix = formatters?.pluralRules.select(options?.pluralValue || count) || 'other';
const pluralSuffix = formatters?.pluralRules.select(count) || 'other';
const string = isPluralLangString(langPackStringValue)
? (langPackStringValue[pluralSuffix] || langPackStringValue.other)
@ -337,20 +351,26 @@ function getString(langKey: LangKey, count: number, options?: Pick<LangFnOptions
}
function processTranslation(
langKey: LangKey, variables?: Record<string, string | number>, options?: LangFnOptions,
langKey: LangKey,
variables?: Record<string, LangVariable>,
options?: LangFnOptions | LangFnOptionsWithPlural,
): string {
const cacheKey = `${langKey}-${JSON.stringify(variables)}-${JSON.stringify(options)}`;
if (TRANSLATION_CACHE.has(cacheKey)) {
return TRANSLATION_CACHE.get(cacheKey)!;
}
const string = getString(langKey, options?.pluralValue || Number(variables?.count) || 0, options);
const pluralValue = options && 'pluralValue' in options ? Number(options.pluralValue) : 0;
const string = getString(langKey, pluralValue);
if (!string) return langKey;
const variableEntries = variables ? Object.entries(variables) : [];
const finalString = variableEntries.reduce((result, [key, value]) => {
return result.replace(`{${key}}`, String(value));
if (!value) return result;
const valueAsString = Number.isInteger(value) ? formatters!.number.format(value as number) : String(value);
return result.replace(`{${key}}`, valueAsString);
}, string);
TRANSLATION_CACHE.set(cacheKey, finalString);
@ -359,9 +379,12 @@ function processTranslation(
}
function processTranslationAdvanced(
langKey: LangKey, variables?: Record<string, TeactNode>, options?: AdvancedLangFnOptions,
langKey: LangKey,
variables?: Record<string, TeactNode | undefined>,
options?: AdvancedLangFnOptions | AdvancedLangFnOptionsWithPlural,
): TeactNode {
const string = getString(langKey, options?.pluralValue || Number(variables?.count) || 0, options);
const pluralValue = options && 'pluralValue' in options ? Number(options.pluralValue) : 0;
const string = getString(langKey, pluralValue);
if (!string) return langKey;
const variableEntries = variables ? Object.entries(variables) : [];
@ -389,7 +412,10 @@ function processTranslationAdvanced(
return renderText(curr, filters, {
markdownPostProcessor: (part: string) => {
return variableEntries.reduce((result, [key, value]): TeactNode[] => {
return replaceInStringsWithTeact(result, `{${key}}`, value);
if (!value) return result;
const preparedValue = Number.isInteger(value) ? formatters!.number.format(value as number) : value;
return replaceInStringsWithTeact(result, `{${key}}`, preparedValue);
}, [part] as TeactNode[]);
},
});
@ -397,7 +423,10 @@ function processTranslationAdvanced(
}
return variableEntries.reduce((result, [key, value]): TeactNode[] => {
return replaceInStringsWithTeact(result, `{${key}}`, value);
if (!value) return result;
const preparedValue = Number.isInteger(value) ? formatters!.number.format(value as number) : value;
return replaceInStringsWithTeact(result, `{${key}}`, preparedValue);
}, tempResult);
}

View File

@ -7,30 +7,51 @@ import type {
LangPackStringValueRegular,
} from '../../api/types';
import type { TextFilter } from '../../components/common/helpers/renderText';
import type { LangKey, LangPair } from '../../types/language';
import type {
LangPairPluralWithVariables,
LangPairWithVariables,
PluralLangKey,
PluralLangKeyWithVariables,
RegularLangKey,
RegularLangKeyWithVariables,
} from '../../types/language';
type ReplaceTypeValues<T, R> = {
[K in keyof T]: R;
};
export interface LangFnOptions {
pluralValue?: number;
export interface LangFnOptionsRegular {
withNodes?: never;
withMarkdown?: never;
pluralValue?: never;
}
export interface AdvancedLangFnOptions {
pluralValue?: number;
export type LangFnOptionsWithPlural = Omit<LangFnOptionsRegular, 'pluralValue'> & {
pluralValue: number;
};
export type LangFnOptions = LangFnOptionsRegular | LangFnOptionsWithPlural;
export interface AdvancedLangFnOptionsRegular {
withNodes: true;
withMarkdown?: boolean;
pluralValue?: never;
renderTextFilters?: TextFilter[];
specialReplacement?: Record<string, TeactNode>;
}
type LangPairWithNodes = {
[K in keyof LangPair]: LangPair[K] extends object ? ReplaceTypeValues<LangPair[K], TeactNode> : LangPair[K];
export type AdvancedLangFnOptionsWithPlural = Omit<AdvancedLangFnOptionsRegular, 'pluralValue'> & {
pluralValue: number;
};
type RegularLangFnParameters<T = LangPair> = {
export type AdvancedLangFnOptions = AdvancedLangFnOptionsRegular | AdvancedLangFnOptionsWithPlural;
type LangPairWithNodes = LangPairWithVariables<TeactNode | undefined>;
type LangPairPluralWithNodes = LangPairPluralWithVariables<TeactNode | undefined>;
type RegularLangFnParametersWithoutVariables = {
key: RegularLangKey;
variables?: undefined;
options?: LangFnOptions;
};
type RegularLangFnParametersWithVariables<T = LangPairWithVariables> = {
[K in keyof T]: {
key: K;
variables: T[K];
@ -38,7 +59,33 @@ type RegularLangFnParameters<T = LangPair> = {
}
}[keyof T];
type AdvancedLangFnParameters<T = LangPairWithNodes> = {
type RegularLangFnPluralParameters = {
key: PluralLangKey;
variables?: undefined;
options: LangFnOptionsWithPlural;
};
type RegularLangFnPluralParametersWithVariables<T = LangPairPluralWithVariables> = {
[K in keyof T]: {
key: K;
variables: T[K];
options: LangFnOptionsWithPlural;
}
}[keyof T];
type RegularLangFnParameters =
| RegularLangFnParametersWithoutVariables
| RegularLangFnParametersWithVariables
| RegularLangFnPluralParameters
| RegularLangFnPluralParametersWithVariables;
type AdvancedLangFnParametersWithoutVariables = {
key: RegularLangKey;
variables?: undefined;
options: AdvancedLangFnOptions;
};
type AdvancedLangFnParametersWithVariables<T = LangPairWithNodes> = {
[K in keyof T]: {
key: K;
variables: T[K];
@ -46,21 +93,56 @@ type AdvancedLangFnParameters<T = LangPairWithNodes> = {
}
}[keyof T];
export type LangFnParameters = RegularLangFnParameters | AdvancedLangFnParameters;
export type LangFnWithFunction = {
(params: RegularLangFnParameters): string;
(params: AdvancedLangFnParameters): TeactNode;
type AdvancedLangFnPluralParameters = {
key: PluralLangKey;
variables?: undefined;
options: AdvancedLangFnOptionsWithPlural;
};
type AdvancedLangFnPluralParametersWithVariables<T = LangPairPluralWithNodes> = {
[K in keyof T]: {
key: K;
variables: T[K];
options: AdvancedLangFnOptionsWithPlural;
}
}[keyof T];
type AdvancedLangFnParameters =
| AdvancedLangFnParametersWithoutVariables
| AdvancedLangFnParametersWithVariables
| AdvancedLangFnPluralParameters
| AdvancedLangFnPluralParametersWithVariables;
export type LangFnParameters = RegularLangFnParameters | AdvancedLangFnParameters;
export type LangFn = {
<K extends LangKey = LangKey, V extends LangPair[K] = LangPair[K]>(
key: K, variables?: V, options?: LangFnOptions,
<K = RegularLangKey>(
key: K, variables?: undefined, options?: LangFnOptions,
): string;
<K extends LangKey = LangKey, V extends LangPairWithNodes[K] = LangPairWithNodes[K]>(
<K = PluralLangKey>(
key: K, variables: undefined, options: LangFnOptionsWithPlural,
): string;
<K extends RegularLangKeyWithVariables = RegularLangKeyWithVariables, V = LangPairWithVariables[K]>(
key: K, variables: V, options?: LangFnOptions,
): string;
<K extends PluralLangKeyWithVariables = PluralLangKeyWithVariables, V = LangPairPluralWithVariables[K]>(
key: K, variables: V, options: LangFnOptionsWithPlural,
): string;
<K = RegularLangKey>(
key: K, variables?: undefined, options?: AdvancedLangFnOptions,
): TeactNode;
<K = PluralLangKey>(
key: K, variables: undefined, options: AdvancedLangFnOptionsWithPlural,
): TeactNode;
<K extends RegularLangKeyWithVariables = RegularLangKeyWithVariables, V = LangPairWithVariables[K]>(
key: K, variables: V, options: AdvancedLangFnOptions,
): TeactNode;
with: LangFnWithFunction;
<K extends PluralLangKeyWithVariables = PluralLangKeyWithVariables, V = LangPairPluralWithVariables[K]>(
key: K, variables: V, options: AdvancedLangFnOptionsWithPlural,
): TeactNode;
with: (params: LangFnParameters) => TeactNode;
region: (code: string) => string | undefined;
conjunction: (list: string[]) => string;
disjunction: (list: string[]) => string;
@ -101,5 +183,5 @@ export function isLangFnParam(object: unknown): object is LangFnParameters {
export function areAdvancedLangFnOptions(
params: LangFnOptions | AdvancedLangFnOptions,
): params is AdvancedLangFnOptions {
return 'withNodes' in params;
return 'withNodes' in params && Boolean(params.withNodes);
}

View File

@ -4,7 +4,7 @@ import type { ApiOldLangPack, ApiOldLangString } from '../api/types';
import type { LangCode, TimeFormat } from '../types';
import {
DEFAULT_LANG_CODE, DEFAULT_LANG_PACK, LANG_CACHE_NAME, LANG_PACKS,
DEFAULT_LANG_CODE, LANG_CACHE_NAME, LANG_PACKS, OLD_DEFAULT_LANG_PACK,
} from '../config';
import { callApi } from '../api/gramjs';
import * as cacheApi from './cacheApi';
@ -155,14 +155,14 @@ export async function getTranslationForLangString(langCode: string, key: string)
let translateString: ApiOldLangString | undefined;
const cachedValue = await cacheApi.fetch(
LANG_CACHE_NAME,
`${DEFAULT_LANG_PACK}_${langCode}_${key}`,
`${OLD_DEFAULT_LANG_PACK}_${langCode}_${key}`,
cacheApi.Type.Json,
);
if (cachedValue) {
translateString = cachedValue.value;
} else {
translateString = await fetchRemoteString(DEFAULT_LANG_PACK, langCode, key);
translateString = await fetchRemoteString(OLD_DEFAULT_LANG_PACK, langCode, key);
}
return processTranslation(translateString, key);
@ -199,7 +199,9 @@ export async function oldSetLanguage(langCode: LangCode, callback?: NoneToVoidFu
langPack = newLangPack;
document.documentElement.lang = langCode;
const { languages, timeFormat } = getGlobal().settings.byKey;
const global = getGlobal();
const { languages, byKey } = global.settings;
const timeFormat = byKey?.timeFormat;
const langInfo = languages?.find((lang) => lang.langCode === langCode);
translationFn = createLangFn();
translationFn.isRtl = Boolean(langInfo?.isRtl);

View File

@ -1,4 +1,4 @@
import type { LangFn } from '../hooks/useOldLang';
import type { OldLangFn } from '../hooks/useOldLang';
import EMOJI_REGEX from '../lib/twemojiRegex';
import fixNonStandardEmoji from './emoji/fixNonStandardEmoji';
@ -48,7 +48,7 @@ export const getFirstLetters = withCache((phrase: string, count = 2) => {
});
const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB'];
export function formatFileSize(lang: LangFn, bytes: number, decimals = 1): string {
export function formatFileSize(lang: OldLangFn, bytes: number, decimals = 1): string {
if (bytes === 0) {
return lang('FileSize.B', 0);
}