Chat List: Show suggestions (#6521)

This commit is contained in:
zubiden 2025-12-22 22:53:43 +01:00 committed by Alexander Zinchuk
parent ee78ee06b8
commit f0df7a01e9
68 changed files with 1400 additions and 330 deletions

View File

@ -7,7 +7,9 @@ import type {
ApiCountry,
ApiLanguage,
ApiOldLangString,
ApiPendingSuggestion,
ApiPrivacyKey,
ApiPromoData,
ApiRestrictionReason,
ApiSession,
ApiTimezone,
@ -22,6 +24,7 @@ import {
} from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers';
import { addUserToLocalDb } from '../helpers/localDb';
import { buildApiFormattedText } from './common';
import { omitVirtualClassFields } from './helpers';
import { buildApiDocument, buildMessageTextContent } from './messageContent';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
@ -210,6 +213,30 @@ export function buildApiConfig(config: GramJs.Config): ApiConfig {
};
}
export function buildApiPromoData(promoData: GramJs.help.PromoData): ApiPromoData {
const {
expires, pendingSuggestions, dismissedSuggestions, customPendingSuggestion,
} = promoData;
return {
expires,
pendingSuggestions,
dismissedSuggestions,
customPendingSuggestion: customPendingSuggestion ? buildApiPendingSuggestion(customPendingSuggestion) : undefined,
};
}
export function buildApiPendingSuggestion(pendingSuggestion: GramJs.TypePendingSuggestion): ApiPendingSuggestion {
const {
suggestion, title, description, url,
} = pendingSuggestion;
return {
suggestion,
title: buildApiFormattedText(title),
description: buildApiFormattedText(description),
url,
};
}
export function oldBuildLangPack(mtpLangPack: GramJs.LangPackDifference) {
return mtpLangPack.strings.reduce<Record<string, ApiOldLangString | undefined>>((acc, mtpString) => {
acc[mtpString.key] = oldBuildLangPackString(mtpString);

View File

@ -46,3 +46,5 @@ export * from './fragment';
export * from './stars';
export * from './forum';
export * from './misc';

View File

@ -0,0 +1,37 @@
import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiAppConfig, ApiConfig, ApiPromoData } from '../../types';
import { buildAppConfig } from '../apiBuilders/appConfig';
import { buildApiConfig, buildApiPromoData } from '../apiBuilders/misc';
import { DEFAULT_PRIMITIVES } from '../gramjsBuilders';
import { invokeRequest } from './client';
export async function fetchAppConfig({ hash }: { hash?: number }): Promise<ApiAppConfig | undefined> {
const result = await invokeRequest(new GramJs.help.GetAppConfig({ hash: hash ?? DEFAULT_PRIMITIVES.INT }));
if (!result || result instanceof GramJs.help.AppConfigNotModified) return undefined;
const { config, hash: resultHash } = result;
return buildAppConfig(config, resultHash);
}
export async function fetchConfig(): Promise<ApiConfig | undefined> {
const result = await invokeRequest(new GramJs.help.GetConfig());
if (!result) return undefined;
return buildApiConfig(result);
}
export async function fetchPromoData(): Promise<ApiPromoData | undefined> {
const result = await invokeRequest(new GramJs.help.GetPromoData());
if (!result || result instanceof GramJs.help.PromoDataEmpty) return undefined;
return buildApiPromoData(result);
}
export async function dismissSuggestion(suggestion: string): Promise<void> {
await invokeRequest(new GramJs.help.DismissSuggestion({
peer: new GramJs.InputPeerEmpty(),
suggestion,
}));
}

View File

@ -3,8 +3,7 @@ import { RPCError } from '../../../lib/gramjs/errors';
import type { LANG_PACKS } from '../../../config';
import type {
ApiAppConfig,
ApiConfig,
ApiBirthday,
ApiDisallowedGiftsSettings,
ApiInputPrivacyRules,
ApiLanguage,
@ -24,11 +23,9 @@ import {
import { buildCollectionByKey } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers';
import { BLOCKED_LIST_LIMIT } from '../../../limits';
import { buildAppConfig } from '../apiBuilders/appConfig';
import { buildApiPhoto, buildPrivacyRules } from '../apiBuilders/common';
import { buildApiDisallowedGiftsSettings } from '../apiBuilders/gifts';
import {
buildApiConfig,
buildApiCountryList,
buildApiLanguage,
buildApiSession,
@ -104,6 +101,18 @@ export function updateUsername(username: string) {
});
}
export function updateBirthday(birthday?: ApiBirthday) {
return invokeRequest(new GramJs.account.UpdateBirthday({
birthday: birthday ? new GramJs.Birthday({
day: birthday.day,
month: birthday.month,
year: birthday.year,
}) : undefined,
}), {
shouldReturnTrue: true,
});
}
export async function updateProfilePhoto(photo?: ApiPhoto, isFallback?: boolean) {
const photoId = photo && buildInputPhoto(photo);
const result = await invokeRequest(new GramJs.photos.UpdateProfilePhoto({
@ -602,21 +611,6 @@ export function updateContentSettings(isEnabled: boolean) {
}));
}
export async function fetchAppConfig(hash?: number): Promise<ApiAppConfig | undefined> {
const result = await invokeRequest(new GramJs.help.GetAppConfig({ hash: hash ?? DEFAULT_PRIMITIVES.INT }));
if (!result || result instanceof GramJs.help.AppConfigNotModified) return undefined;
const { config, hash: resultHash } = result;
return buildAppConfig(config, resultHash);
}
export async function fetchConfig(): Promise<ApiConfig | undefined> {
const result = await invokeRequest(new GramJs.help.GetConfig());
if (!result) return undefined;
return buildApiConfig(result);
}
export async function fetchPeerColors(hash?: number) {
const result = await invokeRequest(new GramJs.help.GetPeerColors({
hash: hash ?? DEFAULT_PRIMITIVES.INT,

View File

@ -3,7 +3,7 @@ import type { TeactNode } from '../../lib/teact/teact';
import type { CallbackAction } from '../../global/types';
import type { IconName } from '../../types/icons';
import type { RegularLangFnParameters } from '../../util/localization';
import type { ApiDocument, ApiPhoto, ApiReaction } from './messages';
import type { ApiDocument, ApiFormattedText, ApiPhoto, ApiReaction } from './messages';
import type { ApiPremiumSection } from './payments';
import type { ApiBotVerification } from './peers';
import type { ApiStarsSubscriptionPricing } from './stars';
@ -290,6 +290,20 @@ export interface ApiConfig {
maxForwardedCount: number;
}
export interface ApiPromoData {
expires: number;
pendingSuggestions: string[];
dismissedSuggestions: string[];
customPendingSuggestion?: ApiPendingSuggestion;
}
export interface ApiPendingSuggestion {
suggestion: string;
title: ApiFormattedText;
description: ApiFormattedText;
url: string;
}
export interface ApiTimezone {
id: string;
name: string;

View File

@ -1443,6 +1443,7 @@
"MenuNightMode" = "Night Mode";
"AriaMenuEnableNightMode" = "Enable night mode";
"AriaMenuDisableNightMode" = "Disable night mode";
"AriaSettingsEditProfilePhoto" = "Edit profile photo";
"MenuAnimationsSwitch" = "Animations";
"MenuTelegramFeatures" = "Telegram Features";
"TelegramFeaturesUsername" = "TelegramTips";
@ -2405,3 +2406,21 @@
"StarGiftUpgradeCostModalTitle" = "Upgrade Cost";
"StarGiftUpgradeCostHint" = "Users who upgrade their gifts first get collectibles with shorter numbers.";
"StarGiftPriceDecreaseTimer" = "Price decreases in {timer}";
"UnconfirmedAuthDeniedTitle" = "New Login Prevented";
"UnconfirmedAuthDeniedMessage" = "We have terminated the login attempt from {location}.";
"UnconfirmedAuthTitle" = "Someone just got access to your messages!";
"UnconfirmedAuthSingle" = "We detected a new login to your account from {location}. Is it you?";
"UnconfirmedAuthConfirm" = "Yes, it's me";
"UnconfirmedAuthDeny" = "No, its not me!";
"UnconfirmedAuthLocationRegion" = "{deviceModel}, {region}, {country}";
"UnconfirmedAuthLocationCountry" = "{deviceModel}, {country}";
"SuggestionBirthdaySetupTitle" = "Add your birthday! 🎂";
"SuggestionBirthdaySetupMessage" = "Let your contacts know when youre celebrating";
"BirthdaySetupTitle" = "Date of Birth";
"BirthdayInputDay" = "Day";
"BirthdayInputMonth" = "Month";
"BirthdayInputYear" = "Year";
"BirthdayRemove" = "Remove from Profile";
"BirthdayPrivacySuggestion" = "Choose who can see your birthday in {link}";
"BirthdayPrivacySuggestionLink" = "Settings >";
"SettingsBirthday" = "Birthday";

Binary file not shown.

View File

@ -27,6 +27,7 @@ export { default as DeleteAccountModal } from '../components/modals/deleteAccoun
export { default as AgeVerificationModal } from '../components/modals/ageVerification/AgeVerificationModal';
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';
export { default as ChatInviteModal } from '../components/modals/chatInvite/ChatInviteModal';
export { default as BirthdaySetupModal } from '../components/modals/birthday/BirthdaySetupModal';
export { default as AboutAdsModal } from '../components/modals/aboutAds/AboutAdsModal';
export { default as AboutMonetizationModal } from '../components/common/AboutMonetizationModal';

View File

@ -1,7 +1,7 @@
@use "../../../styles/mixins";
.GroupCallTopPane {
@include mixins.header-pane;
@include mixins.middle-header-pane;
cursor: var(--custom-cursor, pointer);

View File

@ -27,6 +27,7 @@ import Search from '../../../assets/tgs/Search.tgs';
import SearchingDuck from '../../../assets/tgs/SearchingDuck.tgs';
import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs';
import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs';
import DuckCake from '../../../assets/tgs/settings/DuckCake.tgs';
import Experimental from '../../../assets/tgs/settings/Experimental.tgs';
import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs';
import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs';
@ -81,4 +82,5 @@ export const LOCAL_TGS_URLS = {
Diamond,
Search,
DuckNothingFound,
DuckCake,
};

View File

@ -40,34 +40,34 @@ export default function renderText(
return compact(filters.reduce((text, filter) => {
switch (filter) {
case 'escape_html':
return escapeHtml(text);
return escapeHtml(text, params?.markdownPostProcessor);
case 'hq_emoji':
EMOJI_REGEX.lastIndex = 0;
return replaceEmojis(text, 'big', 'jsx');
return replaceEmojis(text, 'big', 'jsx', params?.markdownPostProcessor);
case 'emoji':
EMOJI_REGEX.lastIndex = 0;
return replaceEmojis(text, 'small', 'jsx');
return replaceEmojis(text, 'small', 'jsx', params?.markdownPostProcessor);
case 'emoji_html':
EMOJI_REGEX.lastIndex = 0;
return replaceEmojis(text, 'small', 'html');
case 'br':
return addLineBreaks(text, 'jsx');
return addLineBreaks(text, 'jsx', params?.markdownPostProcessor);
case 'br_html':
return addLineBreaks(text, 'html');
case 'highlight':
return addHighlight(text, params!.highlight);
return addHighlight(text, params!.highlight, params?.markdownPostProcessor);
case 'links':
return addLinks(text);
return addLinks(text, undefined, params?.markdownPostProcessor);
case 'tg_links':
return addLinks(text, true);
return addLinks(text, true, params?.markdownPostProcessor);
case 'simple_markdown':
return replaceSimpleMarkdown(text, 'jsx', params?.markdownPostProcessor);
@ -80,7 +80,9 @@ export default function renderText(
}, [part] as TextPart[]));
}
function escapeHtml(textParts: TextPart[]): TextPart[] {
function escapeHtml(textParts: TextPart[], postProcessor?: (part: string) => TeactNode): TextPart[] {
const postProcess = postProcessor || ((part: string) => part);
const divEl = document.createElement('div');
return textParts.reduce((result: TextPart[], part) => {
if (typeof part !== 'string') {
@ -89,15 +91,20 @@ function escapeHtml(textParts: TextPart[]): TextPart[] {
}
divEl.innerText = part;
result.push(divEl.innerHTML);
result.push(postProcess(divEl.innerHTML));
return result;
}, []);
}
function replaceEmojis(textParts: TextPart[], size: 'big' | 'small', type: 'jsx' | 'html'): TextPart[] {
function replaceEmojis(
textParts: TextPart[], size: 'big' | 'small', type: 'jsx' | 'html',
postProcessor?: (part: string) => TeactNode,
): TextPart[] {
const postProcess = postProcessor || ((part: string) => part);
if (IS_EMOJI_SUPPORTED) {
return textParts;
return textParts.map((part) => typeof part === 'string' ? postProcess(part) : part);
}
return textParts.reduce((result: TextPart[], part: TextPart) => {
@ -109,12 +116,12 @@ function replaceEmojis(textParts: TextPart[], size: 'big' | 'small', type: 'jsx'
part = fixNonStandardEmoji(part);
const parts = part.split(EMOJI_REGEX);
const emojis: string[] = part.match(EMOJI_REGEX) || [];
result.push(parts[0]);
result.push(postProcess(parts[0]));
return emojis.reduce((emojiResult: TextPart[], emoji, i) => {
const code = nativeToUnifiedExtendedWithCache(emoji);
if (!code) {
emojiResult.push(emoji);
emojiResult.push(postProcess(emoji));
} else {
const src = `./img-apple-${size === 'big' ? '160' : '64'}/${code}.png`;
const className = buildClassName(
@ -150,7 +157,7 @@ function replaceEmojis(textParts: TextPart[], size: 'big' | 'small', type: 'jsx'
const index = i * 2 + 2;
if (parts[index]) {
emojiResult.push(parts[index]);
emojiResult.push(postProcess(parts[index]));
}
return emojiResult;
@ -158,7 +165,11 @@ function replaceEmojis(textParts: TextPart[], size: 'big' | 'small', type: 'jsx'
}, [] as TextPart[]);
}
function addLineBreaks(textParts: TextPart[], type: 'jsx' | 'html'): TextPart[] {
function addLineBreaks(
textParts: TextPart[], type: 'jsx' | 'html', postProcessor?: (part: string) => TeactNode,
): TextPart[] {
const postProcess = postProcessor || ((part: string) => part);
return textParts.reduce((result: TextPart[], part) => {
if (typeof part !== 'string') {
result.push(part);
@ -169,9 +180,10 @@ function addLineBreaks(textParts: TextPart[], type: 'jsx' | 'html'): TextPart[]
.split(/\r\n|\r|\n/g)
.reduce((parts: TextPart[], line: string, i, source) => {
// This adds non-breaking space if line was indented with spaces, to preserve the indentation
const trimmedLine = line.trimLeft();
const trimmedLine = line.trimStart();
const indentLength = line.length - trimmedLine.length;
parts.push(String.fromCharCode(160).repeat(indentLength) + trimmedLine);
parts.push(String.fromCharCode(160).repeat(indentLength));
parts.push(postProcess(trimmedLine));
if (i !== source.length - 1) {
parts.push(
@ -186,7 +198,12 @@ function addLineBreaks(textParts: TextPart[], type: 'jsx' | 'html'): TextPart[]
}, []);
}
function addHighlight(textParts: TextPart[], highlight: string | undefined): TextPart[] {
function addHighlight(
textParts: TextPart[], highlight: string | undefined,
postProcessor?: (part: string) => TeactNode,
): TextPart[] {
const postProcess = postProcessor || ((part: string) => part);
return textParts.reduce<TextPart[]>((result, part) => {
if (typeof part !== 'string' || !highlight) {
result.push(part);
@ -196,25 +213,29 @@ function addHighlight(textParts: TextPart[], highlight: string | undefined): Tex
const lowerCaseText = part.toLowerCase();
const queryPosition = lowerCaseText.indexOf(highlight.toLowerCase());
if (queryPosition < 0) {
result.push(part);
result.push(postProcess(part));
return result;
}
const newParts: TextPart[] = [];
newParts.push(part.substring(0, queryPosition));
newParts.push(postProcess(part.substring(0, queryPosition)));
newParts.push(
<span className="matching-text-highlight">
{part.substring(queryPosition, queryPosition + highlight.length)}
{postProcess(part.substring(queryPosition, queryPosition + highlight.length))}
</span>,
);
newParts.push(part.substring(queryPosition + highlight.length));
newParts.push(postProcess(part.substring(queryPosition + highlight.length)));
return [...result, ...newParts];
}, []);
}
const RE_LINK = new RegExp(`${RE_LINK_TEMPLATE}|${RE_MENTION_TEMPLATE}`, 'ig');
function addLinks(textParts: TextPart[], allowOnlyTgLinks?: boolean): TextPart[] {
function addLinks(
textParts: TextPart[], allowOnlyTgLinks?: boolean, postProcessor?: (part: string) => TeactNode,
): TextPart[] {
const postProcess = postProcessor || ((part: string) => part);
return textParts.reduce<TextPart[]>((result, part) => {
if (typeof part !== 'string') {
result.push(part);
@ -223,7 +244,7 @@ function addLinks(textParts: TextPart[], allowOnlyTgLinks?: boolean): TextPart[]
const links = part.match(RE_LINK);
if (!links || !links.length) {
result.push(part);
result.push(postProcess(part));
return result;
}
@ -233,11 +254,11 @@ function addLinks(textParts: TextPart[], allowOnlyTgLinks?: boolean): TextPart[]
let lastIndex = 0;
while (nextLink) {
const index = part.indexOf(nextLink, lastIndex);
content.push(part.substring(lastIndex, index));
content.push(postProcess(part.substring(lastIndex, index)));
if (nextLink.startsWith('@')) {
content.push(
<MentionLink username={nextLink}>
{nextLink}
{postProcess(nextLink)}
</MentionLink>,
);
} else {
@ -250,13 +271,13 @@ function addLinks(textParts: TextPart[], allowOnlyTgLinks?: boolean): TextPart[]
<SafeLink text={nextLink} url={nextLink} />,
);
} else {
content.push(nextLink);
content.push(postProcess(nextLink));
}
}
lastIndex = index + nextLink.length;
nextLink = links.shift();
}
content.push(part.substring(lastIndex));
content.push(postProcess(part.substring(lastIndex)));
return [...result, ...content];
}, []);

View File

@ -401,6 +401,7 @@ const ChatExtra = ({
<Chat
chatId={personalChannel.id}
orderDiff={0}
shiftDiff={0}
animationType={ChatAnimationTypes.None}
isPreview
previewMessageId={personalChannelMessageId}

View File

@ -66,13 +66,11 @@ const UserBirthday = ({
age,
} = useMemo(() => {
const today = new Date();
const date = new Date();
if (birthday.year) {
date.setFullYear(birthday.year);
}
date.setMonth(birthday.month - 1);
date.setDate(birthday.day);
date.setHours(0, 0, 0, 0);
const date = new Date(
birthday.year || 2024, // Use leap year as fallback
birthday.month - 1,
birthday.day,
);
const formatted = formatDateToString(date, lang.code, true, 'long');
const isBirthdayToday = date.getDate() === today.getDate() && date.getMonth() === today.getMonth();

View File

@ -1,11 +1,17 @@
.root {
--background-color: var(--color-background);
transition: transform var(--chat-transform-transition);
:global(.ListItem-button) {
min-height: auto;
}
}
.noAnimation {
transition: none;
}
.minimized {
margin: -0.5rem -0.5rem 0 -0.5rem !important;
background-color: var(--color-background-secondary);
@ -14,7 +20,7 @@
display: none;
}
&.no-margin-top {
&.noMarginTop {
margin-top: 0 !important;
}

View File

@ -1,19 +1,24 @@
import type { FC } from '../../../lib/teact/teact';
import { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import { memo, useCallback, useMemo, useRef } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global';
import type { GlobalState } from '../../../global/types';
import type { CustomPeer } from '../../../types';
import { ARCHIVED_FOLDER_ID } from '../../../config';
import { ANIMATION_LEVEL_MIN, ARCHIVED_FOLDER_ID } from '../../../config';
import { getChatTitle } from '../../../global/helpers';
import { selectAnimationLevel } from '../../../global/selectors/sharedState';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners';
import { compact } from '../../../util/iteratees';
import { formatIntegerCompact } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
import { ChatAnimationTypes } from './hooks';
import useSelector from '../../../hooks/data/useSelector';
import { useFolderManagerForOrderedIds, useFolderManagerForUnreadCounters } from '../../../hooks/useFolderManager';
import useLang from '../../../hooks/useLang';
import useSyncEffect from '../../../hooks/useSyncEffect';
import Avatar from '../../common/Avatar';
import Icon from '../../common/icons/Icon';
@ -24,9 +29,11 @@ import styles from './Archive.module.scss';
type OwnProps = {
archiveSettings: GlobalState['archiveSettings'];
isFoldersSidebarShown?: boolean;
offsetTop?: number;
animationType: ChatAnimationTypes;
onDragEnter?: NoneToVoidFunction;
onClick?: NoneToVoidFunction;
isFoldersSidebarShown?: boolean;
};
const PREVIEW_SLICE = 5;
@ -37,15 +44,50 @@ const ARCHIVE_CUSTOM_PEER: CustomPeer = {
customPeerAvatarColor: '#9EAAB5',
};
const Archive: FC<OwnProps> = ({
const ANIMATION_RESET_DELAY = 200;
const Archive = ({
archiveSettings,
isFoldersSidebarShown,
offsetTop,
animationType,
onDragEnter,
onClick,
isFoldersSidebarShown,
}) => {
}: OwnProps) => {
const { updateArchiveSettings } = getActions();
const ref = useRef<HTMLDivElement>();
const animationLevel = useSelector(selectAnimationLevel);
const shouldAnimateRef = useRef(animationLevel !== ANIMATION_LEVEL_MIN);
const lang = useLang();
useSyncEffect(() => {
if (animationLevel === ANIMATION_LEVEL_MIN) {
shouldAnimateRef.current = false;
return undefined;
}
if (animationType !== ChatAnimationTypes.None) {
shouldAnimateRef.current = true;
return undefined;
}
// Keep animation alive slightly longer to avoid jumps
const timeout = setTimeout(() => {
shouldAnimateRef.current = false;
}, ANIMATION_RESET_DELAY);
const element = ref.current;
if (element) {
waitForTransitionEnd(element, () => {
shouldAnimateRef.current = false;
}, 'transform');
}
return () => clearTimeout(timeout);
}, [animationType, animationLevel]);
const orderedChatIds = useFolderManagerForOrderedIds(ARCHIVED_FOLDER_ID);
const unreadCounters = useFolderManagerForUnreadCounters();
const archiveUnreadCount = unreadCounters[ARCHIVED_FOLDER_ID]?.chatsCount;
@ -155,15 +197,19 @@ const Archive: FC<OwnProps> = ({
return (
<ListItem
ref={ref}
onClick={onClick}
onDragEnter={handleDragEnter}
className={buildClassName(
styles.root,
archiveSettings.isMinimized && styles.minimized,
isFoldersSidebarShown && archiveSettings.isMinimized && styles.noMarginTop,
!shouldAnimateRef.current && styles.noAnimation,
offsetTop && styles.noMarginTop,
'chat-item-clickable',
'chat-item-archive',
)}
style={buildStyle(Boolean(offsetTop) && `transform: translateY(${offsetTop}px)`)}
buttonClassName={styles.button}
contextActions={contextActions}
withPortalForMenu

View File

@ -37,7 +37,7 @@
&.animate-transform {
will-change: transform;
transition: transform 0.2s ease-out;
transition: transform var(--chat-transform-transition);
}
&:hover,

View File

@ -87,6 +87,7 @@ type OwnProps = {
chatId: string;
folderId?: number;
orderDiff: number;
shiftDiff: number;
animationType: ChatAnimationTypes;
isPinned?: boolean;
offsetTop?: number;
@ -137,6 +138,7 @@ const Chat: FC<OwnProps & StateProps> = ({
chatId,
folderId,
orderDiff,
shiftDiff,
animationType,
isPinned,
listedTopicIds,
@ -239,6 +241,7 @@ const Chat: FC<OwnProps & StateProps> = ({
animationType,
withInterfaceAnimations,
orderDiff,
shiftDiff,
isSavedDialog,
isPreview,
onReorderAnimationEnd,

View File

@ -2,13 +2,13 @@ import type { FC } from '@teact';
import { memo, useEffect, useRef } from '@teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiChatFolder, ApiChatlistExportedInvite, ApiSession } from '../../../api/types';
import type { ApiChatFolder, ApiChatlistExportedInvite } from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReducer';
import type { AnimationLevel } from '../../../types';
import { ALL_FOLDER_ID } from '../../../config';
import { selectIsCurrentUserFrozen, selectTabState } from '../../../global/selectors';
import { selectTabState } from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
@ -51,8 +51,6 @@ type StateProps = {
hasArchivedStories?: boolean;
archiveSettings: GlobalState['archiveSettings'];
isStoryRibbonShown?: boolean;
sessions?: Record<string, ApiSession>;
isAccountFrozen?: boolean;
};
const SAVED_MESSAGES_HOTKEY = '0';
@ -76,8 +74,6 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
hasArchivedStories,
archiveSettings,
isStoryRibbonShown,
sessions,
isAccountFrozen,
isFoldersSidebarShown,
}) => {
const {
@ -232,8 +228,6 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
isMainList
canDisplayArchive={(hasArchivedChats || hasArchivedStories) && !archiveSettings.isHidden}
archiveSettings={archiveSettings}
sessions={sessions}
isAccountFrozen={isAccountFrozen}
isFoldersSidebarShown={isFoldersSidebarShown}
isStoryRibbonShown={isStoryRibbonShown}
withTags
@ -294,16 +288,12 @@ export default memo(withGlobal<OwnProps>(
archived: archivedStories,
},
},
activeSessions: {
byHash: sessions,
},
currentUserId,
archiveSettings,
} = global;
const { animationLevel } = selectSharedSettings(global);
const { shouldSkipHistoryAnimations, activeChatFolder } = selectTabState(global);
const { storyViewer: { isRibbonShown: isStoryRibbonShown } } = selectTabState(global);
const isAccountFrozen = selectIsCurrentUserFrozen(global);
return {
chatFoldersById,
@ -320,8 +310,6 @@ export default memo(withGlobal<OwnProps>(
maxChatLists: selectCurrentLimit(global, 'chatlistJoined'),
archiveSettings,
isStoryRibbonShown,
sessions,
isAccountFrozen,
};
},
)(ChatFolders));

View File

@ -1,8 +1,6 @@
import type { FC } from '@teact';
import { memo, useEffect, useMemo, useRef, useState } from '@teact';
import { getActions } from '../../../global';
import type { ApiSession } from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReducer';
import { LeftColumnContent } from '../../../types';
@ -13,14 +11,13 @@ import {
ARCHIVED_FOLDER_ID,
CHAT_HEIGHT_PX,
CHAT_LIST_SLICE,
FRESH_AUTH_PERIOD,
SAVED_FOLDER_ID,
} from '../../../config';
import { IS_APP, IS_MAC_OS } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers.ts';
import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers';
import { getOrderKey, getPinnedChatsCount } from '../../../util/folderManager';
import { getServerTime } from '../../../util/serverTime';
import { ARCHIVE_ANIMATION_ID } from './hooks';
import usePeerStoriesPolling from '../../../hooks/polling/usePeerStoriesPolling';
import useTopOverscroll from '../../../hooks/scroll/useTopOverscroll';
@ -36,8 +33,7 @@ import Loading from '../../ui/Loading';
import Archive from './Archive';
import Chat from './Chat';
import EmptyFolder from './EmptyFolder';
import FrozenAccountNotification from './FrozenAccountNotification';
import UnconfirmedSession from './UnconfirmedSession';
import ChatListPanes from './panes/ChatListPanes';
type OwnProps = {
className?: string;
@ -47,8 +43,6 @@ type OwnProps = {
canDisplayArchive?: boolean;
archiveSettings?: GlobalState['archiveSettings'];
isForumPanelOpen?: boolean;
sessions?: Record<string, ApiSession>;
isAccountFrozen?: boolean;
isMainList?: boolean;
withTags?: boolean;
isFoldersSidebarShown?: boolean;
@ -59,7 +53,7 @@ type OwnProps = {
const INTERSECTION_THROTTLE = 200;
const RESERVED_HOTKEYS = new Set(['9', '0']);
const ChatList: FC<OwnProps> = ({
const ChatList = ({
className,
folderType,
folderId,
@ -67,24 +61,21 @@ const ChatList: FC<OwnProps> = ({
isForumPanelOpen,
canDisplayArchive,
archiveSettings,
sessions,
isAccountFrozen,
isMainList,
withTags,
isFoldersSidebarShown,
isStoryRibbonShown,
foldersDispatch,
}) => {
}: OwnProps) => {
const {
openChat,
openNextChat,
closeForumPanel,
toggleStoryRibbon,
openFrozenAccountModal,
openLeftColumnContent,
} = getActions();
const containerRef = useRef<HTMLDivElement>();
const [unconfirmedSessionHeight, setUnconfirmedSessionHeight] = useState(0);
const [panesHeight, setPanesHeight] = useState(0);
const isArchived = folderType === 'archived';
const isAllFolder = folderType === 'all';
@ -94,7 +85,6 @@ const ChatList: FC<OwnProps> = ({
);
const shouldDisplayArchive = isAllFolder && canDisplayArchive && archiveSettings;
const shouldShowFrozenAccountNotification = isAccountFrozen && isAllFolder;
const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId);
usePeerStoriesPolling(orderedIds);
@ -102,24 +92,13 @@ const ChatList: FC<OwnProps> = ({
const chatsHeight = (orderedIds?.length || 0) * CHAT_HEIGHT_PX;
const archiveHeight = shouldDisplayArchive
? archiveSettings?.isMinimized ? ARCHIVE_MINIMIZED_HEIGHT : CHAT_HEIGHT_PX : 0;
const frozenNotificationHeight = shouldShowFrozenAccountNotification ? 68 : 0;
const { orderDiffById, getAnimationType, onReorderAnimationEnd: onReorderAnimationEnd } = useOrderDiff(orderedIds);
const {
orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd: onReorderAnimationEnd,
} = useOrderDiff(orderedIds, panesHeight);
const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, CHAT_LIST_SLICE);
const shouldShowUnconfirmedSessions = useMemo(() => {
const sessionsArray = Object.values(sessions || {});
const current = sessionsArray.find((session) => session.isCurrent);
if (!current || getServerTime() - current.dateCreated < FRESH_AUTH_PERIOD) return false;
return !isAccountFrozen && isAllFolder && sessionsArray.some((session) => session.isUnconfirmed);
}, [isAllFolder, sessions, isAccountFrozen]);
useEffect(() => {
if (!shouldShowUnconfirmedSessions) setUnconfirmedSessionHeight(0);
}, [shouldShowUnconfirmedSessions]);
// Support <Alt>+<Up/Down> to navigate between chats
useHotkeys(useMemo(() => (isActive && orderedIds?.length ? {
'Alt+ArrowUp': (e: KeyboardEvent) => {
@ -178,10 +157,6 @@ const ChatList: FC<OwnProps> = ({
closeForumPanel();
});
const handleFrozenAccountNotificationClick = useLastCallback(() => {
openFrozenAccountModal();
});
const handleShowStoryRibbon = useLastCallback(() => {
toggleStoryRibbon({ isShown: true, isArchived });
});
@ -217,8 +192,7 @@ const ChatList: FC<OwnProps> = ({
return viewportIds!.map((id, i) => {
const isPinned = viewportOffset + i < pinnedCount;
const offsetTop = unconfirmedSessionHeight + archiveHeight + frozenNotificationHeight
+ (viewportOffset + i) * CHAT_HEIGHT_PX;
const offsetTop = panesHeight + archiveHeight + (viewportOffset + i) * CHAT_HEIGHT_PX;
return (
<Chat
@ -230,6 +204,7 @@ const ChatList: FC<OwnProps> = ({
isSavedDialog={isSaved}
animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]}
shiftDiff={shiftDiff}
onReorderAnimationEnd={onReorderAnimationEnd}
offsetTop={offsetTop}
observeIntersection={observe}
@ -250,28 +225,18 @@ const ChatList: FC<OwnProps> = ({
itemSelector=".ListItem:not(.chat-item-archive)"
preloadBackwards={CHAT_LIST_SLICE}
withAbsolutePositioning
maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight}
maxHeight={chatsHeight + archiveHeight + panesHeight}
onLoadMore={getMore}
>
{shouldShowUnconfirmedSessions && (
<UnconfirmedSession
key="unconfirmed"
sessions={sessions!}
onHeightChange={setUnconfirmedSessionHeight}
/>
)}
{shouldShowFrozenAccountNotification && (
<FrozenAccountNotification
key="frozen"
onClick={handleFrozenAccountNotificationClick}
/>
)}
{isAllFolder && <ChatListPanes key="panes" onHeightChange={setPanesHeight} />}
{shouldDisplayArchive && (
<Archive
key="archive"
archiveSettings={archiveSettings}
onClick={handleArchivedClick}
onDragEnter={handleArchivedDragEnter}
animationType={getAnimationType(ARCHIVE_ANIMATION_ID)}
offsetTop={panesHeight}
isFoldersSidebarShown={isFoldersSidebarShown}
/>
)}

View File

@ -1,25 +0,0 @@
import { memo } from '../../../lib/teact/teact';
import useLang from '../../../hooks/useLang';
import styles from './FrozenAccountNotification.module.scss';
type OwnProps = {
onClick: () => void;
};
const FrozenAccountNotification = ({ onClick }: OwnProps) => {
const lang = useLang();
return (
<div
className={styles.root}
onClick={onClick}
>
<div className={styles.title}>{lang('TitleFrozenAccount')}</div>
<div className={styles.subtitle}>{lang('SubtitleFrozenAccount')}</div>
</div>
);
};
export default memo(FrozenAccountNotification);

View File

@ -1,71 +0,0 @@
import { memo, useMemo, useRef } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiSession } from '../../../api/types';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import useResizeObserver from '../../../hooks/useResizeObserver';
import Button from '../../ui/Button';
import styles from './UnconfirmedSession.module.scss';
type OwnProps = {
sessions: Record<string, ApiSession>;
onHeightChange: (height: number) => void;
};
const UnconfirmedSession = ({ sessions, onHeightChange }: OwnProps) => {
const { changeSessionSettings, terminateAuthorization, showNotification } = getActions();
const ref = useRef<HTMLDivElement>();
const lang = useOldLang();
useResizeObserver(ref, (entry) => {
const height = entry.borderBoxSize?.[0]?.blockSize || entry.contentRect.height;
onHeightChange(height);
});
const firstUnconfirmed = useMemo(() => {
return Object.values(sessions).sort((a, b) => b.dateCreated - a.dateCreated)
.find((session) => session.isUnconfirmed)!;
}, [sessions]);
const locationString = useMemo(() => {
return [firstUnconfirmed.deviceModel, firstUnconfirmed.region, firstUnconfirmed.country].filter(Boolean).join(', ');
}, [firstUnconfirmed]);
const handleAccept = useLastCallback(() => {
changeSessionSettings({
hash: firstUnconfirmed.hash,
isConfirmed: true,
});
});
const handleReject = useLastCallback(() => {
terminateAuthorization({ hash: firstUnconfirmed.hash });
showNotification({
title: lang('UnconfirmedAuthDeniedTitle', 1),
message: lang('UnconfirmedAuthDeniedMessageSingle', locationString),
});
});
return (
<div className={styles.root} ref={ref}>
<h2 className={styles.title}>{lang('UnconfirmedAuthTitle')}</h2>
<p className={styles.info}>
{lang('UnconfirmedAuthSingle', locationString)}
</p>
<div className={styles.buttons}>
<Button fluid isText className={styles.button} onClick={handleAccept}>
{lang('UnconfirmedAuthConfirm')}
</Button>
<Button fluid isText color="danger" onClick={handleReject} className={styles.button}>
{lang('UnconfirmedAuthDeny')}
</Button>
</div>
</div>
);
};
export default memo(UnconfirmedSession);

View File

@ -135,7 +135,7 @@ const ForumPanel = ({
return [MAIN_THREAD_ID, ...ids];
}, [chat?.isBotForum, topicsInfo]);
const { orderDiffById, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, chat?.id);
const { orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, 0, chat?.id);
const [viewportIds, getMore] = useInfiniteScroll(() => {
if (!chat) return;
@ -223,6 +223,7 @@ const ForumPanel = ({
observeIntersection={observe}
animationType={getAnimationType(id)}
orderDiff={orderDiffById[id]}
shiftDiff={shiftDiff}
onReorderAnimationEnd={onReorderAnimationEnd}
/>
);

View File

@ -1,4 +1,3 @@
import type { FC } from '../../../../lib/teact/teact';
import { memo } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
@ -55,6 +54,7 @@ type OwnProps = {
isSelected: boolean;
style: string;
observeIntersection?: ObserveFn;
shiftDiff: number;
orderDiff: number;
animationType: ChatAnimationTypes;
onReorderAnimationEnd?: NoneToVoidFunction;
@ -76,7 +76,7 @@ type StateProps = {
topics?: Record<number, ApiTopic>;
};
const Topic: FC<OwnProps & StateProps> = ({
const Topic = ({
topic,
isSelected,
chatId,
@ -93,12 +93,13 @@ const Topic: FC<OwnProps & StateProps> = ({
animationType,
withInterfaceAnimations,
orderDiff,
shiftDiff,
typingStatus,
draft,
wasTopicOpened,
topics,
onReorderAnimationEnd,
}) => {
}: OwnProps & StateProps) => {
const {
openThread,
deleteTopic,
@ -154,6 +155,7 @@ const Topic: FC<OwnProps & StateProps> = ({
animationType,
withInterfaceAnimations,
orderDiff,
shiftDiff,
onReorderAnimationEnd,
});

View File

@ -1,20 +1,34 @@
import { useMemo } from '../../../../lib/teact/teact';
export enum ChatAnimationTypes {
Shift,
Move,
Opacity,
None,
}
export function useChatAnimationType<T extends number | string>(orderDiffById: Record<T, number>) {
export const ARCHIVE_ANIMATION_ID = 'archive';
export function useChatAnimationType<T extends number | string>(
orderDiffById: Record<T, number>,
isInitialRender: boolean,
isShifted?: boolean,
) {
return useMemo(() => {
if (isInitialRender) {
return () => ChatAnimationTypes.None;
}
const orderDiffs = Object.values<number>(orderDiffById);
const numberOfUp = orderDiffs.filter((diff) => diff < 0).length;
const numberOfDown = orderDiffs.filter((diff) => diff > 0).length;
return (chatId: T): ChatAnimationTypes => {
const orderDiff = orderDiffById[chatId];
if (orderDiff === 0) {
if (!orderDiff) {
if (isShifted) {
return ChatAnimationTypes.Shift;
}
return ChatAnimationTypes.None;
}
@ -29,5 +43,5 @@ export function useChatAnimationType<T extends number | string>(orderDiffById: R
return ChatAnimationTypes.Move;
};
}, [orderDiffById]);
}, [orderDiffById, isShifted, isInitialRender]);
}

View File

@ -47,6 +47,7 @@ export default function useChatListEntry({
observeIntersection,
animationType,
orderDiff,
shiftDiff,
withInterfaceAnimations,
isTopic,
isSavedDialog,
@ -71,6 +72,7 @@ export default function useChatListEntry({
animationType: ChatAnimationTypes;
orderDiff: number;
shiftDiff: number;
withInterfaceAnimations?: boolean;
onReorderAnimationEnd?: NoneToVoidFunction;
}) {
@ -194,8 +196,10 @@ export default function useChatListEntry({
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
});
} else if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX}px, 0)`;
}
if (animationType === ChatAnimationTypes.Move) {
element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX - shiftDiff}px, 0)`;
requestMutation(() => {
element.classList.add('animate-transform');
@ -203,14 +207,27 @@ export default function useChatListEntry({
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
});
} else {
}
if (animationType === ChatAnimationTypes.Shift) {
element.style.transform = `translate3d(0, ${-shiftDiff}px, 0)`;
requestMutation(() => {
element.classList.add('animate-transform');
element.style.transform = '';
waitStartingTransitionsEnd(element).then(notifyAnimationEnd);
});
}
if (animationType === ChatAnimationTypes.None) {
return;
}
return () => {
isCancelled = true;
};
}, [withInterfaceAnimations, orderDiff, animationType, onReorderAnimationEnd]);
}, [withInterfaceAnimations, orderDiff, shiftDiff, animationType, onReorderAnimationEnd]);
return {
renderSubtitle,

View File

@ -1,5 +1,6 @@
import { useMemo, useRef } from '../../../../lib/teact/teact';
import { useEffect, useMemo, useRef } from '../../../../lib/teact/teact';
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import { mapValues } from '../../../../util/iteratees';
import { useChatAnimationType } from './useChatAnimationType';
@ -10,7 +11,7 @@ import useSyncEffect from '../../../../hooks/useSyncEffect';
const EMPTY_ORDER_DIFF = {};
export default function useOrderDiff(orderedIds: (string | number)[] | undefined, key?: string) {
export default function useOrderDiff(orderedIds: (string | number)[] | undefined, topOffset: number, key?: string) {
const orderById = useMemo(() => {
if (!orderedIds) {
return undefined;
@ -24,6 +25,14 @@ export default function useOrderDiff(orderedIds: (string | number)[] | undefined
const prevOrderById = usePreviousDeprecated(orderById);
const prevChatId = usePreviousDeprecated(key);
const prevTopOffset = usePreviousDeprecated(topOffset);
const isInitialRenderRef = useRef(true);
useEffect(() => {
requestNextMutation(() => {
isInitialRenderRef.current = false;
});
}, []);
const orderDiffByIdRef = useRef<Record<string | number, number>>(EMPTY_ORDER_DIFF);
const forceUpdate = useForceUpdate();
@ -35,8 +44,10 @@ export default function useOrderDiff(orderedIds: (string | number)[] | undefined
forceUpdate();
});
const shiftDiff = prevTopOffset !== undefined ? topOffset - prevTopOffset : 0;
useSyncEffect(() => {
if (!orderById || !prevOrderById || key !== prevChatId) {
if (!orderById || !prevOrderById || key !== prevChatId || prevOrderById === orderById) {
orderDiffByIdRef.current = EMPTY_ORDER_DIFF;
return;
}
@ -47,12 +58,17 @@ export default function useOrderDiff(orderedIds: (string | number)[] | undefined
const hasChanges = Object.values(diff).some((value) => value !== 0);
orderDiffByIdRef.current = hasChanges ? diff : EMPTY_ORDER_DIFF;
}, [key, orderById, prevChatId, prevOrderById]);
}, [key, orderById, prevChatId, prevOrderById, topOffset]);
const getAnimationType = useChatAnimationType(orderDiffByIdRef.current);
const getAnimationType = useChatAnimationType(
orderDiffByIdRef.current,
isInitialRenderRef.current,
Boolean(shiftDiff),
);
return {
orderDiffById: orderDiffByIdRef.current,
shiftDiff,
getAnimationType,
onReorderAnimationEnd,
};

View File

@ -0,0 +1,5 @@
.root {
position: absolute;
z-index: var(--z-left-header);
width: 100%;
}

View File

@ -0,0 +1,140 @@
import { memo, useMemo, useRef, useSignal } from '@teact';
import { setExtraStyles } from '@teact/teact-dom';
import { withGlobal } from '../../../../global';
import type { ApiPromoData, ApiSession } from '../../../../api/types';
import { FRESH_AUTH_PERIOD } from '../../../../config';
import { selectIsCurrentUserFrozen } from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import { getServerTime } from '../../../../util/serverTime';
import { REM } from '../../../common/helpers/mediaDimensions';
import useEffectOnce from '../../../../hooks/useEffectOnce';
import useShowTransition from '../../../../hooks/useShowTransition';
import { useSignalEffect } from '../../../../hooks/useSignalEffect';
import { applyAnimationState, type PaneState } from '../../../middle/hooks/useHeaderPane';
import FrozenAccountPane from './FrozenAccountPane';
import SuggestionPane from './SuggestionPane';
import UnconfirmedSessionPane from './UnconfirmedSessionPane';
import styles from './ChatListPanes.module.scss';
type OwnProps = {
className?: string;
onHeightChange: (height: number) => void;
};
type StateProps = {
sessions: Record<string, ApiSession>;
promoData?: ApiPromoData;
isAccountFrozen?: boolean;
};
const TOP_MARGIN = 0.5 * REM;
const BOTTOM_MARGIN = 0.25 * REM;
const FALLBACK_PANE_STATE = { height: 0 };
const ChatListPanes = ({
className,
sessions,
promoData,
isAccountFrozen,
onHeightChange,
}: OwnProps & StateProps) => {
const [getUnconfirmedSessionHeight, setUnconfirmedSessionHeight] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getFrozenAccountState, setFrozenAccountState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getSuggestionState, setSuggestionState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const isFirstRenderRef = useRef(true);
const {
shouldRender,
ref,
} = useShowTransition({
isOpen: true,
withShouldRender: true,
noMountTransition: true,
});
useEffectOnce(() => {
isFirstRenderRef.current = false;
});
const unconfirmedSession = useMemo(() => {
const sessionsArray = Object.values(sessions || {});
const current = sessionsArray.find((session) => session.isCurrent);
if (!current || getServerTime() - current.dateCreated < FRESH_AUTH_PERIOD) return undefined;
return sessionsArray.find((session) => session.isUnconfirmed);
}, [sessions]);
const canShowUnconfirmedSession = !isAccountFrozen && unconfirmedSession;
const canShowSuggestions = !isAccountFrozen && !unconfirmedSession && promoData;
useSignalEffect(() => {
const unconfirmedSessionHeight = getUnconfirmedSessionHeight();
const frozenAccountHeight = getFrozenAccountState();
const suggestionHeight = getSuggestionState();
// Keep in sync with the order of the panes in the DOM
const stateArray = [unconfirmedSessionHeight, frozenAccountHeight, suggestionHeight];
const isFirstRender = isFirstRenderRef.current;
const panelsHeight = stateArray.reduce((acc, state) => acc + state.height, 0);
const totalHeight = panelsHeight ? panelsHeight + BOTTOM_MARGIN : 0;
onHeightChange(totalHeight);
const leftColumn = document.getElementById('LeftColumn');
if (!leftColumn) return;
applyAnimationState({
list: stateArray,
noTransition: isFirstRender,
topMargin: TOP_MARGIN,
zIndexIncrease: true,
});
setExtraStyles(leftColumn, {
'--chat-list-panes-height': `${totalHeight}px`,
});
}, [getUnconfirmedSessionHeight, getFrozenAccountState, getSuggestionState]);
if (!shouldRender) return undefined;
return (
<div
ref={ref}
className={
buildClassName(
styles.root,
className,
)
}
>
<FrozenAccountPane
isAccountFrozen={isAccountFrozen}
onPaneStateChange={setFrozenAccountState}
/>
<UnconfirmedSessionPane
unconfirmedSession={canShowUnconfirmedSession ? unconfirmedSession : undefined}
onPaneStateChange={setUnconfirmedSessionHeight}
/>
<SuggestionPane
promoData={canShowSuggestions ? promoData : undefined}
onPaneStateChange={setSuggestionState}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
sessions: global.activeSessions.byHash,
promoData: global.promoData,
isAccountFrozen: selectIsCurrentUserFrozen(global),
};
},
)(ChatListPanes));

View File

@ -1,9 +1,13 @@
@use "../../../../styles/mixins";
.root {
@include mixins.chat-list-pane;
cursor: pointer;
margin-inline: -0.5rem;
padding-block: 0.75rem;
padding-inline: 1rem;
border-radius: var(--border-radius-default);
background-color: var(--color-background-secondary);

View File

@ -0,0 +1,44 @@
import { memo } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../../../middle/hooks/useHeaderPane';
import styles from './FrozenAccountPane.module.scss';
type OwnProps = {
isAccountFrozen?: boolean;
onPaneStateChange: (state: PaneState) => void;
};
const FrozenAccountPane = ({ isAccountFrozen, onPaneStateChange }: OwnProps) => {
const { openFrozenAccountModal } = getActions();
const lang = useLang();
const { ref, shouldRender } = useHeaderPane({
isOpen: isAccountFrozen,
onStateChange: onPaneStateChange,
});
const handleClick = useLastCallback(() => {
openFrozenAccountModal();
});
if (!shouldRender) return undefined;
return (
<div
ref={ref}
className={styles.root}
role="button"
tabIndex={0}
onClick={handleClick}
>
<div className={styles.title}>{lang('TitleFrozenAccount')}</div>
<div className={styles.subtitle}>{lang('SubtitleFrozenAccount')}</div>
</div>
);
};
export default memo(FrozenAccountPane);

View File

@ -0,0 +1,56 @@
@use "../../../../styles/mixins";
.root {
@include mixins.chat-list-pane;
cursor: var(--custom-cursor, pointer);
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: 1fr 1fr;
border-radius: var(--border-radius-default);
line-height: 1.25;
background-color: var(--color-background-secondary);
&:hover {
opacity: 0.85;
}
}
.title {
--emoji-size: 1.125rem;
--custom-emoji-size: var(--emoji-size);
grid-column: 1 / 2;
grid-row: 1 / 2;
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
}
.subtitle {
--emoji-size: 1rem;
--custom-emoji-size: var(--emoji-size);
grid-column: 1 / 3;
grid-row: 2 / 3;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.closeIcon {
grid-column: 2 / 3;
grid-row: 1 / 2;
place-self: center;
padding: 0.125rem;
border-radius: 50%;
color: var(--color-text-secondary);
&:hover {
background-color: var(--color-interactive-element-hover);
}
}

View File

@ -0,0 +1,104 @@
import { memo, useMemo } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ApiPromoData } from '../../../../api/types';
import type { RegularLangKey } from '../../../../types/language';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../../../middle/hooks/useHeaderPane';
import Icon from '../../../common/icons/Icon';
import styles from './SuggestionPane.module.scss';
type OwnProps = {
promoData?: ApiPromoData;
onPaneStateChange: (state: PaneState) => void;
};
// https://core.telegram.org/api/config#suggestions
const BIRTHDAY_SETUP = 'BIRTHDAY_SETUP';
const SUPPORTED_SUGGESTIONS = [BIRTHDAY_SETUP] as const;
type Suggestion = (typeof SUPPORTED_SUGGESTIONS)[number];
const SUPPORTED_SUGGESTIONS_SET = new Set<string>(SUPPORTED_SUGGESTIONS);
const AUTOCLOSABLE_SUGGESTIONS = new Set<string>([BIRTHDAY_SETUP]);
const SUGGESTION_LANG_KEYS: Record<Suggestion, [RegularLangKey, RegularLangKey]> = {
BIRTHDAY_SETUP: ['SuggestionBirthdaySetupTitle', 'SuggestionBirthdaySetupMessage'],
};
const SuggestionPane = ({ promoData, onPaneStateChange }: OwnProps) => {
const { openBirthdaySetupModal, dismissSuggestion, openUrl } = getActions();
const lang = useLang();
const currentSuggestion = useMemo(() => {
if (promoData?.customPendingSuggestion) return promoData.customPendingSuggestion;
return promoData?.pendingSuggestions.find((suggestion): suggestion is Suggestion => (
SUPPORTED_SUGGESTIONS_SET.has(suggestion)
));
}, [promoData]);
const renderingSuggestion = useCurrentOrPrev(currentSuggestion);
const isCustomSuggestion = typeof renderingSuggestion === 'object';
const { ref, shouldRender } = useHeaderPane({
isOpen: Boolean(currentSuggestion),
onStateChange: onPaneStateChange,
});
const handleClick = useLastCallback(() => {
if (!renderingSuggestion) return;
const suggestion = isCustomSuggestion ? renderingSuggestion.suggestion : renderingSuggestion;
if (AUTOCLOSABLE_SUGGESTIONS.has(suggestion)) {
dismissSuggestion({ suggestion });
}
if (isCustomSuggestion) {
openUrl({ url: renderingSuggestion.url });
return;
}
switch (renderingSuggestion) {
case BIRTHDAY_SETUP:
openBirthdaySetupModal({});
break;
}
});
const handleDismiss = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!renderingSuggestion) return;
const suggestion = isCustomSuggestion ? renderingSuggestion.suggestion : renderingSuggestion;
dismissSuggestion({ suggestion });
});
if (!shouldRender || !renderingSuggestion) return undefined;
const title = isCustomSuggestion ? renderTextWithEntities(renderingSuggestion.title)
: lang(SUGGESTION_LANG_KEYS[renderingSuggestion][0], undefined, { withNodes: true });
const message = isCustomSuggestion ? renderTextWithEntities(renderingSuggestion.description)
: lang(SUGGESTION_LANG_KEYS[renderingSuggestion][1], undefined, { withNodes: true });
return (
<div
ref={ref}
className={styles.root}
role="button"
tabIndex={0}
onClick={handleClick}
>
<div className={styles.title}>{title}</div>
<div className={styles.subtitle}>{message}</div>
<Icon name="close" className={styles.closeIcon} onClick={handleDismiss} />
</div>
);
};
export default memo(SuggestionPane);

View File

@ -1,13 +1,10 @@
/* stylelint-disable-next-line */
@value minimized from "./Archive.module.scss";
@use "../../../../styles/mixins";
.root {
@include mixins.chat-list-pane;
padding: 0.5rem;
text-align: center;
& + :global(.minimized) {
margin-top: 0 !important;
}
}
.title {

View File

@ -0,0 +1,92 @@
import { memo, useMemo } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ApiSession } from '../../../../api/types';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../../../middle/hooks/useHeaderPane';
import Button from '../../../ui/Button';
import styles from './UnconfirmedSessionPane.module.scss';
type OwnProps = {
unconfirmedSession: ApiSession | undefined;
onPaneStateChange: (state: PaneState) => void;
};
const UnconfirmedSessionPane = ({
unconfirmedSession,
onPaneStateChange,
}: OwnProps) => {
const { changeSessionSettings, terminateAuthorization, showNotification } = getActions();
const lang = useLang();
const isOpen = Boolean(unconfirmedSession);
const renderingSession = useCurrentOrPrev(unconfirmedSession);
const { ref, shouldRender } = useHeaderPane({
isOpen,
withResizeObserver: true,
onStateChange: onPaneStateChange,
});
const locationString = useMemo(() => {
if (!renderingSession) return '';
if (!renderingSession.region) {
return lang('UnconfirmedAuthLocationCountry', {
deviceModel: renderingSession.deviceModel,
country: renderingSession.country,
});
}
return lang('UnconfirmedAuthLocationRegion', {
deviceModel: renderingSession.deviceModel,
region: renderingSession.region,
country: renderingSession.country,
});
}, [renderingSession, lang]);
const handleAccept = useLastCallback(() => {
if (!renderingSession) return;
changeSessionSettings({
hash: renderingSession.hash,
isConfirmed: true,
});
});
const handleReject = useLastCallback(() => {
if (!renderingSession) return;
terminateAuthorization({ hash: renderingSession.hash });
showNotification({
title: lang('UnconfirmedAuthDeniedTitle'),
message: lang('UnconfirmedAuthDeniedMessage', { location: locationString }),
});
});
if (!shouldRender || !renderingSession) return undefined;
return (
<div
className={styles.root}
ref={ref}
>
<h2 className={styles.title}>{lang('UnconfirmedAuthTitle')}</h2>
<p className={styles.info}>
{lang('UnconfirmedAuthSingle', { location: locationString })}
</p>
<div className={styles.buttons}>
<Button fluid isText className={styles.button} onClick={handleAccept}>
{lang('UnconfirmedAuthConfirm')}
</Button>
<Button fluid isText color="danger" onClick={handleReject} className={styles.button}>
{lang('UnconfirmedAuthDeny')}
</Button>
</div>
</div>
);
};
export default memo(UnconfirmedSessionPane);

View File

@ -177,7 +177,9 @@
}
&-description {
margin: -0.5rem 1rem 1rem;
margin-block: 0;
margin-inline: 1rem;
font-size: 0.875rem;
line-height: 1.3125;
color: var(--color-text-secondary);
@ -284,6 +286,10 @@
margin-left: 0;
}
}
& + .settings-item-description {
margin-top: 0.5rem;
}
}
.radio-group {
@ -390,3 +396,8 @@
.settings-button {
font-weight: var(--font-weight-semibold);
}
.settings-birthday-date {
font-size: 0.875rem;
color: var(--color-text-secondary);
}

View File

@ -1,23 +1,23 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import {
memo, useCallback, useEffect, useMemo,
useState,
memo, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiUsername } from '../../../api/types';
import type { ApiBirthday, ApiUsername } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { ProfileEditProgress } from '../../../types';
import { ProfileEditProgress, SettingsScreens } from '../../../types';
import { PURCHASE_USERNAME, TME_LINK_PREFIX, USERNAME_PURCHASE_ERROR } from '../../../config';
import { getChatAvatarHash } from '../../../global/helpers';
import { selectTabState, selectUser, selectUserFullInfo } from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import { formatDateToString } from '../../../util/dates/dateFormat';
import { throttle } from '../../../util/schedulers';
import renderText from '../../common/helpers/renderText';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useOldLang from '../../../hooks/useOldLang';
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
@ -28,6 +28,8 @@ import UsernameInput from '../../common/UsernameInput';
import AvatarEditable from '../../ui/AvatarEditable';
import FloatingActionButton from '../../ui/FloatingActionButton';
import InputText from '../../ui/InputText';
import Link from '../../ui/Link';
import ListItem from '../../ui/ListItem';
import TextArea from '../../ui/TextArea';
type OwnProps = {
@ -39,6 +41,7 @@ type StateProps = {
currentAvatarHash?: string;
currentFirstName?: string;
currentLastName?: string;
currentBirthday?: ApiBirthday;
currentBio?: string;
progress?: ProfileEditProgress;
checkedUsername?: string;
@ -52,11 +55,12 @@ const runThrottled = throttle((cb) => cb(), 60000, true);
const ERROR_FIRST_NAME_MISSING = 'Please provide your first name';
const SettingsEditProfile: FC<OwnProps & StateProps> = ({
const SettingsEditProfile = ({
isActive,
currentAvatarHash,
currentFirstName,
currentLastName,
currentBirthday,
currentBio,
progress,
checkedUsername,
@ -65,13 +69,16 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
maxBioLength,
usernames,
onReset,
}) => {
}: OwnProps & StateProps) => {
const {
loadCurrentUser,
updateProfile,
openSettingsScreen,
openBirthdaySetupModal,
} = getActions();
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
const firstEditableUsername = useMemo(() => usernames?.find(({ isEditable }) => isEditable), [usernames]);
const currentUsername = firstEditableUsername?.username || '';
@ -137,31 +144,51 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
}
}, [progress]);
const handlePhotoChange = useCallback((newPhoto: File) => {
setPhoto(newPhoto);
}, []);
const formattedBirthday = useMemo(() => {
if (!currentBirthday) return undefined;
const handleFirstNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const date = new Date(
currentBirthday.year || 2024, // Use leap year as fallback
currentBirthday.month - 1,
currentBirthday.day,
);
return formatDateToString(date, lang.code, true, 'long');
}, [currentBirthday, lang]);
const handlePhotoChange = useLastCallback((newPhoto: File) => {
setPhoto(newPhoto);
});
const handleFirstNameChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFirstName(e.target.value);
setIsProfileFieldsTouched(true);
}, []);
});
const handleLastNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const handleLastNameChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setLastName(e.target.value);
setIsProfileFieldsTouched(true);
}, []);
});
const handleBioChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const handleBioChange = useLastCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBio(e.target.value);
setIsProfileFieldsTouched(true);
}, []);
});
const handleUsernameChange = useCallback((value: string | false) => {
const handleUsernameChange = useLastCallback((value: string | false) => {
setEditableUsername(value);
setIsUsernameTouched(currentUsername !== value);
}, [currentUsername]);
});
const handleProfileSave = useCallback(() => {
const handleBirthdayPrivacyClick = useLastCallback(() => {
openSettingsScreen({ screen: SettingsScreens.PrivacyBirthday });
});
const handleBirthdayClick = useLastCallback(() => {
openBirthdaySetupModal({ currentBirthday });
});
const handleProfileSave = useLastCallback(() => {
const trimmedFirstName = firstName.trim();
const trimmedLastName = lastName.trim();
const trimmedBio = bio.trim();
@ -184,19 +211,14 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
username: editableUsername,
}),
});
}, [
photo,
firstName, lastName, bio, isProfileFieldsTouched,
editableUsername, isUsernameTouched,
updateProfile,
]);
});
function renderPurchaseLink() {
const purchaseInfoLink = `${TME_LINK_PREFIX}${PURCHASE_USERNAME}`;
return (
<p className="settings-item-description" dir={lang.isRtl ? 'rtl' : undefined}>
{(lang('lng_username_purchase_available'))
<p className="settings-item-description" dir={oldLang.isRtl ? 'rtl' : undefined}>
{(oldLang('lng_username_purchase_available'))
.replace('{link}', '%PURCHASE_LINK%')
.split('%')
.map((s) => {
@ -214,39 +236,57 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
<AvatarEditable
currentAvatarBlobUrl={currentAvatarBlobUrl}
onChange={handlePhotoChange}
title="Edit your profile photo"
title={lang('AriaSettingsEditProfilePhoto')}
disabled={isLoading}
/>
<InputText
value={firstName}
onChange={handleFirstNameChange}
label={lang('FirstName')}
label={oldLang('FirstName')}
disabled={isLoading}
error={error === ERROR_FIRST_NAME_MISSING ? error : undefined}
/>
<InputText
value={lastName}
onChange={handleLastNameChange}
label={lang('LastName')}
label={oldLang('LastName')}
disabled={isLoading}
/>
<TextArea
value={bio}
onChange={handleBioChange}
label={lang('UserBio')}
label={oldLang('UserBio')}
disabled={isLoading}
maxLength={maxBioLength}
maxLengthIndicator={maxBioLength ? (maxBioLength - bio.length).toString() : undefined}
/>
</div>
<p className="settings-item-description" dir={lang.isRtl ? 'rtl' : undefined}>
{renderText(lang('lng_settings_about_bio'), ['br', 'simple_markdown'])}
<p className="settings-item-description" dir={oldLang.isRtl ? 'rtl' : undefined}>
{renderText(oldLang('lng_settings_about_bio'), ['br', 'simple_markdown'])}
</p>
</div>
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('Username')}</h4>
<ListItem
icon="gift"
narrow
rightElement={formattedBirthday ?
<span className="settings-birthday-date">{formattedBirthday}</span>
: undefined}
onClick={handleBirthdayClick}
>
<span className="flex-grow">{lang('SettingsBirthday')}</span>
</ListItem>
<p className="settings-item-description" dir={oldLang.isRtl ? 'rtl' : undefined}>
{lang('BirthdayPrivacySuggestion', {
link: <Link isPrimary onClick={handleBirthdayPrivacyClick}>{lang('BirthdayPrivacySuggestionLink')}</Link>,
}, { withNodes: true })}
</p>
</div>
<div className="settings-item">
<h4 className="settings-item-header" dir={oldLang.isRtl ? 'rtl' : undefined}>{oldLang('Username')}</h4>
<div className="settings-input">
<UsernameInput
@ -259,12 +299,12 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
</div>
{editUsernameError === USERNAME_PURCHASE_ERROR && renderPurchaseLink()}
<p className="settings-item-description" dir={lang.isRtl ? 'rtl' : undefined}>
{renderText(lang('UsernameHelp'), ['br', 'simple_markdown'])}
<p className="settings-item-description" dir={oldLang.isRtl ? 'rtl' : undefined}>
{renderText(oldLang('UsernameHelp'), ['br', 'simple_markdown'])}
</p>
{editableUsername && (
<p className="settings-item-description" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('lng_username_link')}
<p className="settings-item-description" dir={oldLang.isRtl ? 'rtl' : undefined}>
{oldLang('lng_username_link')}
<br />
<span className="username-link">
{TME_LINK_PREFIX}
@ -286,7 +326,7 @@ const SettingsEditProfile: FC<OwnProps & StateProps> = ({
isShown={isSaveButtonShown}
onClick={handleProfileSave}
disabled={isLoading}
ariaLabel={lang('Save')}
ariaLabel={oldLang('Save')}
iconName="check"
isLoading={isLoading}
/>
@ -316,6 +356,7 @@ export default memo(withGlobal<OwnProps>(
currentAvatarHash,
currentFirstName,
currentLastName,
currentBirthday: currentUserFullInfo?.birthday,
currentBio: currentUserFullInfo?.bio,
progress,
isUsernameAvailable,

View File

@ -68,9 +68,9 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
canSetPasscode,
needAgeVideoVerification,
privacy,
onReset,
isCurrentUserFrozen,
accountDaysTtl,
onReset,
}) => {
const {
openDeleteAccountModal,
@ -89,7 +89,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (!isCurrentUserFrozen) {
loadBlockedUsers();
loadPrivacySettings();
loadPrivacySettings({});
loadWebAuthorizations();
}
}, [isCurrentUserFrozen]);

View File

@ -65,7 +65,7 @@ const Dialogs: FC<StateProps> = ({ dialogs, currentMessageList }) => {
{lang(
'AreYouSureShareMyContactInfoBot',
undefined,
{ withNodes: true, withMarkdown: true, renderTextFilters: ['br', 'emoji'],
{ withNodes: true, withMarkdown: true, renderTextFilters: ['br'],
})}
<div className="dialog-buttons mt-2">
<Button

View File

@ -263,6 +263,7 @@ const Main = ({
loadAllStories,
loadAllHiddenStories,
loadContentSettings,
loadPromoData,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -315,6 +316,7 @@ const Main = ({
loadAllChats({ listType: 'saved' });
loadAllStories();
loadAllHiddenStories();
loadPromoData();
loadContentSettings();
loadRecentReactions();
loadDefaultTagReactions();

View File

@ -113,7 +113,7 @@ const MiddleHeaderPanes = ({
const middleColumn = document.getElementById('MiddleColumn');
if (!middleColumn) return;
applyAnimationState(stateArray, isFirstRender);
applyAnimationState({ list: stateArray, noTransition: isFirstRender });
setExtraStyles(middleColumn, {
'--middle-header-panes-height': `${totalHeight}px`,

View File

@ -126,7 +126,17 @@ export default function useHeaderPane<RefType extends HTMLElement = HTMLDivEleme
};
}
export function applyAnimationState(list: PaneState[], noTransition = false) {
export function applyAnimationState({
list,
noTransition = false,
zIndexIncrease,
topMargin = 0,
}: {
list: PaneState[];
noTransition?: boolean;
zIndexIncrease?: boolean;
topMargin?: number;
}) {
let cumulativeHeight = 0;
for (let i = 0; i < list.length; i++) {
const state = list[i];
@ -139,7 +149,7 @@ export function applyAnimationState(list: PaneState[], noTransition = false) {
const apply = () => {
setExtraStyles(element, {
transform: `translateY(${state.isOpen ? shiftPx : `calc(${shiftPx} - 100%)`})`,
transform: `translateY(${state.isOpen ? shiftPx : `calc(${shiftPx} - ${topMargin}px - 100%)`})`,
zIndex: String(-i),
transition: noTransition ? 'none' : '',
});
@ -148,8 +158,8 @@ export function applyAnimationState(list: PaneState[], noTransition = false) {
if (!element.dataset.isPanelOpen && state.isOpen && !noTransition) {
// Start animation right above its final position
setExtraStyles(element, {
transform: `translateY(calc(${shiftPx} - 100%))`,
zIndex: String(-i),
transform: `translateY(calc(${shiftPx} - ${topMargin}px - 100%))`,
zIndex: String(zIndexIncrease ? i : -i),
transition: 'none',
});
element.dataset.isPanelOpen = 'true';

View File

@ -227,7 +227,7 @@
}
&.full-width-player {
@include mixins.header-pane;
@include mixins.middle-header-pane;
.AudioPlayer-content {
flex-grow: 1;

View File

@ -1,7 +1,7 @@
@use "../../../styles/mixins";
.root {
@include mixins.header-pane;
@include mixins.middle-header-pane;
cursor: var(--custom-cursor, pointer);
@ -11,7 +11,7 @@
height: auto;
// Slight variation from mixins.header-pane
// Slight variation from mixins.middle-header-pane
padding-right: max(1rem, env(safe-area-inset-right));
padding-left: max(1rem, env(safe-area-inset-left));

View File

@ -1,7 +1,7 @@
@use "../../../styles/mixins";
.root {
@include mixins.header-pane;
@include mixins.middle-header-pane;
display: flex;
justify-content: center;

View File

@ -1,7 +1,7 @@
@use "../../../styles/mixins";
.ChatReportPane {
@include mixins.header-pane;
@include mixins.middle-header-pane;
display: flex;
align-items: center;

View File

@ -49,7 +49,7 @@
}
.fullWidth {
@include mixins.header-pane;
@include mixins.middle-header-pane;
height: 3.5rem;

View File

@ -1,7 +1,7 @@
@use "../../../styles/mixins";
.root {
@include mixins.header-pane;
@include mixins.middle-header-pane;
display: flex;
flex-direction: column;

View File

@ -12,6 +12,7 @@ import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal
import AboutAdsModal from './aboutAds/AboutAdsModal.async';
import AgeVerificationModal from './ageVerification/AgeVerificationModal.async';
import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async';
import BirthdaySetupModal from './birthday/BirthdaySetupModal.async';
import BoostModal from './boost/BoostModal.async';
import ChatInviteModal from './chatInvite/ChatInviteModal.async';
import ChatlistModal from './chatlist/ChatlistModal.async';
@ -109,7 +110,8 @@ type ModalKey = keyof Pick<TabState,
'isAgeVerificationModalOpen' |
'profileRatingModal' |
'quickPreview' |
'storyStealthModal'
'storyStealthModal' |
'birthdaySetupModal'
>;
type StateProps = {
@ -175,6 +177,7 @@ const MODALS: ModalRegistry = {
profileRatingModal: ProfileRatingModal,
quickPreview: QuickPreviewModal,
storyStealthModal: StealthModeModal,
birthdaySetupModal: BirthdaySetupModal,
};
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>;

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './BirthdaySetupModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const BirthdaySetupModalAsync = (props: OwnProps) => {
const { modal } = props;
const BirthdaySetupModal = useModuleLoader(Bundles.Extra, 'BirthdaySetupModal', !modal);
return BirthdaySetupModal ? <BirthdaySetupModal {...props} /> : undefined;
};
export default BirthdaySetupModalAsync;

View File

@ -0,0 +1,61 @@
.content {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
}
.header {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.inputs {
display: flex;
gap: 0.5rem;
}
.title {
font-size: 1.5rem;
}
.input {
flex-shrink: 1;
margin-bottom: 0;
}
.month {
flex-basis: 100%;
}
.footer {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
align-self: stretch;
}
.privacySuggestion {
margin-bottom: 0.5rem;
}
.monthDropdown {
flex-basis: 100%;
flex-shrink: 1;
margin-bottom: 0;
:global(.Menu) {
width: 100%;
margin-top: 0.25rem;
}
}
.monthBubble {
overflow: auto !important;
width: 100%;
min-width: auto !important;
max-height: 12rem;
}

View File

@ -0,0 +1,240 @@
import { memo, useMemo, useRef, useState } from '@teact';
import { getActions } from '../../../global';
import type { TabState } from '../../../global/types';
import { SettingsScreens } from '../../../types';
import buildClassName from '../../../util/buildClassName';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import Button from '../../ui/Button';
import DropdownMenu from '../../ui/DropdownMenu';
import InputText from '../../ui/InputText';
import Link from '../../ui/Link';
import MenuItem from '../../ui/MenuItem';
import Modal from '../../ui/Modal';
import styles from './BirthdaySetupModal.module.scss';
export type OwnProps = {
modal: TabState['birthdaySetupModal'];
};
const STICKER_SIZE = 120;
const MAX_AGE = 150;
const CURRENT_YEAR = new Date().getFullYear();
const MIN_YEAR = CURRENT_YEAR - MAX_AGE;
type MonthIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
const MONTH_INDEXES = Array.from({ length: 12 }, (_, index) => index + 1) as MonthIndex[];
const BirthdaySetupModal = ({ modal }: OwnProps) => {
const { closeBirthdaySetupModal, openSettingsScreen, updateBirthday } = getActions();
const { currentBirthday } = modal || {};
const dialogRef = useRef<HTMLDivElement>();
const [day, setDay] = useState<number | undefined>(currentBirthday?.day);
const [month, setMonth] = useState<MonthIndex | undefined>(currentBirthday?.month as MonthIndex | undefined);
const [year, setYear] = useState<number | undefined>(currentBirthday?.year);
const lang = useLang();
const handleClose = useLastCallback(() => {
closeBirthdaySetupModal();
});
const handleRemove = useLastCallback(() => {
updateBirthday({
birthday: undefined,
});
closeBirthdaySetupModal();
});
const handlePrivacyClick = useLastCallback(() => {
openSettingsScreen({ screen: SettingsScreens.PrivacyBirthday });
closeBirthdaySetupModal();
});
const maxDay = getMaxMonthDay(month, year);
const handleDayChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.value) {
setDay(undefined);
return;
}
const value = Number(e.target.value.replace(/[^\d]+/g, ''));
if (!value) {
e.preventDefault();
return;
}
if (value > maxDay) {
setDay(maxDay);
return;
}
setDay(Math.max(value, 1));
});
const handleMonthUpdate = useLastCallback((value: MonthIndex) => {
setMonth(value);
if (day) setDay(Math.min(day, getMaxMonthDay(value, year)));
});
const handleYearChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.value) {
setYear(undefined);
return;
}
const value = Number(e.target.value.replace(/[^\d]+/g, ''));
if (!value) {
e.preventDefault();
return;
}
if (value > CURRENT_YEAR) {
setYear(CURRENT_YEAR);
return;
}
setYear(value);
if (day) setDay(Math.min(day, getMaxMonthDay(month, value)));
});
const handleYearBlur = useLastCallback(() => {
if (!year) {
setYear(undefined);
return;
}
if (year < 100) {
setYear(1900 + year);
return;
}
if (year < MIN_YEAR) {
setYear(MIN_YEAR);
return;
}
});
const handleSubmit = useLastCallback(() => {
if (!day || !month) return;
updateBirthday({
birthday: {
day,
month,
year,
},
});
closeBirthdaySetupModal();
});
const MonthTrigger = useMemo(() => {
return ({ onTrigger, isOpen }: { onTrigger: () => void; isOpen?: boolean }) => (
<InputText
label={lang('BirthdayInputMonth')}
className={buildClassName(styles.input, styles.month, isOpen && 'active')}
value={month ? lang(`Month${month}`) : ''}
onClick={onTrigger}
inputMode="numeric"
teactExperimentControlled
/>
);
}, [lang, month]);
return (
<Modal
isOpen={Boolean(modal)}
hasCloseButton
hasAbsoluteCloseButton
isSlim
dialogRef={dialogRef}
contentClassName={styles.content}
onClose={handleClose}
>
<div className={styles.header}>
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.DuckCake}
size={STICKER_SIZE}
className="section-icon"
/>
<h3 className={styles.title}>{lang('BirthdaySetupTitle')}</h3>
</div>
<div className={styles.inputs}>
<InputText
label={lang('BirthdayInputDay')}
className={styles.input}
value={day?.toString()}
onChange={handleDayChange}
maxLength={2}
inputMode="numeric"
/>
<DropdownMenu
className={buildClassName(styles.monthDropdown, 'with-menu-transitions')}
bubbleClassName={styles.monthBubble}
autoClose
positionY="bottom"
trigger={MonthTrigger}
>
{MONTH_INDEXES.map((index: MonthIndex) => (
<MenuItem key={index} onClick={() => handleMonthUpdate(index)}>
{lang(`Month${index}`)}
</MenuItem>
))}
</DropdownMenu>
<InputText
label={lang('BirthdayInputYear')}
className={styles.input}
value={year?.toString()}
onBlur={handleYearBlur}
onChange={handleYearChange}
maxLength={4}
inputMode="numeric"
/>
</div>
<div className={styles.footer}>
<span className={styles.privacySuggestion}>
{lang('BirthdayPrivacySuggestion', {
link: <Link isPrimary onClick={handlePrivacyClick}>{lang('BirthdayPrivacySuggestionLink')}</Link>,
}, { withNodes: true })}
</span>
{currentBirthday && (
<Button isText onClick={handleRemove}>
{lang('BirthdayRemove')}
</Button>
)}
<Button
disabled={!day || !month}
onClick={handleSubmit}
>
{lang('Save')}
</Button>
</div>
</Modal>
);
};
const getMaxMonthDay = (month?: number, year?: number) => {
if (!month) return 31;
if (month === 2) {
const isLeapYear = year ? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) : true;
return isLeapYear ? 29 : 28;
}
if (month === 4 || month === 6 || month === 9 || month === 11) {
return 30;
}
return 31;
};
export default memo(BirthdaySetupModal);

View File

@ -1142,7 +1142,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
textParts={lang(
'AreYouSureShareMyContactInfoBot',
undefined,
{ withNodes: true, withMarkdown: true, renderTextFilters: ['br', 'emoji'],
{ withNodes: true, withMarkdown: true, renderTextFilters: ['br'],
})}
confirmHandler={handleAcceptPhone}
confirmLabel={lang('ContactShare')}

View File

@ -1,7 +1,7 @@
import type {
ChangeEvent, FormEvent,
} from 'react';
import type { ElementRef, FC } from '../../lib/teact/teact';
import type { ElementRef } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact';
import { IS_TAURI } from '../../util/browser/globalEnvironment';
@ -31,9 +31,10 @@ type OwnProps = {
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
onPaste?: (e: React.ClipboardEvent<HTMLInputElement>) => void;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
};
const InputText: FC<OwnProps> = ({
const InputText = ({
ref,
id,
className,
@ -55,7 +56,8 @@ const InputText: FC<OwnProps> = ({
onKeyDown,
onBlur,
onPaste,
}) => {
onClick,
}: OwnProps) => {
const lang = useLang();
const labelText = error || success || label;
const fullClassName = buildClassName(
@ -93,6 +95,7 @@ const InputText: FC<OwnProps> = ({
onPaste={onPaste}
aria-label={labelText}
teactExperimentControlled={teactExperimentControlled}
onClick={onClick}
/>
{labelText && (
<label htmlFor={id}>{labelText}</label>

View File

@ -103,6 +103,17 @@ addActionHandler('updateProfile', async (global, actions, payload): Promise<void
}
});
addActionHandler('updateBirthday', async (global, actions, payload): Promise<void> => {
const { birthday } = payload;
const { currentUserId } = global;
if (!currentUserId) return;
const result = await callApi('updateBirthday', birthday);
if (!result) return;
actions.loadFullUser({ userId: currentUserId });
});
addActionHandler('updateProfilePhoto', async (global, actions, payload): Promise<void> => {
const { photo, isFallback } = payload;
const { currentUserId } = global;
@ -395,7 +406,12 @@ addActionHandler('loadLanguages', async (global): Promise<void> => {
setGlobal(global);
});
addActionHandler('loadPrivacySettings', async (global): Promise<void> => {
addActionHandler('loadPrivacySettings', async (global, actions, payload): Promise<void> => {
const { skipIfCached } = payload;
if (skipIfCached && Object.keys(global.settings.privacy).length > 0) {
return;
}
if (selectIsCurrentUserFrozen(global)) return;
const result = await Promise.all([
@ -642,7 +658,7 @@ addActionHandler('ensureTimeFormat', async (global, actions): Promise<void> => {
addActionHandler('loadAppConfig', async (global, actions, payload): Promise<void> => {
const hash = payload?.hash;
const appConfig = await callApi('fetchAppConfig', hash);
const appConfig = await callApi('fetchAppConfig', { hash });
if (!appConfig) return;
requestActionTimeout({
@ -677,6 +693,33 @@ addActionHandler('loadConfig', async (global): Promise<void> => {
setGlobal(global);
});
addActionHandler('loadPromoData', async (global): Promise<void> => {
const promoData = await callApi('fetchPromoData');
if (!promoData) return;
global = getGlobal();
const timeout = promoData.expires - getServerTime();
if (timeout > 0) {
requestActionTimeout({
action: 'loadPromoData',
payload: undefined,
}, timeout * 1000);
}
global = {
...global,
promoData,
};
setGlobal(global);
});
addActionHandler('dismissSuggestion', async (global, actions, payload): Promise<void> => {
const { suggestion } = payload;
await callApi('dismissSuggestion', suggestion);
actions.loadPromoData();
});
addActionHandler('loadPeerColors', async (global): Promise<void> => {
const generalHash = global.peerColors?.generalHash;
const profileHash = global.peerColors?.profileHash;

View File

@ -21,6 +21,7 @@ import * as langProvider from '../../../util/oldLangProvider';
import updateIcon from '../../../util/updateIcon';
import { setPageTitle, setPageTitleInstant } from '../../../util/updatePageTitle';
import { getAllowedAttachmentOptions, getChatTitle } from '../../helpers';
import { addTabStateResetterAction } from '../../helpers/meta';
import {
addActionHandler, getActions, getGlobal, setGlobal,
} from '../../index';
@ -894,6 +895,17 @@ addActionHandler('closeCollectibleInfoModal', (global, actions, payload): Action
}, tabId);
});
addActionHandler('openBirthdaySetupModal', (global, actions, payload): ActionReturnType => {
const { currentBirthday, tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
birthdaySetupModal: {
currentBirthday,
},
}, tabId);
});
addTabStateResetterAction('closeBirthdaySetupModal', 'birthdaySetupModal');
addActionHandler('setShouldCloseRightColumn', (global, actions, payload): ActionReturnType => {
const { value, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {

View File

@ -163,6 +163,8 @@ addActionHandler('openLeftColumnContent', (global, actions, payload): ActionRetu
addActionHandler('openSettingsScreen', (global, actions, payload): ActionReturnType => {
const { screen, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
actions.loadPrivacySettings({ skipIfCached: true });
// Force settings only if new screen is passed, do not on resets
if (payload.screen !== undefined) actions.openLeftColumnContent({ contentKey: LeftColumnContent.Settings, tabId });
return updateTabState(global, {

View File

@ -11,3 +11,9 @@ export function selectSharedSettings<T extends GlobalState>(
) {
return selectSharedState(global).settings;
}
export function selectAnimationLevel<T extends GlobalState>(
global: T,
) {
return selectSharedSettings(global).animationLevel;
}

View File

@ -1,6 +1,7 @@
import type {
ApiAttachBot,
ApiAttachment,
ApiBirthday,
ApiChat,
ApiChatAdminRights,
ApiChatBannedRights,
@ -249,7 +250,9 @@ export interface ActionPayloads {
notificationSoundVolume?: number;
};
loadLanguages: undefined;
loadPrivacySettings: undefined;
loadPrivacySettings: {
skipIfCached?: boolean;
};
setPrivacyVisibility: {
privacyKey: ApiPrivacyKey;
visibility: PrivacyVisibility;
@ -1831,6 +1834,13 @@ export interface ActionPayloads {
bio?: string;
username?: string;
} & WithTabId;
updateBirthday: {
birthday?: ApiBirthday;
};
openBirthdaySetupModal: {
currentBirthday?: ApiBirthday;
} & WithTabId;
closeBirthdaySetupModal: WithTabId | undefined;
updateBotProfile: {
photo?: File;
firstName?: string;
@ -2452,6 +2462,10 @@ export interface ActionPayloads {
loadAppConfig: {
hash: number;
} | undefined;
loadPromoData: undefined;
dismissSuggestion: {
suggestion: string;
} & WithTabId;
loadPeerColors: undefined;
loadTimezones: undefined;
openLeftColumnContent: {

View File

@ -24,6 +24,7 @@ import type {
ApiPoll,
ApiPrivacyKey,
ApiPrivacySettings,
ApiPromoData,
ApiQuickReply,
ApiReaction,
ApiReactionKey,
@ -81,6 +82,7 @@ export type GlobalState = {
isInited: boolean;
config?: ApiConfig;
appConfig: ApiAppConfig;
promoData?: ApiPromoData;
peerColors?: ApiPeerColors;
timezones?: {
byId: Record<string, ApiTimezone>;

View File

@ -1,5 +1,6 @@
import type {
ApiAttachBot,
ApiBirthday,
ApiBoost,
ApiBoostsStatus,
ApiChannelMonetizationStatistics,
@ -783,6 +784,10 @@ export type TabState = {
isAgeVerificationModalOpen?: boolean;
birthdaySetupModal?: {
currentBirthday?: ApiBirthday;
};
paidReactionModal?: {
chatId: string;
messageId: number;

View File

@ -1586,6 +1586,7 @@ account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool;
account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses;
account.reorderUsernames#ef500eab order:Vector<string> = Bool;
account.toggleUsername#58d6b376 username:string active:Bool = Bool;
account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool;
account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks;
account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool;
account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses;
@ -1781,6 +1782,8 @@ help.getNearestDc#1fb33026 = NearestDc;
help.getSupport#9cdf08cd = help.Support;
help.acceptTermsOfService#ee72f79a id:DataJSON = Bool;
help.getAppConfig#61e3f854 hash:int = help.AppConfig;
help.getPromoData#c0977421 = help.PromoData;
help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool;
help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList;
help.getPremiumPromo#b81b93d4 = help.PremiumPromo;
help.getPeerColors#da80f42f hash:int = help.PeerColors;

View File

@ -58,6 +58,7 @@
"account.getRecentEmojiStatuses",
"account.reorderUsernames",
"account.toggleUsername",
"account.updateBirthday",
"account.resolveBusinessChatLink",
"account.toggleSponsoredMessages",
"account.getCollectibleEmojiStatuses",
@ -251,14 +252,16 @@
"upload.getWebFile",
"help.getConfig",
"help.getNearestDc",
"help.getAppConfig",
"help.getSupport",
"help.acceptTermsOfService",
"help.getPromoData",
"help.getCountriesList",
"help.getAppConfig",
"help.getPremiumPromo",
"help.dismissSuggestion",
"help.getPeerColors",
"help.getPeerProfileColors",
"help.getTimezonesList",
"help.getPremiumPromo",
"channels.readHistory",
"channels.deleteMessages",
"channels.deleteParticipantHistory",

View File

@ -121,7 +121,7 @@
}
}
@mixin header-pane {
@mixin middle-header-pane {
position: absolute;
top: 0;
transform: translateY(-100%);
@ -169,6 +169,34 @@
}
}
@mixin chat-list-pane {
position: absolute;
top: 0;
transform: translateY(calc(-100% - 0.5rem)); // Include top margin to hide fully
width: 100%;
padding: 0.5625rem;
background-color: var(--color-background);
transition: transform var(--chat-transform-transition);
// Some panels might unmount without animation, so we provide same background above panel to make it less noticeable
&::after {
content: "";
position: absolute;
z-index: -1;
top: -100%;
right: 0;
left: 0;
height: inherit;
background-color: inherit;
}
}
@mixin side-panel-section {
border-bottom: 0.625rem solid var(--color-background-secondary);
background-color: var(--color-background);

View File

@ -301,6 +301,7 @@ $color-message-story-mention-to: #74bcff;
--layer-transition-behind: 300ms cubic-bezier(0.33, 1, 0.68, 1);
--slide-transition: 300ms cubic-bezier(0.25, 1, 0.5, 1);
--select-transition: 200ms ease-out;
--chat-transform-transition: 0.2s ease-out;
--safe-area-top: env(safe-area-inset-top);
--safe-area-right: env(safe-area-inset-right);

View File

@ -233,6 +233,10 @@ body:not(.is-ios) {
touch-action: none;
}
.flex-grow {
flex-grow: 1;
}
.icon {
@include iconsMixins.icon;
}

View File

@ -1230,6 +1230,7 @@ export interface LangPair {
'MenuNightMode': undefined;
'AriaMenuEnableNightMode': undefined;
'AriaMenuDisableNightMode': undefined;
'AriaSettingsEditProfilePhoto': undefined;
'MenuAnimationsSwitch': undefined;
'MenuTelegramFeatures': undefined;
'TelegramFeaturesUsername': undefined;
@ -1787,6 +1788,19 @@ export interface LangPair {
'StarGiftPriceDecreaseInfoLink': undefined;
'StarGiftUpgradeCostModalTitle': undefined;
'StarGiftUpgradeCostHint': undefined;
'UnconfirmedAuthDeniedTitle': undefined;
'UnconfirmedAuthTitle': undefined;
'UnconfirmedAuthConfirm': undefined;
'UnconfirmedAuthDeny': undefined;
'SuggestionBirthdaySetupTitle': undefined;
'SuggestionBirthdaySetupMessage': undefined;
'BirthdaySetupTitle': undefined;
'BirthdayInputDay': undefined;
'BirthdayInputMonth': undefined;
'BirthdayInputYear': undefined;
'BirthdayRemove': undefined;
'BirthdayPrivacySuggestionLink': undefined;
'SettingsBirthday': undefined;
}
export interface LangPairWithVariables<V = LangVariable> {
@ -3084,6 +3098,24 @@ export interface LangPairWithVariables<V = LangVariable> {
'StarGiftPriceDecreaseTimer': {
'timer': V;
};
'UnconfirmedAuthDeniedMessage': {
'location': V;
};
'UnconfirmedAuthSingle': {
'location': V;
};
'UnconfirmedAuthLocationRegion': {
'deviceModel': V;
'region': V;
'country': V;
};
'UnconfirmedAuthLocationCountry': {
'deviceModel': V;
'country': V;
};
'BirthdayPrivacySuggestion': {
'link': V;
};
}
export interface LangPairPlural {

View File

@ -32,7 +32,7 @@ import { notifyLangpackUpdate } from '../browser/multitab';
import { createCallbackManager } from '../callbacks';
import readFallbackStrings from '../data/readFallbackStrings';
import { initialEstablishmentPromise, isCurrentTabMaster } from '../establishMultitabRole';
import { omit, unique } from '../iteratees';
import { omit } from '../iteratees';
import { replaceInStringsWithTeact } from '../replaceWithTeact';
import { fastRaf } from '../schedulers';
@ -411,40 +411,46 @@ function processTranslationAdvanced(
const variableEntries = variables ? Object.entries(variables) : [];
let tempResult: TeactNode = [string];
let tempResult: TeactNode = string;
if (options?.specialReplacement) {
const specialReplacements = Object.entries(options.specialReplacement);
tempResult = specialReplacements.reduce((acc, [key, value]) => {
return replaceInStringsWithTeact(acc, key, value);
}, tempResult);
}, tempResult as TeactNode);
}
const withRenderText = options?.withMarkdown || options?.renderTextFilters;
const withRenderText = options?.withNodes;
if (withRenderText) {
const filters = options?.withMarkdown
? unique((options.renderTextFilters || []).concat(['simple_markdown', 'emoji']))
: options.renderTextFilters;
const textFiltersSet = new Set(options?.renderTextFilters);
textFiltersSet.add('emoji');
return tempResult.flatMap((curr: TeactNode) => {
if (options?.withMarkdown) {
textFiltersSet.add('simple_markdown');
}
const filters = Array.from(textFiltersSet);
const tempResultArray = Array.isArray(tempResult) ? tempResult : [tempResult];
return tempResultArray.flatMap((curr: TeactNode) => {
if (typeof curr !== 'string') {
return curr;
}
return renderText(curr, filters, {
markdownPostProcessor: (part: string) => {
return variableEntries.reduce((result, [key, value]): TeactNode[] => {
return variableEntries.reduce((result, [key, value]): TeactNode => {
if (value === undefined) return result;
const preparedValue = Number.isFinite(value) ? formatters!.number.format(value as number) : value;
return replaceInStringsWithTeact(result, `{${key}}`, renderText(preparedValue));
}, [part] as TeactNode[]);
}, part as TeactNode);
},
});
});
}
return variableEntries.reduce((result, [key, value]): TeactNode[] => {
return variableEntries.reduce((result, [key, value]): TeactNode => {
if (value === undefined) return result;
const preparedValue = Number.isFinite(value) ? formatters!.number.format(value as number) : value;

View File

@ -12,10 +12,14 @@ export function replaceWithTeact(
}
export function replaceInStringsWithTeact(
input: TeactNode[], searchValue: string | RegExp, replaceValue: TeactNode,
) {
input: TeactNode, searchValue: string | RegExp, replaceValue: TeactNode,
): TeactNode {
if (typeof input === 'string') return replaceWithTeact(input, searchValue, replaceValue);
if (!input || !Array.isArray(input)) return input;
return input.flatMap((curr: TeactNode) => {
if (typeof curr === 'string') return replaceWithTeact(curr, searchValue, replaceValue);
if (Array.isArray(curr)) return replaceInStringsWithTeact(curr, searchValue, replaceValue);
return curr;
}, []);
});
}